From f84921f53e73840e5d1e23003c0152d3fd77a37e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 00:02:33 +0900 Subject: [PATCH 1/3] Fix indexing liveness for slow extraction (#2627) --- changelog.d/unreleased/2627.fixed.md | 17 ++++++++++ src/CodeIndex/Cli/IndexCommandRunner.cs | 32 ++++++++++++++++--- src/CodeIndex/Cli/RuntimeSafety.cs | 17 ++++++++++ .../Indexer/References/ReferenceExtractor.cs | 7 ++-- src/CodeIndex/Program.cs | 1 + 5 files changed, 67 insertions(+), 7 deletions(-) create mode 100644 changelog.d/unreleased/2627.fixed.md create mode 100644 src/CodeIndex/Cli/RuntimeSafety.cs diff --git a/changelog.d/unreleased/2627.fixed.md b/changelog.d/unreleased/2627.fixed.md new file mode 100644 index 0000000000..8e94dd0510 --- /dev/null +++ b/changelog.d/unreleased/2627.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2627 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.cs + - src/CodeIndex/Cli/RuntimeSafety.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.cs +--- + +## English + +- **Clearer long-running extraction progress during indexing (#2627)** — `cdidx index --json` liveness output now includes the current per-file phase, and C# pattern-reference regexes use bounded matching so unusually expensive source/test files no longer look like an undifferentiated one-path stall. + +## 日本語 + +- **index 中の長時間抽出 progress を明確化しました (#2627)** — `cdidx index --json` の liveness output が現在のファイル内 phase を表示し、C# pattern-reference regex には bounded matching を適用したため、非常に重い source/test file が単なる 1 path の停止のように見え続ける状態を避けます。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index cc712b7497..6c049e5066 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -1,6 +1,7 @@ using System.Diagnostics; using System.Collections.Concurrent; using System.Runtime.InteropServices; +using System.Text.RegularExpressions; using System.Text.Json; using CodeIndex.Database; using CodeIndex.Indexer; @@ -38,6 +39,7 @@ public static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions) => internal static int Run(string[] indexArgs, JsonSerializerOptions jsonOptions, CancellationTokenSource? cancellationForTesting) { + RuntimeSafety.Configure(); var options = ParseArgs(indexArgs); var jsonContext = CliJsonSerializerContextFactory.Create(jsonOptions); using var ownedCancellation = cancellationForTesting == null ? new CancellationTokenSource() : null; @@ -2082,8 +2084,10 @@ void ThrowIfUpdateCancelled() writer.PurgeStaleFilesSharingDirectoryAndStem(projectRoot, record.Path); WriteProjectRootOnce(); var fileId = writer.UpsertFile(record); + currentUpdatePath = $"{relPath} (chunking)"; var chunks = ChunkSplitter.Split(fileId, content); writer.InsertChunks(chunks); + currentUpdatePath = $"{relPath} (symbols)"; var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, absPath, Path.GetFullPath(options.ProjectPath!)); SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(absPath, record.Lang)); var fileContext = new FileContext(projectRoot, record.Path, absPath, record.Lang); @@ -2091,6 +2095,7 @@ void ThrowIfUpdateCancelled() symbolsDroppedByKindFilter += options.SymbolKindFilter.Apply(symbols); FileIndexer.ValidateSymbolLineRanges(record, symbols); writer.InsertSymbols(symbols); + currentUpdatePath = $"{relPath} (references)"; var references = ReferenceExtractor.Extract( fileId, record.Lang, @@ -2101,8 +2106,10 @@ void ThrowIfUpdateCancelled() postExtractionHooks.OnReferencesExtracted(fileContext, references); writer.InsertReferences(references); // Validate content for encoding issues / エンコーディング問題を検証 + currentUpdatePath = $"{relPath} (validating)"; var issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); writer.InsertIssues(fileId, issues); + currentUpdatePath = $"{relPath} (committing)"; writer.ClearBatchInProgress(); txn.Commit(); @@ -2183,11 +2190,12 @@ void ThrowIfUpdateCancelled() GlobalToolLog.Error($"index_update_file_failed path={CollapseLineBreaks(relPath)}\n{GlobalToolLog.FormatExceptionChain(ex)}"); errors++; - errorList.Add(new CliJsonMessage(relPath, ex.Message)); + var errorMessage = FormatIndexFileException(ex); + errorList.Add(new CliJsonMessage(relPath, errorMessage)); if (!options.Json) { PauseUpdateSpinnerForConsoleWrite(); - Console.Error.WriteLine(FormatPerFileErrorLine("ERR ", relPath, ex)); + Console.Error.WriteLine(FormatPerFileErrorLine("ERR ", relPath, ex, errorMessage)); ResumeUpdateSpinnerAfterConsoleWrite(); } } @@ -2767,7 +2775,15 @@ private static void StampWorkspacePathCaseSensitivity(DbWriter writer, string pr // 複数行メッセージが疑似スタック行を注入できないようにする。詳細診断は // `cdidx report` / `CDIDX_DEBUG` で取得する (#1578)。 internal static string FormatPerFileErrorLine(string label, string path, Exception ex) => - $" [{label}] {CollapseLineBreaks(path)}: {CollapseLineBreaks(ex.Message)}"; + FormatPerFileErrorLine(label, path, ex, FormatIndexFileException(ex)); + + internal static string FormatPerFileErrorLine(string label, string path, Exception ex, string message) => + $" [{label}] {CollapseLineBreaks(path)}: {CollapseLineBreaks(message)}"; + + internal static string FormatIndexFileException(Exception ex) => + ex is RegexMatchTimeoutException timeoutException + ? RuntimeSafety.FormatRegexTimeout(timeoutException) + : ex.Message; private static string CollapseLineBreaks(string value) { @@ -3602,10 +3618,12 @@ void StopJsonHeartbeat() using var txn = writer.BeginTransaction(); writer.PurgeStaleFilesSharingChecksum(projectRoot, record.Path, record.Checksum); var fileId = writer.UpsertFile(record); + currentJsonIndexFile = $"{record.Path} (chunking)"; var chunks = item.Chunks == null ? ChunkSplitter.Split(fileId, item.Content!) : ReassignChunkFileIds(item.Chunks, fileId); writer.InsertChunks(chunks); + currentJsonIndexFile = $"{record.Path} (symbols)"; var symbols = item.Symbols == null ? SymbolExtractor.Extract(fileId, record.Lang, item.Content!, item.FilePath, Path.GetFullPath(options.ProjectPath!)) : ReassignSymbolFileIds(item.Symbols, fileId); @@ -3618,6 +3636,7 @@ void StopJsonHeartbeat() symbols = (IReadOnlyList)mutableSymbols; FileIndexer.ValidateSymbolLineRanges(record, symbols); writer.InsertSymbols(symbols); + currentJsonIndexFile = $"{record.Path} (references)"; var references = item.References == null ? ReferenceExtractor.Extract( fileId, @@ -3629,8 +3648,10 @@ void StopJsonHeartbeat() : ReassignReferenceFileIds(item.References, fileId); postExtractionHooks.OnReferencesExtracted(fileContext, AsMutableList(references)); writer.InsertReferences(references); + currentJsonIndexFile = $"{record.Path} (validating)"; var issues = item.Issues ?? FileIndexer.ValidateContent(record.Path, item.RawBytes!, item.Content!); writer.InsertIssues(fileId, issues); + currentJsonIndexFile = $"{record.Path} (committing)"; WriteProjectRootOnce(); txn.Commit(); @@ -3640,12 +3661,13 @@ void StopJsonHeartbeat() { GlobalToolLog.Error($"index_file_failed path={CollapseLineBreaks(item.FilePath)}\n{GlobalToolLog.FormatExceptionChain(ex)}"); errors++; - errorList.Add(new CliJsonMessage(item.FilePath, ex.Message)); + var errorMessage = FormatIndexFileException(ex); + errorList.Add(new CliJsonMessage(item.FilePath, errorMessage)); if (!options.Json) { PauseIndexSpinnerForConsoleWrite(); ConsoleUi.ClearProgressLine(); - Console.Error.WriteLine(FormatPerFileErrorLine("ERR ", item.FilePath, ex)); + Console.Error.WriteLine(FormatPerFileErrorLine("ERR ", item.FilePath, ex, errorMessage)); ResumeIndexSpinnerAfterConsoleWrite(); } } diff --git a/src/CodeIndex/Cli/RuntimeSafety.cs b/src/CodeIndex/Cli/RuntimeSafety.cs new file mode 100644 index 0000000000..d21649ec12 --- /dev/null +++ b/src/CodeIndex/Cli/RuntimeSafety.cs @@ -0,0 +1,17 @@ +using System.Text.RegularExpressions; + +namespace CodeIndex.Cli; + +internal static class RuntimeSafety +{ + internal static readonly TimeSpan RegexMatchTimeout = TimeSpan.FromSeconds(2); + + public static void Configure() + { + AppDomain.CurrentDomain.SetData("REGEX_DEFAULT_MATCH_TIMEOUT", RegexMatchTimeout); + } + + public static string FormatRegexTimeout(RegexMatchTimeoutException ex) + => $"Regex extraction timed out after {ex.MatchTimeout.TotalSeconds:0.###}s while indexing this file. " + + "The file was skipped so indexing can finish; please report the file or reduce the pathological pattern input."; +} diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index fa5aafcfd8..45fca82de6 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -11,6 +11,7 @@ namespace CodeIndex.Indexer; /// public static partial class ReferenceExtractor { + private static readonly TimeSpan ExtractionRegexTimeout = TimeSpan.FromSeconds(2); // THREAD-SAFETY: Reference extraction is stateless per call. Shared Regex instances and // lookup tables are initialized once and then read concurrently; language-specific state // must be created per extraction call (for example via CreateState helpers) rather than @@ -572,7 +573,8 @@ private static bool IsFunctionLikeSymbolKind(string kind) // `is` / `is not` / `as` の型位置 (`o is Base`, `o is not Base`, `o as Base`)。 private static readonly Regex CSharpIsAsTypeTestRegex = new( $@"(?{CSharpTypeExpressionPattern})", - RegexOptions.Compiled); + RegexOptions.Compiled, + ExtractionRegexTimeout); internal static readonly Regex CSharpTrailingIsAsTypePatternIntroRegex = new( @"(?{CSharpTypeExpressionPattern})", - RegexOptions.Compiled); + RegexOptions.Compiled, + ExtractionRegexTimeout); // C# XML-doc cross-reference (``, ``). // C# XML doc の `` / ``。 private static readonly Regex CSharpDocCrefRegex = new( diff --git a/src/CodeIndex/Program.cs b/src/CodeIndex/Program.cs index 76ca0232f9..0d2f83b95d 100644 --- a/src/CodeIndex/Program.cs +++ b/src/CodeIndex/Program.cs @@ -6,4 +6,5 @@ // Windows のコンソールは既定で OEM コードページを使用するため、Unicode 文字が文字化けします。 Console.OutputEncoding = Encoding.UTF8; ConsoleUi.EnsureConsoleWritersSynchronized(); +RuntimeSafety.Configure(); return ProgramRunner.Run(args); From cf307493181e3cce5ac3527e63a38ed19822d932 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 00:16:30 +0900 Subject: [PATCH 2/3] Add indexing liveness tests (#2627) --- src/CodeIndex/Cli/IndexCommandRunner.cs | 23 +++++++++++-------- .../IndexCommandRunnerTests.cs | 21 +++++++++++++++++ 2 files changed, 34 insertions(+), 10 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 6c049e5066..a3191a74c9 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -2084,10 +2084,10 @@ void ThrowIfUpdateCancelled() writer.PurgeStaleFilesSharingDirectoryAndStem(projectRoot, record.Path); WriteProjectRootOnce(); var fileId = writer.UpsertFile(record); - currentUpdatePath = $"{relPath} (chunking)"; + currentUpdatePath = FormatIndexPhasePath(relPath, "chunking"); var chunks = ChunkSplitter.Split(fileId, content); writer.InsertChunks(chunks); - currentUpdatePath = $"{relPath} (symbols)"; + currentUpdatePath = FormatIndexPhasePath(relPath, "symbols"); var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, absPath, Path.GetFullPath(options.ProjectPath!)); SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(absPath, record.Lang)); var fileContext = new FileContext(projectRoot, record.Path, absPath, record.Lang); @@ -2095,7 +2095,7 @@ void ThrowIfUpdateCancelled() symbolsDroppedByKindFilter += options.SymbolKindFilter.Apply(symbols); FileIndexer.ValidateSymbolLineRanges(record, symbols); writer.InsertSymbols(symbols); - currentUpdatePath = $"{relPath} (references)"; + currentUpdatePath = FormatIndexPhasePath(relPath, "references"); var references = ReferenceExtractor.Extract( fileId, record.Lang, @@ -2106,10 +2106,10 @@ void ThrowIfUpdateCancelled() postExtractionHooks.OnReferencesExtracted(fileContext, references); writer.InsertReferences(references); // Validate content for encoding issues / エンコーディング問題を検証 - currentUpdatePath = $"{relPath} (validating)"; + currentUpdatePath = FormatIndexPhasePath(relPath, "validating"); var issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); writer.InsertIssues(fileId, issues); - currentUpdatePath = $"{relPath} (committing)"; + currentUpdatePath = FormatIndexPhasePath(relPath, "committing"); writer.ClearBatchInProgress(); txn.Commit(); @@ -2785,6 +2785,9 @@ ex is RegexMatchTimeoutException timeoutException ? RuntimeSafety.FormatRegexTimeout(timeoutException) : ex.Message; + internal static string FormatIndexPhasePath(string path, string phase) => + $"{path} ({phase})"; + private static string CollapseLineBreaks(string value) { if (string.IsNullOrEmpty(value)) @@ -3618,12 +3621,12 @@ void StopJsonHeartbeat() using var txn = writer.BeginTransaction(); writer.PurgeStaleFilesSharingChecksum(projectRoot, record.Path, record.Checksum); var fileId = writer.UpsertFile(record); - currentJsonIndexFile = $"{record.Path} (chunking)"; + currentJsonIndexFile = FormatIndexPhasePath(record.Path, "chunking"); var chunks = item.Chunks == null ? ChunkSplitter.Split(fileId, item.Content!) : ReassignChunkFileIds(item.Chunks, fileId); writer.InsertChunks(chunks); - currentJsonIndexFile = $"{record.Path} (symbols)"; + currentJsonIndexFile = FormatIndexPhasePath(record.Path, "symbols"); var symbols = item.Symbols == null ? SymbolExtractor.Extract(fileId, record.Lang, item.Content!, item.FilePath, Path.GetFullPath(options.ProjectPath!)) : ReassignSymbolFileIds(item.Symbols, fileId); @@ -3636,7 +3639,7 @@ void StopJsonHeartbeat() symbols = (IReadOnlyList)mutableSymbols; FileIndexer.ValidateSymbolLineRanges(record, symbols); writer.InsertSymbols(symbols); - currentJsonIndexFile = $"{record.Path} (references)"; + currentJsonIndexFile = FormatIndexPhasePath(record.Path, "references"); var references = item.References == null ? ReferenceExtractor.Extract( fileId, @@ -3648,10 +3651,10 @@ void StopJsonHeartbeat() : ReassignReferenceFileIds(item.References, fileId); postExtractionHooks.OnReferencesExtracted(fileContext, AsMutableList(references)); writer.InsertReferences(references); - currentJsonIndexFile = $"{record.Path} (validating)"; + currentJsonIndexFile = FormatIndexPhasePath(record.Path, "validating"); var issues = item.Issues ?? FileIndexer.ValidateContent(record.Path, item.RawBytes!, item.Content!); writer.InsertIssues(fileId, issues); - currentJsonIndexFile = $"{record.Path} (committing)"; + currentJsonIndexFile = FormatIndexPhasePath(record.Path, "committing"); WriteProjectRootOnce(); txn.Commit(); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index e6492476f5..0fdf44f942 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text.RegularExpressions; using System.Runtime.Versioning; using System.Runtime.InteropServices; using CodeIndex.Cli; @@ -48,6 +49,26 @@ public void ParseArgs_HelpFlagSetsShowHelp() Assert.Null(options.ProjectPath); } + [Fact] + public void FormatIndexFileException_RegexTimeout_UsesBoundedExtractionMessage() + { + var ex = new RegexMatchTimeoutException("raw-sensitive-content", "raw-sensitive-pattern", TimeSpan.FromSeconds(2)); + + var message = IndexCommandRunner.FormatIndexFileException(ex); + + Assert.Contains("Regex extraction timed out after 2s", message); + Assert.Contains("file was skipped", message); + Assert.DoesNotContain("raw-sensitive", message); + } + + [Fact] + public void FormatIndexPhasePath_AppendsPhaseSuffixForJsonLiveness() + { + var message = IndexCommandRunner.FormatIndexPhasePath("src/App.cs", "references"); + + Assert.Equal("src/App.cs (references)", message); + } + [Fact] public void Run_NullByteFile_SkipsWithoutPersistingPartialRows() { From b471a5b041b9859793e5af19e6471b425f12d899 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 00:30:26 +0900 Subject: [PATCH 3/3] Cover worker extraction liveness (#2627) --- src/CodeIndex/Cli/IndexCommandRunner.cs | 27 ++++++++++++++++--- .../IndexCommandRunnerTests.cs | 20 ++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index a3191a74c9..47aa82e5ee 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -2788,6 +2788,14 @@ ex is RegexMatchTimeoutException timeoutException internal static string FormatIndexPhasePath(string path, string phase) => $"{path} ({phase})"; + internal static string? GetJsonIndexHeartbeatPath(string? currentFile, IEnumerable activeExtractionPhases) + { + if (!string.IsNullOrEmpty(currentFile)) + return currentFile; + + return activeExtractionPhases.FirstOrDefault(static phase => !string.IsNullOrEmpty(phase)); + } + private static string CollapseLineBreaks(string value) { if (string.IsNullOrEmpty(value)) @@ -3260,13 +3268,14 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) int processed = 0, skipped = 0, warnings = warningList.Count, errors = errorList.Count; var symbolsDroppedByKindFilter = 0; - var interactiveIndexSpinner = !options.Json && !options.Quiet && ConsoleUi.ShouldUseInteractiveConsole(); + var interactiveIndexSpinner = !options.Json && !options.Quiet && ConsoleUi.ShouldUseInteractiveConsole(); var redirectedIndexingMessagePrinted = false; var indexProgressVisible = false; var reusedHotspotFamilyLanguages = new HashSet(StringComparer.Ordinal); var skippedSymbolExtractorLanguages = new HashSet(StringComparer.Ordinal); var lastJsonProgressAt = Stopwatch.GetTimestamp(); string? currentJsonIndexFile = null; + var activeJsonExtractionPhases = new ConcurrentDictionary(); CancellationTokenSource? jsonHeartbeatCts = null; Task? jsonHeartbeatTask = null; using var postExtractionHooks = PostExtractionHookRunner.DiscoverDefault(); @@ -3381,7 +3390,9 @@ void StartJsonHeartbeatIfNeeded() if (token.IsCancellationRequested) break; - var file = currentJsonIndexFile; + var file = GetJsonIndexHeartbeatPath( + currentJsonIndexFile, + activeJsonExtractionPhases.OrderBy(static kvp => kvp.Key).Select(static kvp => kvp.Value)); var fileSuffix = string.IsNullOrEmpty(file) ? string.Empty : $": {file}"; Console.Error.WriteLine($"cdidx: still indexing {processed:N0}/{files.Count:N0} file(s){fileSuffix}..."); } @@ -3448,7 +3459,7 @@ void StopJsonHeartbeat() using var extractionResults = new BlockingCollection(Math.Max(1, extractionParallelism * 4)); var nextFileIndex = -1; var workers = Enumerable.Range(0, extractionParallelism) - .Select(_ => Task.Factory.StartNew(() => + .Select(workerIndex => Task.Factory.StartNew(() => { while (true) { @@ -3460,6 +3471,8 @@ void StopJsonHeartbeat() var filePath = files[fileIndex]; try { + var relativeFilePath = FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectRoot, filePath)); + activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(relativeFilePath, "reading"); var (record, content, rawBytes, warning) = indexer.BuildRecordWithRawBytes(filePath); IReadOnlyList? chunks = null; IReadOnlyList? symbols = null; @@ -3467,9 +3480,12 @@ void StopJsonHeartbeat() IReadOnlyList? issues = null; if (parallelizeExtraction) { + activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(record.Path, "chunking"); chunks = ChunkSplitter.Split(0, content); + activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(record.Path, "symbols"); symbols = SymbolExtractor.Extract(0, record.Lang, content, filePath, Path.GetFullPath(options.ProjectPath!)); SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(filePath, record.Lang)); + activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(record.Path, "references"); references = ReferenceExtractor.Extract( 0, record.Lang, @@ -3477,6 +3493,7 @@ void StopJsonHeartbeat() symbols, record.Path, record.Lang == "csharp" ? csharpWorkspace.Symbols : null); + activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(record.Path, "validating"); issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); } extractionResults.Add( @@ -3502,6 +3519,10 @@ void StopJsonHeartbeat() { extractionResults.Add(FullScanFileWorkItem.Failure(filePath, ex), cancellationToken); } + finally + { + activeJsonExtractionPhases.TryRemove(workerIndex, out _); + } } }, cancellationToken, TaskCreationOptions.LongRunning, TaskScheduler.Default)) .ToArray(); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 0fdf44f942..8074854832 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -69,6 +69,26 @@ public void FormatIndexPhasePath_AppendsPhaseSuffixForJsonLiveness() Assert.Equal("src/App.cs (references)", message); } + [Fact] + public void GetJsonIndexHeartbeatPath_UsesWorkerPhaseWhenMainThreadIsIdle() + { + var message = IndexCommandRunner.GetJsonIndexHeartbeatPath( + currentFile: null, + activeExtractionPhases: ["src/App.cs (references)"]); + + Assert.Equal("src/App.cs (references)", message); + } + + [Fact] + public void GetJsonIndexHeartbeatPath_PrefersMainThreadPhaseWhenCommittingResults() + { + var message = IndexCommandRunner.GetJsonIndexHeartbeatPath( + "src/App.cs (committing)", + ["src/Other.cs (references)"]); + + Assert.Equal("src/App.cs (committing)", message); + } + [Fact] public void Run_NullByteFile_SkipsWithoutPersistingPartialRows() {