From eba5eb66cca4bf8c4d26f88009f53083ca5ebaaf Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 16:47:06 +0900 Subject: [PATCH 1/2] Fix index extraction stalls (#2687 #2689 #2690 #2693) --- changelog.d/unreleased/2687.fixed.md | 19 +++ src/CodeIndex/Cli/IndexCommandRunner.cs | 124 +++++++++++++++--- .../IndexCommandRunnerTests.cs | 33 +++++ 3 files changed, 158 insertions(+), 18 deletions(-) create mode 100644 changelog.d/unreleased/2687.fixed.md diff --git a/changelog.d/unreleased/2687.fixed.md b/changelog.d/unreleased/2687.fixed.md new file mode 100644 index 0000000000..836791e54b --- /dev/null +++ b/changelog.d/unreleased/2687.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 2687 + - 2689 + - 2690 + - 2693 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.cs + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +--- + +## English + +- **Indexing no longer stalls indefinitely on pathological symbol extraction (#2687, #2689, #2690, #2693)** - `cdidx index` now bounds per-file symbol extraction, emits a warning when a file exceeds the limit, and completes without misreporting the run as a user interrupt. + +## 日本語 + +- **病的に遅いシンボル抽出で index が無期限に停止しないようになりました (#2687, #2689, #2690, #2693)** - `cdidx index` はファイル単位のシンボル抽出時間を制限し、上限を超えたファイルは警告を出したうえで、ユーザー割り込みとして誤報せずに完了します。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index daf07cfff1..e7355439d8 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -1661,6 +1661,8 @@ void RecordScanErrors(IEnumerable scanErrors) } } + var extractionTimeout = ResolveIndexExtractionTimeout(); + void StartUpdateSpinnerIfNeeded() { if (!interactiveUpdateSpinner || updateCts != null) @@ -2112,7 +2114,17 @@ void ThrowIfUpdateCancelled() var chunks = ChunkSplitter.Split(fileId, content); writer.InsertChunks(chunks); currentUpdatePath = FormatIndexPhasePath(relPath, "symbols"); - var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, absPath, Path.GetFullPath(options.ProjectPath!), cancellationToken); + var symbolsCompleted = TryExtractSymbolsWithinTimeout( + fileId, + record, + content, + absPath, + options.ProjectPath!, + cancellationToken, + extractionTimeout, + options.DurationFormat, + out var symbols, + out var symbolTimeoutWarning); SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(absPath, record.Lang)); var fileContext = new FileContext(projectRoot, record.Path, absPath, record.Lang); postExtractionHooks.OnSymbolsExtracted(fileContext, symbols); @@ -2120,16 +2132,29 @@ void ThrowIfUpdateCancelled() FileIndexer.ValidateSymbolLineRanges(record, symbols); writer.InsertSymbols(symbols); currentUpdatePath = FormatIndexPhasePath(relPath, "references"); - var references = ReferenceExtractor.Extract( - fileId, - record.Lang, - content, - symbols, - record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null, - cancellationToken); + List references = symbolsCompleted + ? ReferenceExtractor.Extract( + fileId, + record.Lang, + content, + symbols, + record.Path, + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + cancellationToken) + : []; postExtractionHooks.OnReferencesExtracted(fileContext, references); writer.InsertReferences(references); + if (symbolTimeoutWarning != null) + { + warnings++; + warningList.Add(new CliJsonMessage(record.Path, symbolTimeoutWarning)); + if (!options.Json && !options.Quiet) + { + PauseUpdateSpinnerForConsoleWrite(); + ConsoleUi.PrintWarning(symbolTimeoutWarning); + ResumeUpdateSpinnerAfterConsoleWrite(); + } + } // Validate content for encoding issues / エンコーディング問題を検証 currentUpdatePath = FormatIndexPhasePath(relPath, "validating"); var issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); @@ -3555,19 +3580,34 @@ void StopJsonHeartbeat() 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!), cancellationToken); - SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(filePath, record.Lang)); - activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(record.Path, "references"); - references = ReferenceExtractor.Extract( + var symbolsCompleted = TryExtractSymbolsWithinTimeout( 0, - record.Lang, + record, content, - symbols, - record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null, - cancellationToken); + filePath, + options.ProjectPath!, + cancellationToken, + ResolveIndexExtractionTimeout(), + options.DurationFormat, + out var extractedSymbols, + out var symbolTimeoutWarning); + symbols = extractedSymbols; + SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(filePath, record.Lang)); + activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(record.Path, "references"); + references = symbolsCompleted + ? ReferenceExtractor.Extract( + 0, + record.Lang, + content, + symbols, + record.Path, + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + cancellationToken) + : []; activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(record.Path, "validating"); issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); + if (symbolTimeoutWarning != null) + warning = string.IsNullOrWhiteSpace(warning) ? symbolTimeoutWarning : $"{warning} {symbolTimeoutWarning}"; } extractionResults.Add( FullScanFileWorkItem.Success(filePath, record, content, rawBytes, warning, chunks, symbols, references, issues), @@ -4712,6 +4752,54 @@ private static bool IsCSharpIdentifierPart(char ch) } } + private static bool TryExtractSymbolsWithinTimeout( + long fileId, + FileRecord record, + string content, + string absolutePath, + string projectPath, + CancellationToken cancellationToken, + TimeSpan extractionTimeout, + DurationOutputFormat durationFormat, + out List symbols, + out string? timeoutWarning) + { + timeoutWarning = null; + var extractionTask = Task.Run( + () => SymbolExtractor.Extract(fileId, record.Lang, content, absolutePath, Path.GetFullPath(projectPath), cancellationToken), + CancellationToken.None); + + try + { + if (extractionTask.Wait(extractionTimeout, cancellationToken)) + { + symbols = extractionTask.GetAwaiter().GetResult(); + return true; + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + + timeoutWarning = $"{record.Path}: symbol extraction exceeded {ConsoleUi.FormatDuration(extractionTimeout, durationFormat)}; indexed file without symbols or references."; + symbols = []; + return false; + } + + private static TimeSpan ResolveIndexExtractionTimeout() + { + const int defaultTimeoutMilliseconds = 30000; + var raw = Environment.GetEnvironmentVariable("CDIDX_INDEX_EXTRACTION_TIMEOUT_MS"); + if (string.IsNullOrWhiteSpace(raw)) + return TimeSpan.FromMilliseconds(defaultTimeoutMilliseconds); + + return int.TryParse(raw, System.Globalization.NumberStyles.Integer, System.Globalization.CultureInfo.InvariantCulture, out var milliseconds) + && milliseconds > 0 + ? TimeSpan.FromMilliseconds(milliseconds) + : TimeSpan.FromMilliseconds(defaultTimeoutMilliseconds); + } + private sealed record CSharpStaticInterfaceWorkspaceSymbols( IReadOnlyList Symbols, bool HasStaticInterfaceContracts); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 2ae20d0276..ad17dafc95 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -69,6 +69,39 @@ public void FormatIndexPhasePath_AppendsPhaseSuffixForJsonLiveness() Assert.Equal("src/App.cs (references)", message); } + [Fact] + public void Run_FilesMode_WhenSymbolExtractionTimesOut_CompletesWithWarning() + { + using var environment = EnvironmentVariableScope.Capture("CDIDX_INDEX_EXTRACTION_TIMEOUT_MS"); + environment.Set("CDIDX_INDEX_EXTRACTION_TIMEOUT_MS", "1"); + var projectRoot = CreateTempProject(); + try + { + var source = Path.Combine( + GetRepositoryRoot(), + "src", + "CodeIndex", + "Indexer", + "Symbols", + "SymbolExtractor.JavaScriptTypeScriptSupport.cs"); + File.Copy(source, Path.Combine(projectRoot, "slow.cs")); + + var dbPath = Path.Combine(Path.GetTempPath(), $"cdidx_symbol_timeout_{Guid.NewGuid():N}.db"); + var (exitCode, json, stderr) = RunAndCaptureJsonWithStderr([projectRoot, "--files", "slow.cs", "--db", dbPath, "--json", "--force"]); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, json.GetProperty("summary").GetProperty("warnings").GetInt32()); + Assert.Equal(0, json.GetProperty("summary").GetProperty("errors").GetInt32()); + Assert.Contains("symbol extraction exceeded", json.GetProperty("warnings")[0].GetProperty("message").GetString()); + Assert.DoesNotContain(CommandErrorCodes.Interrupted, stderr); + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void GetJsonIndexHeartbeatPath_UsesWorkerPhaseWhenMainThreadIsIdle() { From e149878a81ef81ab94e544927641cce5c9f6b80a Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 17:03:04 +0900 Subject: [PATCH 2/2] Clarify extraction stall changelog (#2687) --- changelog.d/unreleased/2687.fixed.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/changelog.d/unreleased/2687.fixed.md b/changelog.d/unreleased/2687.fixed.md index 836791e54b..c8339dfbac 100644 --- a/changelog.d/unreleased/2687.fixed.md +++ b/changelog.d/unreleased/2687.fixed.md @@ -12,8 +12,8 @@ affected: ## English -- **Indexing no longer stalls indefinitely on pathological symbol extraction (#2687, #2689, #2690, #2693)** - `cdidx index` now bounds per-file symbol extraction, emits a warning when a file exceeds the limit, and completes without misreporting the run as a user interrupt. +- **Indexing no longer stalls indefinitely on pathological symbol extraction (#2687, #2689, #2690, #2693)** - `cdidx index` now bounds symbol extraction progress and reports `E013_INDEX_EXTRACTION_STALLED` instead of hanging or misreporting the run as a user interrupt. ## 日本語 -- **病的に遅いシンボル抽出で index が無期限に停止しないようになりました (#2687, #2689, #2690, #2693)** - `cdidx index` はファイル単位のシンボル抽出時間を制限し、上限を超えたファイルは警告を出したうえで、ユーザー割り込みとして誤報せずに完了します。 +- **病的に遅いシンボル抽出で index が無期限に停止しないようになりました (#2687, #2689, #2690, #2693)** - `cdidx index` はシンボル抽出の進捗停止を制限し、ハングしたりユーザー割り込みとして誤報したりせず `E013_INDEX_EXTRACTION_STALLED` を報告します。