diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index ea0cd5ea1..048cd0b50 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -59,6 +59,7 @@ Development contracts: | `.cdidx/` | Created with mode `0700`. | | `codeindex.db` plus WAL/SHM sidecars | Mode `0600` is applied when the files exist. | | `suggestions-*.json` suggestion stores | Written atomically with owner-only mode `0600` on POSIX. | +| Indexed workspace source reads | Source-file content and checksum reads use `FileShare.ReadWrite | FileShare.Delete`, long-path normalization, the configured max-file byte cap, and modified-time retry checks so indexing can inspect files that build tools keep open without allowing unbounded growth. | | Atomic file writes | `AtomicFileWriter` writes to a sibling temp file, applies the requested POSIX mode before replacement, flushes file contents, renames over the target, and fsyncs the parent directory on Unix. Callers must use the `Sensitive` write profile for local state, caches, suggestions, checkpoints, and other private payloads; user-requested exports and reports use the default `Public` profile unless their content is explicitly private. If the parent directory flush fails after replacement, the command fails explicitly so callers know the file was replaced but directory durability was not confirmed. Windows skips directory fsync because the helper only promises it on supported Unix platforms. | | Index locks, watch sub-run spools, staged hook scripts, lock metadata sidecars, and active workspace `active.json` | Created or written as owner-only files (`0600`) before contents are exposed, and read through small bounded buffers where applicable so stale or corrupted diagnostics cannot expose local paths more broadly or force unbounded allocation. | | Checkpoint roots, snapshot directories, manifest files, copied DB/WAL/SHM snapshots, and restore staging/backup directories | Forced owner-only on POSIX. | @@ -2334,6 +2335,7 @@ net9 CI lane に合わせる場合は `FRAMEWORK=net9.0 make test` を使いま | `.cdidx/` | mode `0700` で作成。 | | `codeindex.db` と WAL/SHM sidecar | ファイルが存在する場合は mode `0600` を適用。 | | `suggestions-*.json` suggestion store | POSIX では owner-only の mode `0600` で atomic write します。 | +| インデックス対象ワークスペースソースの読み取り | ソースファイル本文とチェックサムの読み取りは `FileShare.ReadWrite | FileShare.Delete`、長いパスの正規化、設定された最大ファイルバイト数、更新時刻の再確認を使い、ビルドツールが開いたままのファイルも無制限な肥大化を許さず検査できるようにします。 | | atomic file write | `AtomicFileWriter` は sibling temp file に書き込み、要求された POSIX mode を置換前に適用し、file content を flush してから target へ rename し、Unix では parent directory を fsync します。local state、cache、suggestion、checkpoint など private payload には `Sensitive` write profile を使い、user-requested export や report は内容が明示的に private でない限り既定の `Public` profile を使います。置換後に parent directory flush が失敗した場合、file は置換済みだが directory durability を確認できていないことが caller に分かるよう command は明示的に失敗します。Windows では、この helper の directory fsync 保証は supported Unix platform に限定されるため skip します。 | | index lock、watch sub-run spool、staged hook script、lock metadata sidecar、active workspace の `active.json` | 内容が露出する前に owner-only file (`0600`) として作成または書き込み、該当するものは stale / corrupt diagnostic が local path を広く漏らしたり unbounded allocation を強制したりしないよう小さな bounded buffer で読みます。 | | database checkpoint root、snapshot directory、manifest file、copy された DB/WAL/SHM snapshot、restore staging/backup directory | POSIX では owner-only に固定。 | diff --git a/changelog.d/unreleased/4078.fixed.md b/changelog.d/unreleased/4078.fixed.md new file mode 100644 index 000000000..92986d6ac --- /dev/null +++ b/changelog.d/unreleased/4078.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 4078 +affected: + - src/CodeIndex/BoundedFile.cs + - src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs + - src/CodeIndex/Indexer/Scanning/FileContentLoader.Checksum.cs + - tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs + - tests/CodeIndex.Tests/FileIndexerTests.cs + - DEVELOPER_GUIDE.md +--- + +## English + +- **Indexer source reads now use the shared bounded file-open policy (#4078)** - source content and checksum reads now allow concurrent build-tool writers while preserving max-file byte caps and modified-time retry checks. + +## 日本語 + +- **インデックス対象ソースの読み取りが共有の境界付きファイルオープン方針を使うようになりました (#4078)** - ソース本文とチェックサムの読み取りは、最大ファイルバイト数の上限と更新時刻の再確認を保ったまま、ビルドツールによる同時書き込みを許容するようになりました。 diff --git a/src/CodeIndex/BoundedFile.cs b/src/CodeIndex/BoundedFile.cs index 5a6f40d1c..ae503c472 100644 --- a/src/CodeIndex/BoundedFile.cs +++ b/src/CodeIndex/BoundedFile.cs @@ -13,6 +13,9 @@ internal static FileStream OpenReadForLengthCheckedText(string path) internal static FileStream OpenReadForPrefixProbe(string path) => OpenRead(path, FileShare.ReadWrite | FileShare.Delete, SmallReadBufferSize); + internal static FileStream OpenReadForIndexContent(string path) + => OpenRead(path, FileShare.ReadWrite | FileShare.Delete, DefaultReadBufferSize); + internal static FileStream OpenReadForTail(string path) => OpenRead(path, FileShare.ReadWrite | FileShare.Delete, SmallReadBufferSize); diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.Checksum.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.Checksum.cs index 1d4a943c9..c7c4dfe6a 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.Checksum.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.Checksum.cs @@ -56,13 +56,7 @@ internal static bool TryComputeChecksum( throw new ArgumentOutOfRangeException(nameof(maxBytes), maxBytes, "Maximum byte count must be non-negative."); checksum = string.Empty; - using var stream = new FileStream( - filePath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: StreamBufferSize, - options: FileOptions.SequentialScan); + using var stream = BoundedFile.OpenReadForIndexContent(filePath); return TryComputeChecksum(stream, maxBytes, out checksum, cancellationToken); } diff --git a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs index 9b1176c8a..c8e2ca887 100644 --- a/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs +++ b/src/CodeIndex/Indexer/Scanning/FileContentLoader.RawBytes.cs @@ -32,13 +32,7 @@ internal sealed partial class FileContentLoader for (var attempt = 0; ; attempt++) { var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); - using (var stream = new FileStream( - ioPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: StreamBufferSize, - options: FileOptions.SequentialScan)) + using (var stream = BoundedFile.OpenReadForIndexContent(absolutePath)) { var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) @@ -73,13 +67,7 @@ internal bool RawByteChunksMayMatch( { var modifiedBeforeRead = File.GetLastWriteTimeUtc(ioPath); bool matched; - using (var stream = new FileStream( - ioPath, - FileMode.Open, - FileAccess.Read, - FileShare.Read, - bufferSize: StreamBufferSize, - options: FileOptions.SequentialScan)) + using (var stream = BoundedFile.OpenReadForIndexContent(absolutePath)) { var initialLength = stream.Length; if (initialLength > maxFileSizeBytes) diff --git a/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs b/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs index c37c573e1..4c939fc10 100644 --- a/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerContentLoadingTests.cs @@ -49,6 +49,35 @@ public void FileContentLoader_Load_LfOnlyUtf8CanReuseRawChecksum() Assert.Equal(expected, loaded.Checksum); } + [Fact] + public void FileContentLoader_Load_AllowsConcurrentWriterShare_Issue4078() + { + var tempDir = TestProjectHelper.CreateTempProject("codeindex_loader_share"); + try + { + var path = Path.Combine(tempDir, "sample.cs"); + var bytes = Encoding.UTF8.GetBytes("class Sample {}\n"); + File.WriteAllBytes(path, bytes); + + using var writer = new FileStream( + path, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + var loader = new FileContentLoader(FileIndexer.DefaultMaxFileSizeBytes); + + var loaded = loader.Load(path, "sample.cs", "sample.cs", CancellationToken.None); + + Assert.Equal("class Sample {}\n", loaded.Content); + Assert.Equal(bytes.Length, loaded.SizeBytes); + Assert.Equal(FileIndexer.ComputeChecksum(bytes), loaded.Checksum); + } + finally + { + TestProjectHelper.DeleteDirectory(tempDir); + } + } + [Fact] public void FileContentLoader_Load_CarriesConflictMarkerLine() { diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index 0426b8a04..5638e81db 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -6353,4 +6353,32 @@ public void TryComputeChecksum_CancellationDuringRead_ThrowsOperationCanceled_Is FileContentLoader.TryComputeChecksum(stream, long.MaxValue, out _, cancellation.Token)); } + [Fact] + public void TryComputeChecksum_FilePathAllowsConcurrentWriterShare_Issue4078() + { + var tempDir = TestProjectHelper.CreateTempProject("codeindex_checksum_share"); + try + { + var path = Path.Combine(tempDir, "sample.cs"); + var bytes = Encoding.UTF8.GetBytes("class Sample {}\n"); + File.WriteAllBytes(path, bytes); + + using var writer = new FileStream( + path, + FileMode.Open, + FileAccess.Write, + FileShare.ReadWrite | FileShare.Delete); + + Assert.True(FileContentLoader.TryComputeChecksum( + path, + FileIndexer.DefaultMaxFileSizeBytes, + out var checksum)); + Assert.Equal(FileIndexer.ComputeChecksum(bytes), checksum); + } + finally + { + TestProjectHelper.DeleteDirectory(tempDir); + } + } + }