Skip to content

Fix race condition in WriteLinesToFile transactional mode (#13323)#13477

Open
huulinhnguyen-dev wants to merge 10 commits intodotnet:mainfrom
huulinhnguyen-dev:dev/huulinhnguyen/issue13323_flaky_test_transactional
Open

Fix race condition in WriteLinesToFile transactional mode (#13323)#13477
huulinhnguyen-dev wants to merge 10 commits intodotnet:mainfrom
huulinhnguyen-dev:dev/huulinhnguyen/issue13323_flaky_test_transactional

Conversation

@huulinhnguyen-dev
Copy link
Copy Markdown
Contributor

Fixes #13323

Context

Concurrent parallel builds writing to the same file with Overwrite="true" and transactional mode could throw IOException: Cannot create a file when that file already exists. This happened because File.Move failed when another thread created the target file between the Replace check and the Move call.

Changes Made

  • In WriteLinesToFile.cs: when File.Move fails with IOException, retry using File.Replace since the target file now exists
  • In WriteLinesToFile_Tests.cs: rename TransactionalModePreservesAllDataTransactionalModeSucceedsWithConcurrentOverwrites to accurately reflect test behavior (Overwrite="true" means only the last writer survives, not all data)

Testing

Existing test TransactionalModeSucceedsWithConcurrentOverwrites covers the concurrent overwrite scenario with parallel MSBuild projects.

huulinhnguyen-dev and others added 6 commits April 1, 2026 13:50
Copilot AI review requested due to automatic review settings April 1, 2026 08:02
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Fixes a race in the built-in WriteLinesToFile task’s transactional overwrite path where concurrent writers could cause File.Move to fail with “file already exists”, by falling back to File.Replace when the destination appears mid-operation.

Changes:

  • Add a transactional overwrite recovery path: if File.Move fails and the destination now exists, retry via File.Replace with a small bounded retry loop.
  • Rename the flaky/misleading concurrent overwrite test to reflect actual Overwrite="true" semantics.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

File Description
src/Tasks/FileIO/WriteLinesToFile.cs Adds a File.Replace-based fallback when Move fails due to a concurrent create, improving reliability under parallel builds.
src/Tasks.UnitTests/WriteLinesToFile_Tests.cs Renames a test to better describe the concurrent overwrite behavior being validated.

Comment thread src/Tasks/FileIO/WriteLinesToFile.cs Outdated
@huulinhnguyen-dev huulinhnguyen-dev marked this pull request as draft April 1, 2026 08:06
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.

Comment thread src/Tasks/FileIO/WriteLinesToFile.cs Outdated
@huulinhnguyen-dev huulinhnguyen-dev marked this pull request as ready for review April 2, 2026 08:13
@huulinhnguyen-dev huulinhnguyen-dev marked this pull request as draft April 2, 2026 09:30
@huulinhnguyen-dev huulinhnguyen-dev marked this pull request as ready for review April 2, 2026 10:52
Copy link
Copy Markdown
Member

@JanProvaznik JanProvaznik left a comment

Choose a reason for hiding this comment

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

This is a bandaid on a more fundamental problem. Please consider how to do it properly with the Move with overwrite based on my comments.

Comment thread src/Tasks/FileIO/WriteLinesToFile.cs Outdated
Comment thread src/Tasks/FileIO/WriteLinesToFile.cs Outdated
Comment thread src/Tasks/FileIO/WriteLinesToFile.cs Outdated
Comment thread src/Tasks/FileIO/WriteLinesToFile.cs Outdated
JanProvaznik added a commit that referenced this pull request Apr 8, 2026
Learnings from reviewing PR #13477 — the 24-dimension checklist missed
design-level issues that required stepping back from the diff.

### Changes
- **Dim 4 (Tests)**: Flag weak assertions that would pass with incorrect
output
- **Dim 10 (Design)**: Check alignment with original design intent, flag
workarounds when better APIs exist in existing dependencies, validate
borrowed patterns
- **Dim 22 (Correctness)**: Generalize 2-participant fixes to N
participants, flag symptom-only fixes
- **Workflow**: Add historical context step (read linked issue +
original feature PR) before dispatching dimension agents
- **Tasks instructions**: Document \Microsoft.IO.Redist\ API
availability for .NET Framework

### Why
The review agents evaluated the diff mechanically but missed:
1. The fix patches a TOCTOU symptom; a better API
(\Microsoft.IO.File.Move(overwrite: true)\) exists in an
already-referenced package
2. The fix handles 2 concurrent writers but fails with 3+
3. Test assertions were too weak to verify correct behavior
4. The approach was borrowed from the VS editor (which never has 'file
doesn't exist' case) without validating assumptions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
dfederm pushed a commit to dfederm/msbuild that referenced this pull request Apr 9, 2026
…13504)

Learnings from reviewing PR dotnet#13477 — the 24-dimension checklist missed
design-level issues that required stepping back from the diff.

### Changes
- **Dim 4 (Tests)**: Flag weak assertions that would pass with incorrect
output
- **Dim 10 (Design)**: Check alignment with original design intent, flag
workarounds when better APIs exist in existing dependencies, validate
borrowed patterns
- **Dim 22 (Correctness)**: Generalize 2-participant fixes to N
participants, flag symptom-only fixes
- **Workflow**: Add historical context step (read linked issue +
original feature PR) before dispatching dimension agents
- **Tasks instructions**: Document \Microsoft.IO.Redist\ API
availability for .NET Framework

### Why
The review agents evaluated the diff mechanically but missed:
1. The fix patches a TOCTOU symptom; a better API
(\Microsoft.IO.File.Move(overwrite: true)\) exists in an
already-referenced package
2. The fix handles 2 concurrent writers but fails with 3+
3. Test assertions were too weak to verify correct behavior
4. The approach was borrowed from the VS editor (which never has 'file
doesn't exist' case) without validating assumptions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
// 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;
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.

this retry mechanism seems reasonalbe

Comment thread src/Tasks/FileIO/WriteLinesToFile.cs
try
{
System.Threading.Thread.Sleep(10);
System.IO.File.Replace(temporaryFilePath, filePath, null, 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.

this completely gets rid of the Replace path
Why was it used in the original PR and why you decided to not implement it here?

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.

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.

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.

In what case would the file identity matter? aren't you creating the file right before moving it anyway?

Copy link
Copy Markdown
Contributor Author

@huulinhnguyen-dev huulinhnguyen-dev Apr 13, 2026

Choose a reason for hiding this comment

The 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

TransactionalModePreservesAllData test is flaky

3 participants