-
Notifications
You must be signed in to change notification settings - Fork 1.4k
Fix race condition in WriteLinesToFile transactional mode (#13323) #13477
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
56c5b56
6ed6b98
16ac14c
e37c38f
c7bc50f
e407fd1
9e37e8c
1f0a46e
af74929
1ee094d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -22,6 +22,29 @@ public class WriteLinesToFile : TaskExtension, IIncrementalTask, IMultiThreadabl | |
| // Default encoding taken from System.IO.WriteAllText() | ||
| private static readonly Encoding s_defaultEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false, throwOnInvalidBytes: true); | ||
|
|
||
| /// <summary> | ||
| /// Moves <paramref name="source"/> to <paramref name="destination"/>, overwriting it if it exists. | ||
| /// Uses <c>Microsoft.IO.File</c> on .NET Framework to access the overwrite overload, | ||
| /// which is available natively in <c>System.IO.File</c> on .NET 6+. | ||
| /// </summary> | ||
| private static void MoveFileWithOverwrite(string source, string destination) | ||
| { | ||
| #if NETFRAMEWORK | ||
| // Microsoft.IO.Redist backports File.Move(overwrite) to .NET Framework. | ||
| Microsoft.IO.File.Move(source, destination, overwrite: true); | ||
| #elif NET | ||
| // File.Move(overwrite) is available natively on .NET 5+. | ||
| System.IO.File.Move(source, destination, overwrite: true); | ||
| #else | ||
| // netstandard2.0: overwrite overload unavailable; delete destination first. | ||
| if (System.IO.File.Exists(destination)) | ||
| { | ||
| System.IO.File.Delete(destination); | ||
| } | ||
| System.IO.File.Move(source, destination); | ||
| #endif | ||
| } | ||
|
|
||
| /// <inheritdoc /> | ||
| public TaskEnvironment TaskEnvironment { get; set; } = TaskEnvironment.Fallback; | ||
|
|
||
|
|
@@ -181,8 +204,10 @@ private bool ExecuteTransactional(AbsolutePath filePath, string directoryPath, s | |
| } | ||
|
|
||
| /// <summary> | ||
| /// Saves content to file atomically using a temporary file, following the Visual Studio editor pattern. | ||
| /// This is for overwrite mode where we write the entire content. | ||
| /// Saves content to file atomically using a temporary file. | ||
| /// Writes to a temp file first, then moves it to the target with overwrite, | ||
| /// which handles both the "target exists" and "target doesn't exist" cases in a | ||
| /// single call and eliminates the race window present in a check-then-act pattern. | ||
| /// </summary> | ||
| private bool SaveAtomically(AbsolutePath filePath, string contentsAsString, Encoding encoding) | ||
| { | ||
|
|
@@ -197,63 +222,33 @@ private bool SaveAtomically(AbsolutePath filePath, string contentsAsString, Enco | |
| // Write content to temporary file | ||
| System.IO.File.WriteAllText(temporaryFilePath, contentsAsString, encoding); | ||
|
|
||
| // Attempt to atomically replace target file with temporary file | ||
| try | ||
| { | ||
| // Replace the contents of filePath with the contents of the temporary using File.Replace | ||
| // to preserve the various attributes of the original file. | ||
| System.IO.File.Replace(temporaryFilePath, filePath, null, true); | ||
| temporaryFilePath = null; // Mark as successfully replaced | ||
| return !Log.HasLoggedErrors; | ||
| } | ||
| catch (FileNotFoundException) | ||
| { | ||
| // The target file doesn't exist, which is fine. Move the temp file to target. | ||
| try | ||
| { | ||
| System.IO.File.Move(temporaryFilePath, filePath); | ||
| temporaryFilePath = null; // Mark as successfully moved | ||
| return !Log.HasLoggedErrors; | ||
| } | ||
| catch (IOException moveEx) | ||
| { | ||
| // Move failed, log and return | ||
| string lockedFileMessage = LockCheck.GetLockedFileMessage(filePath); | ||
| Log.LogErrorWithCodeFromResources("WriteLinesToFile.ErrorOrWarning", filePath.OriginalValue, moveEx.Message, lockedFileMessage); | ||
| return !Log.HasLoggedErrors; | ||
| } | ||
| } | ||
| catch (IOException) | ||
| // Atomically move temp file to target, overwriting if it already exists. | ||
| // Using overwrite: true handles concurrent writes without a race condition — | ||
| // both "target doesn't exist" and "target already exists" cases are covered | ||
| // by a single operation, with no window between them. | ||
| const int maxAttempts = 3; | ||
| Exception lastException = null; | ||
| for (int attempt = 1; attempt <= maxAttempts; attempt++) | ||
| { | ||
| // Replace failed (likely file is locked). Retry a few times with small delay. | ||
| for (int retry = 1; retry < 3; retry++) | ||
| if (attempt > 1) | ||
| { | ||
| try | ||
| { | ||
| System.Threading.Thread.Sleep(10); | ||
| System.IO.File.Replace(temporaryFilePath, filePath, null, true); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this completely gets rid of the Replace path
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. File.Replace was used in the original PR because it uses the Windows ReplaceFile API which preserves file identity (attributes, security, timestamps). However, it requires the destination to already exist — throwing FileNotFoundException otherwise — and the fallback File.Move in that catch block was exactly where the race condition lived (another thread could create the destination in that window). File.Move(overwrite: true) handles both the "exists" and "not exists" cases atomically in a single call, eliminating the race window entirely. The tradeoff is losing attribute preservation, but correctness under concurrent writes takes priority here.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In what case would the file identity matter? aren't you creating the file right before moving it anyway?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Sorry for the confusion in my previous reply — saying "the tradeoff is losing attribute preservation" was misleading.. since we always create a fresh temp file first, there's no meaningful identity to preserve regardless. The File.Replace approach was unnecessary complexity. |
||
| temporaryFilePath = null; // Mark as successfully replaced | ||
| return !Log.HasLoggedErrors; | ||
| } | ||
| catch (IOException) | ||
| { | ||
| // Continue to next retry | ||
| } | ||
| // Log the retry so concurrency issues are visible when diagnosing builds. | ||
| Log.LogMessageFromResources(MessageImportance.High, "WriteLinesToFile.Retry", | ||
| filePath.OriginalValue, attempt, maxAttempts, lastException.Message); | ||
| System.Threading.Thread.Sleep(10); | ||
|
JanProvaznik marked this conversation as resolved.
|
||
| } | ||
|
|
||
| // Retries exhausted. Try simple write as fallback. | ||
| try | ||
| { | ||
| System.IO.File.WriteAllText(filePath, contentsAsString, encoding); | ||
| temporaryFilePath = null; // Mark temp as not needed | ||
| MoveFileWithOverwrite(temporaryFilePath, filePath); | ||
| temporaryFilePath = null; | ||
| return !Log.HasLoggedErrors; | ||
| } | ||
| catch (Exception fallbackEx) when (ExceptionHandling.IsIoRelatedException(fallbackEx)) | ||
| catch (Exception ex) when (attempt < maxAttempts && ExceptionHandling.IsIoRelatedException(ex)) | ||
| { | ||
| string lockedFileMessage = LockCheck.GetLockedFileMessage(filePath); | ||
| Log.LogErrorWithCodeFromResources("WriteLinesToFile.ErrorOrWarning", filePath.OriginalValue, fallbackEx.Message, lockedFileMessage); | ||
| return !Log.HasLoggedErrors; | ||
| lastException = ex; | ||
| } | ||
| // On the last attempt, the exception propagates to the outer handler. | ||
| } | ||
| } | ||
| catch (Exception e) when (ExceptionHandling.IsIoRelatedException(e)) | ||
|
|
@@ -280,6 +275,8 @@ private bool SaveAtomically(AbsolutePath filePath, string contentsAsString, Enco | |
| } | ||
| } | ||
| } | ||
|
|
||
| return !Log.HasLoggedErrors; | ||
| } | ||
|
|
||
| /// <summary> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
this retry mechanism seems reasonalbe