Skip to content

Remove race conditions from File.Move and Directory.Move #33370

Closed
joshudson wants to merge 8 commits into
dotnet:mainfrom
joshudson:master
Closed

Remove race conditions from File.Move and Directory.Move #33370
joshudson wants to merge 8 commits into
dotnet:mainfrom
joshudson:master

Conversation

@joshudson

@joshudson joshudson commented Mar 9, 2020

Copy link
Copy Markdown
Contributor

(Directory.Move only applies on Linux)

Fixes #31228

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment about performance of trying link() on FAT is clearly not correct because the syscall fast-fails so if you have to perform any checks before deciding to rename() it's slower.

@jkotas jkotas changed the title #31228 remove race conditions from File.Move and Directory.Move Remove race conditions from File.Move and Directory.Move Mar 9, 2020
@jhudsoncedaron

Copy link
Copy Markdown

Azure builds die with

🍺 /usr/local/Cellar/pkg-config/0.29.2: 11 files, 626.9KB
Error: python 3.7.6_1 is already installed
To upgrade to 3.7.7, run brew upgrade python.

##[error]Bash exited with code '1'.
Finishing: Install native dependencies

Not mine!

@joshudson

Copy link
Copy Markdown
Contributor Author

(NETCORE_ENGINEERING_TELEMETRY=InitializeToolset) Failed to install dotnet SDK from public location (exit code '1').

Not mine again!

@joshudson

Copy link
Copy Markdown
Contributor Author

Ok somebody else needs to look at this. Remaining failed test cases:

System.IO.Tests.DirectoryInfo_MoveTo.MoveDirectory_NoOpWhenMovingDirectoryWithUpperCaseToOtherDirectoryWithLowerCase
System.IO.IOException : Cannot create '/var/folders/j1/76_p663903g5g2rg7ckqbv440000gy/T/DirectoryInfo_MoveTo_xz4to5q4.r0u/bar/foo' because a file or directory with the same name already exists.
at System.IO.FileSystem.MoveDirectory(String sourceFullPath, String destFullPath) in //src/libraries/System.IO.FileSystem/src/System/IO/FileSystem.Unix.cs:line 317
at System.IO.Directory.Move(String sourceDirName, String destDirName) in /
/src/libraries/System.IO.FileSystem/src/System/IO/Directory.cs:line 300
at System.IO.Tests.Directory_Move.MoveDirectory_NoOpWhenMovingDirectoryWithUpperCaseToOtherDirectoryWithLowerCase() in /_/src/libraries/System.IO.FileSystem/tests/Directory/Move.cs:line 341

This one is testing for
Directory.Create("foo");
Directory.Create("bar/FOO");
Directory.Move("foo", "bar/foo");

actually succeeding and clobbering bar/FOO. I'm convinced my patch behaves correctly and the test is just wrong, because the documentation says it won't clobber the directory.

The remaining two failing tests are:

System.IO.Tests.File_Move_Tests.Unix_File_Move_To_Same_Director
Renamed event did not occur as expected\nExpected: True\nActual: False
at System.IO.Tests.FileSystemWatcherTest.ExecuteAndVerifyEvents(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Boolean assertExpected, String[] expectedPaths, Int32 timeout) in //src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs:line 339
at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String[] expectedPaths, Int32 attempts, Int32 timeout) in /
/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs:line 227
at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String expectedPath, Int32 attempts, Int32 timeout) in //src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs:line 197
at System.IO.Tests.File_Move_Tests.FileMove_SameDirectory(WatcherChangeTypes eventType) in /
/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs:line 142
at System.IO.Tests.File_Move_Tests.Unix_File_Move_To_Same_Directory() in /_/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs:line 24

System.IO.Tests.File_Move_Tests.Unix_File_Move_With_Set_NotifyFilte
Renamed event did not occur as expected\nExpected: True\nActual: False
at System.IO.Tests.FileSystemWatcherTest.ExecuteAndVerifyEvents(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Boolean assertExpected, String[] expectedPaths, Int32 timeout) in //src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs:line 339
at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String[] expectedPaths, Int32 attempts, Int32 timeout) in /
/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs:line 227
at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String expectedPath, Int32 attempts, Int32 timeout) in //src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs:line 197
at System.IO.Tests.File_Move_Tests.FileMove_WithNotifyFilter(WatcherChangeTypes eventType) in /
/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs:line 311
at System.IO.Tests.File_Move_Tests.Unix_File_Move_With_Set_NotifyFilter() in /_/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs:line 120

which check if File.Move() actually does a rename operation. But you can't do rename() and have race-free clobber avoidance here. The OS for which this is failing is Mac OSX. The only race-free solution involves link() and unlink().

Comment thread src/libraries/System.IO.FileSystem/src/System/IO/FileSystem.Unix.cs Outdated

@adamsitnik adamsitnik left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @joshudson

First of all, I want to thank you for your work and apologize for the delay in the reviewing process.

Could you please respond to my comments and also solve the conflicts?

Thanks,
Adam

Comment thread src/libraries/System.IO.FileSystem/src/System/IO/FileSystem.Unix.cs Outdated
Comment thread src/libraries/Native/Unix/System.Native/pal_io.c
}
else
{
#if HAVE_RENAMEAT2 && defined(RENAME_NOREPLACE)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

just for my education: is it possible to have HAVE_RENAMEAT2 but no RENAME_NOREPLACE defined?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's feature probing. Clearly both must exist or it won't work. Officially, we don't need to know. I think it's possible, but I'm not certain.

else if (errno == EEXIST)
{
// But renameat2 returns EEXIST on renaming a file onto itself. Check that.
result = RenameOnEexist(result, NULL, oldPath, newPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on renaming a file onto itself

Is this a breaking change? I would expect that previously we would have thrown an exception in the managed layer because FileExists would return true?

Do we have a unit test that covers this path?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No unit test is possible because we can't demand root to run unit tests. We can't get here if the name components are equal.

// Linux renameat2() provides the functionality we want inline.
// This is in a feature check block as this API may well be ported to other OSes later.
do {
result = renameat2(AT_FDCWD, oldPath, AT_FDCWD, newPath, RENAME_NOREPLACE);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for using AT_FDCWD here and for taking care of the edge case! 👍

Could you please add a comment about why it's important to use it here? (I had to check the docs to find out)

Is there any chance that you could add a unit test that would guard us from removing this flag in the future?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm calling renameat2() for RENAME_NOREPLACE. AT_FDCWD is what you pass if you don't have anything else to pass in that slot.

@joshudson joshudson Jan 9, 2021

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

On third attempt, pronoun [it] is ambiguous. Do you mean AT_FDCWD or RENAME_NOREPLACE? A unit test for missing RENAME_NOREPLACE would just check that MoveFile(,,false) doesn't ovewrite its target and I think that already exists. If AT_FDCWD isn't passed, it will fail pretty hard as handle 0 won't be a directory handle.

Comment thread src/libraries/Native/Unix/System.Native/pal_io.c
result = -1; // Keeps compiler happy.
if (!(flags & 2))
{
result = link(oldPath, newPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
result = link(oldPath, newPath);
result = link(oldPath, newPath); // link does not overwrite newPath if it exists

result = link(oldPath, newPath);
if (result >= 0)
{
while ((result = unlink(oldPath)) < 0 && errno == EINTR);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

what are the side effects of using this approach (link and unlink) compared to what we had before (check + rename with TOCTOU as a side effect)? can it lead to any new problems? (please keep in mind that I am not a Unix expert)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Side effect: File system watcher looking for rename() won't ever see it.

Incidentally I had to go through our own codebase ten years ago and systematically swap File.Move() for P/Invoke of CreateHardLink because somebody deployed our application on Windows pointing at a NAS box and hit the race condition where Windows resolving a UNC to a NAS box will inherently have this race. There is one other side effect, that is if the application crashes, you can see both names at once. But it's not really a side effect on Unix, because that can happen anyway on power loss.

Comment thread src/libraries/Native/Unix/System.Native/pal_io.c Outdated
@joshudson

Copy link
Copy Markdown
Contributor Author

Hey just to let you know I'm not ignoring this. I've got some time off upcoming and I intend to look at this during that time.

// file, if it exists. If deletion fails for a reason other than the file not existing, fail.
if (Interop.Sys.Unlink(destBackupFullPath) != 0)
// We're backing up the destination file to the backup file. If it exists, it will be removed if possible.
if (Interop.Sys.Rename(destFullPath, destBackupFullPath, 1) < 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is the call Interop.Sys.Rename(destFullPath, destBackupFullPath, 1) identical to calling the method Interop.Sys.Rename(string oldPath, string newPath) that existed prior to this PR?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's supposed to be.

// file, if it exists. If deletion fails for a reason other than the file not existing, fail.
if (Interop.Sys.Unlink(destBackupFullPath) != 0)
// We're backing up the destination file to the backup file. If it exists, it will be removed if possible.
if (Interop.Sys.Rename(destFullPath, destBackupFullPath, 1) < 0)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is < 0 correct or should it be != 0 instead?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's a style debate, not a functional debate. I kept the original return values of 0 for success and -1 for failure.

errno.Error == Interop.Error.EMLINK) // too many hard links to the source file
{
// Destination file could not be renamed. Copy it.
CopyFile(destFullPath, destBackupFullPath, true);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why are we no longer attempting to create a link before a copy for the destBackupFullPath?

@joshudson joshudson Jan 5, 2021

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Because if you hit any of those errors, link() won't work either.

Comment thread src/libraries/System.IO.FileSystem/src/System/IO/FileSystem.Unix.cs Outdated
Comment thread src/libraries/Native/Unix/System.Native/pal_io.c
Comment thread src/libraries/System.IO.FileSystem/src/System/IO/FileSystem.Unix.cs
@joshudson

Copy link
Copy Markdown
Contributor Author

I'm blocked on #46301 ; the tree doesn't build referencing SOS_readme.md

@joshudson

Copy link
Copy Markdown
Contributor Author

I fixed minor problems from review; however I ran into problems dealing with File.Replace. Filed doc issue dotnet/docs#22288 ; the code might also be wrong in master.

@danmoseley

Copy link
Copy Markdown
Member

Some test failures:

    System.IO.Tests.File_Move_Tests.Unix_File_Move_With_Set_NotifyFilter [FAIL]
      Renamed event did not occur as expected
      Expected: True
      Actual:   False
      Stack Trace:
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(337,0): at System.IO.Tests.FileSystemWatcherTest.ExecuteAndVerifyEvents(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Boolean assertExpected, String[] expectedPaths, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(225,0): at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String[] expectedPaths, Int32 attempts, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(195,0): at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String expectedPath, Int32 attempts, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs(311,0): at System.IO.Tests.File_Move_Tests.FileMove_WithNotifyFilter(WatcherChangeTypes eventType)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs(120,0): at System.IO.Tests.File_Move_Tests.Unix_File_Move_With_Set_NotifyFilter()
      Renamed event did not occur as expected
      Expected: True
      Actual:   False
    System.IO.Tests.File_Move_Tests.Unix_File_Move_In_Nested_Directory(includeSubdirectories: True) [FAIL]
      Stack Trace:
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(337,0): at System.IO.Tests.FileSystemWatcherTest.ExecuteAndVerifyEvents(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Boolean assertExpected, String[] expectedPaths, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(225,0): at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String[] expectedPaths, Int32 attempts, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(195,0): at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String expectedPath, Int32 attempts, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs(290,0): at System.IO.Tests.File_Move_Tests.FileMove_NestedDirectory(WatcherChangeTypes eventType, Boolean includeSubdirectories)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs(106,0): at System.IO.Tests.File_Move_Tests.Unix_File_Move_In_Nested_Directory(Boolean includeSubdirectories)
    System.IO.Tests.File_Move_Tests.Unix_File_Move_To_Same_Directory [FAIL]
      Renamed event did not occur as expected
      Expected: True
      Actual:   False
      Stack Trace:
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(337,0): at System.IO.Tests.FileSystemWatcherTest.ExecuteAndVerifyEvents(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Boolean assertExpected, String[] expectedPaths, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(225,0): at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String[] expectedPaths, Int32 attempts, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs(195,0): at System.IO.Tests.FileSystemWatcherTest.ExpectEvent(FileSystemWatcher watcher, WatcherChangeTypes expectedEvents, Action action, Action cleanup, String expectedPath, Int32 attempts, Int32 timeout)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs(142,0): at System.IO.Tests.File_Move_Tests.FileMove_SameDirectory(WatcherChangeTypes eventType)
        /_/src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.File.Move.cs(24,0): at System.IO.Tests.File_Move_Tests.Unix_File_Move_To_Same_Directory()

@joshudson

Copy link
Copy Markdown
Contributor Author

@danmosemsft : Yes, that's a decision that has to be made. I left these failing until we decide we're good with the filesystem watcher not seeing any rename events on File.Move(..., ..., false); until the build target has a high enough libc, or carry around the syscall definition of renameat2() so we can call it w/o libc.

@danmoseley

Copy link
Copy Markdown
Member

@adamsitnik thoughts about next steps for this PR?

Base automatically changed from master to main March 1, 2021 09:06
@danmoseley

Copy link
Copy Markdown
Member

We don't seem to be making progress on this PR. Regretfully, I'm going to close it. It can always be reopened .

@danmoseley danmoseley closed this Mar 19, 2021
@ghost ghost locked as resolved and limited conversation to collaborators Apr 18, 2021
@karelz karelz added this to the 6.0.0 milestone May 20, 2021
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

System.IO.Directory.Move claims to throw when the target already exists; however it does not do so reliably.

8 participants