Repository navigation
fix(content): eliminate SaveFileAsync file-lock race in content-collection writes - #349
Merged
Merged
Conversation
…ction writes FileSystemStreamProvider WRITES opened with FileShare.None while the read path (GetStreamAsync/GetStreamWithMetadataAsync) opens with the tolerant FileShare.ReadWrite|Delete. On Unix .NET emulates FileShare via flock: FileShare.None takes an EXCLUSIVE lock (LOCK_EX) that the OS refuses while a reader holds the file (LOCK_SH). The FileSystemWatcher-driven UpdateArticle read legitimately overlaps a write, so under CI load the write threw fast (not a timeout): System.IO.IOException: The process cannot access the file '.../Files/NodeContent/TestOrg/sub/hello.md' because it is being used by another process. at FileSystemStreamProvider.SaveFileAsync (:157) This is the file-lock manifestation of the CollectionNamedArea watcher-race flake family (the timeout manifestation was PR #238). There is NO undisposed handle — the readers dispose correctly; the defect is that the write demanded exclusivity against readers the read path is designed to tolerate. Content files are eventually-consistent and re-ingested on change, so a write must never lock readers out. Fix: both write sites use single-writer / multi-reader / delete-tolerant sharing (FileShare.Read | FileShare.Delete) to match the read path. Pinned by ContentCollectionWriteReadRaceTest: without the fix the write throws the exact IOException within a few iterations; with it, 300 writes concurrent with a continuously-open reader never throw. Deterministic, no sleeps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Contributor
Test Results (shard 5)881 tests 699 ✅ 4m 10s ⏱️ Results for commit 9b2b7a4. |
Contributor
Contributor
Contributor
Contributor
Contributor
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The flake
CI-only, load-dependent, not a timeout — fails fast (~21 ms):
Observed on
NodeHubContentCollectionTest.CollectionNamedArea_RendersBrowserForFolder_AndContentForFile. This is a new manifestation of theCollectionNamedAreawatcher-race flake family — the timeout manifestation was fixed in #238; this is a file-lock / sharing violation on write.Root cause (exact, proven)
FileSystemStreamProviderwrites (WriteStreamAsync,SaveFileAsync) opened the file withFileShare.None, while the read path (GetStreamAsync/GetStreamWithMetadataAsync) opens with the deliberately-tolerantFileShare.ReadWrite | FileShare.Delete.On Unix, .NET emulates
FileShareviaflock:FileShare.None→ exclusiveLOCK_EXLOCK_SHLOCK_EXis refused while a reader holdsLOCK_SH, so the write throwsIOException: "... used by another process". The concurrent reader is theFileSystemWatcher-drivenContentCollection.UpdateArticle, which reads the file on every change and legitimately overlaps a write. macOS usesflocktoo, so it reproduces locally under load.There is no undisposed handle — the readers dispose correctly. The defect is that the write demanded exclusivity against readers the read path is explicitly built to tolerate (content is eventually-consistent and re-ingested on change).
FileShare.Noneon the write is simply inconsistent with that contract.Confirmed empirically (probe table):
ReadWrite|Delete(read path)None(old write)ReadWrite|DeleteReadReadWrite|DeleteReadWriteFix (correct sharing semantics, not a mask)
Both write sites now use single-writer / multi-reader / delete-tolerant sharing —
FileShare.Read | FileShare.Delete— matching the read path's intent: a write permits concurrent readers (and deletes, which reads already permit) but does not lock them out. This is not a blanket widening to hide an undisposed handle; it makes the write's sharing contract consistent with the read contract.Verification
failures=0with it.ContentCollectionWriteReadRaceTestdrives an explicitly-open reader (FileShare.ReadWrite|Delete) concurrently with 300 writes — throws the exact IOException without the fix, passes with it. No sleeps, no watcher-timing dependence.dotnet build -c Release -warnaserror -p:CIRun=trueclean (src + test).MeshWeaver.ContentCollections.Testsuite green (17 tests), including the previously-flakingCollectionNamedAreatest and the fix(content): ingest a collection's own write immediately — kill the CI-only file-render flake #238 ingest pin.🤖 Generated with Claude Code