From 5c2acdb20f2cec70edf3582ab955866352f93335 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:39:44 +0900 Subject: [PATCH 1/7] Propagate index cancellation into extractors (#1841) --- changelog.d/unreleased/1841.fixed.md | 19 +++++++++++++ src/CodeIndex/Cli/IndexCommandRunner.cs | 21 ++++++++------ .../References/ReferenceExtractionContext.cs | 3 +- .../Indexer/References/ReferenceExtractor.cs | 13 +++++++-- src/CodeIndex/Indexer/Scanning/FileIndexer.cs | 28 +++++++++++++------ .../Indexer/Symbols/SymbolExtractor.cs | 8 +++++- src/CodeIndex/Mcp/McpToolHandlers.cs | 22 +++++++++------ tests/CodeIndex.Tests/FileIndexerTests.cs | 18 ++++++++++++ .../ReferenceExtractorTests.cs | 10 +++++++ tests/CodeIndex.Tests/SymbolExtractorTests.cs | 10 +++++++ 10 files changed, 122 insertions(+), 30 deletions(-) create mode 100644 changelog.d/unreleased/1841.fixed.md diff --git a/changelog.d/unreleased/1841.fixed.md b/changelog.d/unreleased/1841.fixed.md new file mode 100644 index 0000000000..c84dc2b069 --- /dev/null +++ b/changelog.d/unreleased/1841.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1841 +affected: + - src/CodeIndex/Indexer/Scanning/FileIndexer.cs + - src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs + - src/CodeIndex/Indexer/References/ReferenceExtractor.cs + - src/CodeIndex/Cli/IndexCommandRunner.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs +--- + +## English + +- **Index cancellation now reaches scanner, file IO, and extractor work (#1841)** — `cdidx index` and MCP indexing now pass cancellation tokens into file scanning, raw file reads, symbol extraction, and reference extraction so Ctrl-C and request cancellation can stop deeper in-flight work sooner. + +## 日本語 + +- **index のキャンセルが scanner・ファイル IO・抽出処理まで届くようになりました (#1841)** — `cdidx index` と MCP indexing は cancellation token をファイル走査、raw file 読み込み、symbol 抽出、reference 抽出に渡すため、Ctrl-C やリクエストキャンセル後に深い処理もより早く停止できます。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 50ca078fcf..cec530b6b0 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -2084,7 +2084,7 @@ void ThrowIfUpdateCancelled() var fileId = writer.UpsertFile(record); var chunks = ChunkSplitter.Split(fileId, content); writer.InsertChunks(chunks); - var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, absPath, Path.GetFullPath(options.ProjectPath!)); + var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, absPath, Path.GetFullPath(options.ProjectPath!), cancellationToken); SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(absPath, record.Lang)); var fileContext = new FileContext(projectRoot, record.Path, absPath, record.Lang); postExtractionHooks.OnSymbolsExtracted(fileContext, symbols); @@ -2097,7 +2097,8 @@ void ThrowIfUpdateCancelled() content, symbols, record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null); + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + cancellationToken); postExtractionHooks.OnReferencesExtracted(fileContext, references); writer.InsertReferences(references); // Validate content for encoding issues / エンコーディング問題を検証 @@ -3476,7 +3477,7 @@ void StopJsonHeartbeat() var filePath = files[fileIndex]; try { - var (record, content, rawBytes, warning) = indexer.BuildRecordWithRawBytes(filePath); + var (record, content, rawBytes, warning) = indexer.BuildRecordWithRawBytes(filePath, cancellationToken); IReadOnlyList? chunks = null; IReadOnlyList? symbols = null; IReadOnlyList? references = null; @@ -3484,7 +3485,7 @@ void StopJsonHeartbeat() if (parallelizeExtraction) { chunks = ChunkSplitter.Split(0, content); - symbols = SymbolExtractor.Extract(0, record.Lang, content, filePath, Path.GetFullPath(options.ProjectPath!)); + symbols = SymbolExtractor.Extract(0, record.Lang, content, filePath, Path.GetFullPath(options.ProjectPath!), cancellationToken); SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(filePath, record.Lang)); references = ReferenceExtractor.Extract( 0, @@ -3492,7 +3493,8 @@ void StopJsonHeartbeat() content, symbols, record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null); + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + cancellationToken); issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); } extractionResults.Add( @@ -3656,7 +3658,7 @@ void StopJsonHeartbeat() : ReassignChunkFileIds(item.Chunks, fileId); writer.InsertChunks(chunks); var symbols = item.Symbols == null - ? SymbolExtractor.Extract(fileId, record.Lang, item.Content!, item.FilePath, Path.GetFullPath(options.ProjectPath!)) + ? SymbolExtractor.Extract(fileId, record.Lang, item.Content!, item.FilePath, Path.GetFullPath(options.ProjectPath!), cancellationToken) : ReassignSymbolFileIds(item.Symbols, fileId); if (item.Symbols == null) SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(item.FilePath, record.Lang)); @@ -3674,7 +3676,8 @@ void StopJsonHeartbeat() item.Content!, symbols, record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null) + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + cancellationToken) : ReassignReferenceFileIds(item.References, fileId); postExtractionHooks.OnReferencesExtracted(fileContext, AsMutableList(references)); writer.InsertReferences(references); @@ -4243,14 +4246,14 @@ private static CSharpStaticInterfaceWorkspaceSymbols BuildCSharpStaticInterfaceW try { reportCurrentFile?.Invoke(relativePath); - var (record, content, _, _) = indexer.BuildRecordWithRawBytes(absolutePath); + var (record, content, _, _) = indexer.BuildRecordWithRawBytes(absolutePath, cancellationToken); if (record.Lang != "csharp") continue; if (!MayContainCSharpStaticInterfaceContract(content)) continue; - pendingSymbols.AddRange(SymbolExtractor.Extract(0, record.Lang, content, record.Path)); + pendingSymbols.AddRange(SymbolExtractor.Extract(0, record.Lang, content, record.Path, cancellationToken: cancellationToken)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) { diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractionContext.cs b/src/CodeIndex/Indexer/References/ReferenceExtractionContext.cs index f0d6cbe98e..7f5a870665 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractionContext.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractionContext.cs @@ -13,4 +13,5 @@ public sealed record ReferenceExtractionContext( IReadOnlyList Symbols, string? Path = null, IReadOnlyList? WorkspaceSymbols = null, - string? RequestedLanguage = null); + string? RequestedLanguage = null, + CancellationToken CancellationToken = default); diff --git a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs index 612ea1dc0a..a8bcf9b5cb 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -905,8 +905,10 @@ public static List Extract( string content, IReadOnlyList symbols, string? path = null, - IReadOnlyList? workspaceSymbols = null) + IReadOnlyList? workspaceSymbols = null, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var requestedLanguage = lang; var pluginLanguage = NormalizePluginLanguage(lang); if (!TryGetExtractor(lang, out var extractor)) @@ -921,6 +923,7 @@ public static List Extract( if (content.Contains('\r')) content = content.Replace("\r\n", "\n").Replace("\r", "\n"); content = FileIndexer.StripLineLeadingInvisibles(content); + cancellationToken.ThrowIfCancellationRequested(); return pluginExtractor.Extract( fileId, @@ -938,11 +941,13 @@ public static List Extract( symbols, path, workspaceSymbols, - requestedLanguage)); + requestedLanguage, + cancellationToken)); } internal static List ExtractCore(ReferenceExtractionContext request) { + request.CancellationToken.ThrowIfCancellationRequested(); var fileId = request.FileId; var language = request.Language; var content = request.Content; @@ -955,6 +960,7 @@ internal static List ExtractCore(ReferenceExtractionContext req if (!TryPrepareReferenceLines(language, content, isRazorFile, out var preparedInput)) return []; + request.CancellationToken.ThrowIfCancellationRequested(); content = preparedInput.Content; var lines = preparedInput.Lines; @@ -1155,6 +1161,9 @@ bool HasActiveSameFileCSharpTypeCandidate(string typeExpression, int lineNumber) for (int i = 0; i < lines.Length; i++) { + if ((i & 0x3f) == 0) + request.CancellationToken.ThrowIfCancellationRequested(); + var lineNumber = i + 1; var originalLine = lines[i]; var preparedLine = luaPreparedLines?[i] ?? lispReferenceLines?[i] ?? preparedLines[i]; diff --git a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs index afe8a4b4fc..9659b9f56c 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -1633,8 +1633,10 @@ internal PathFilterResult EvaluatePathFilter(string absolutePath, bool isDirecto internal ScanFilesResult ScanFilesDetailed( IReadOnlySet? checkpointedDirectories = null, - bool continueOnError = true) + bool continueOnError = true, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var files = new List(); var errors = new List(); var nonIndexablePaths = new HashSet(StringComparer.Ordinal); @@ -1651,7 +1653,7 @@ internal ScanFilesResult ScanFilesDetailed( var preloadResult = LoadAncestorIgnoreRules(errors, ref fullyScanned); if (preloadResult.IgnoreRulesAvailable) { - ScanDirectory(_projectRoot, files, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, activeCheckpointedDirectories, attributePrunedDirectories, visitedFileIdentities, preloadResult.Rules, isProjectRoot: true, continueOnError); + ScanDirectory(_projectRoot, files, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, activeCheckpointedDirectories, attributePrunedDirectories, visitedFileIdentities, preloadResult.Rules, isProjectRoot: true, continueOnError, cancellationToken); } return new ScanFilesResult( files, @@ -1680,8 +1682,10 @@ private bool ScanDirectory( HashSet visitedFileIdentities, IgnoreRuleSet activeIgnoreRules, bool isProjectRoot = false, - bool continueOnError = true) + bool continueOnError = true, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var relativeDir = ToRelativePath(dir); if (checkpointedDirectories.Contains(relativeDir)) @@ -1695,7 +1699,7 @@ private bool ScanDirectory( return true; } - return EnumerateDirectory(dir, results, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, checkpointedDirectories, attributePrunedDirectories, visitedFileIdentities, activeIgnoreRules, continueOnError); + return EnumerateDirectory(dir, results, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, checkpointedDirectories, attributePrunedDirectories, visitedFileIdentities, activeIgnoreRules, continueOnError, cancellationToken); } private bool EnumerateDirectory( @@ -1711,8 +1715,10 @@ private bool EnumerateDirectory( HashSet attributePrunedDirectories, HashSet visitedFileIdentities, IgnoreRuleSet inheritedIgnoreRules, - bool continueOnError) + bool continueOnError, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var fullyScanned = true; try { @@ -1744,6 +1750,7 @@ private bool EnumerateDirectory( : null; foreach (var enumeratedFile in _enumerateFiles(dir)) { + cancellationToken.ThrowIfCancellationRequested(); // Strip any \\?\ prefix returned by EnumerateFiles when we passed a long-path // directory, so downstream relative-path math (which compares against the // un-prefixed _projectRoot) still produces the canonical project-relative key. @@ -1861,6 +1868,7 @@ private bool EnumerateDirectory( foreach (var enumeratedSubDir in Directory.EnumerateDirectories(LongPath.EnsureWindowsPrefix(dir))) { + cancellationToken.ThrowIfCancellationRequested(); var subDir = LongPath.RemoveWindowsPrefix(enumeratedSubDir); // In passthrough mode, only descend into subdirectories that are themselves // submodules or submodule ancestors. Treat siblings the same way SkipDirs @@ -1895,7 +1903,7 @@ private bool EnumerateDirectory( continue; } - var childFullyScanned = ScanDirectory(subDir, results, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, checkpointedDirectories, attributePrunedDirectories, visitedFileIdentities, activeIgnoreRules, continueOnError: continueOnError); + var childFullyScanned = ScanDirectory(subDir, results, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, checkpointedDirectories, attributePrunedDirectories, visitedFileIdentities, activeIgnoreRules, continueOnError: continueOnError, cancellationToken: cancellationToken); fullyScanned &= childFullyScanned; if (!continueOnError && !childFullyScanned) break; @@ -2404,9 +2412,9 @@ public static string NormalizePathSeparators(string path) /// Build a FileRecord and return file content (avoids reading the file twice). /// FileRecordを構築しファイル内容も返す(二重読み込み防止)。 /// - public (FileRecord record, string content, string? warning) BuildRecord(string absolutePath) + public (FileRecord record, string content, string? warning) BuildRecord(string absolutePath, CancellationToken cancellationToken = default) { - var (record, content, _, warning) = BuildRecordWithRawBytes(absolutePath); + var (record, content, _, warning) = BuildRecordWithRawBytes(absolutePath, cancellationToken); return (record, content, warning); } @@ -2416,8 +2424,9 @@ public static string NormalizePathSeparators(string path) /// FileRecordを構築し、デコード済み内容とraw bytesを返す。 /// 呼び出し側は再読込なしでエンコーディング検証できる。 /// - public (FileRecord record, string content, byte[] rawBytes, string? warning) BuildRecordWithRawBytes(string absolutePath) + public (FileRecord record, string content, byte[] rawBytes, string? warning) BuildRecordWithRawBytes(string absolutePath, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); if (!IsFilePathSyntaxIndexable(absolutePath)) throw new InvalidOperationException("Cannot index a file path that contains NUL or control characters."); @@ -2475,6 +2484,7 @@ public static string NormalizePathSeparators(string path) int read; while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) { + cancellationToken.ThrowIfCancellationRequested(); total += read; if (total > _maxFileSizeBytes) throw new FileTooLargeSkippedException( diff --git a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs index 8f2ff12b88..d1b43f0eb9 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -2139,8 +2139,9 @@ private static bool IsRustDirectTraitBodyMember(List symbols, int /// Full file content / ファイル全体の内容 /// Relative file path when available / 利用可能なら相対ファイルパス /// List of extracted symbols / 抽出されたシンボルのリスト - public static List Extract(long fileId, string? lang, string content, string? filePath = null, string? projectRoot = null) + public static List Extract(long fileId, string? lang, string content, string? filePath = null, string? projectRoot = null, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var originalLang = lang; lang = NormalizeLanguage(lang); var pluginLanguage = NormalizePluginLanguage(originalLang); @@ -2175,6 +2176,7 @@ public static List Extract(long fileId, string? lang, string conte if (content.Contains('\r')) content = content.Replace("\r\n", "\n").Replace("\r", "\n"); content = FileIndexer.StripLineLeadingInvisibles(content); + cancellationToken.ThrowIfCancellationRequested(); if (pluginLanguage != null && !PatternCache.ContainsKey(pluginLanguage) @@ -2216,6 +2218,7 @@ public static List Extract(long fileId, string? lang, string conte // で `^\s*` 固定パターンを成立させる。行頭以外の U+FEFF (文字列リテラル中 // の意図的な ZWNBSP 等) はそのまま保持する。Closes #183. var lines = content.Split('\n'); + cancellationToken.ThrowIfCancellationRequested(); var pythonModulePrefix = lang == "python" ? GetPythonModulePrefix(filePath) : null; @@ -2284,6 +2287,9 @@ public static List Extract(long fileId, string? lang, string conte for (int i = 0; i < lines.Length; i++) { + if ((i & 0x3f) == 0) + cancellationToken.ThrowIfCancellationRequested(); + if (lang == "csharp" && i <= csharpSuppressedContinuationUntil) continue; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index b85a640754..08888c6f51 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2409,10 +2409,12 @@ void WriteProjectRootOnce() writer.PurgeUnsupportedReferences(ReferenceExtractor.GetSupportedLanguages()); // Scan and index / スキャン・インデックス - var scanResult = indexer.ScanFilesDetailed(); + var requestToken = _currentRequestToken.Value; + requestToken.ThrowIfCancellationRequested(); + var scanResult = indexer.ScanFilesDetailed(cancellationToken: requestToken); var files = scanResult.Files; EmitProgressNotification(progressToken, 0, files.Count, "Index scan complete; indexing files."); - var csharpWorkspace = BuildMcpCSharpStaticInterfaceWorkspaceSymbols(writer, indexer, projectPath, files); + var csharpWorkspace = BuildMcpCSharpStaticInterfaceWorkspaceSymbols(writer, indexer, projectPath, files, requestToken); if (purged > 0 && hadCSharpStaticInterfaceContractsBeforePurge) csharpWorkspace = csharpWorkspace with { HasStaticInterfaceContracts = true }; int processed = 0, skipped = 0, errors = 0; @@ -2423,7 +2425,8 @@ void WriteProjectRootOnce() var fileBatchMarked = false; try { - var (record, content, rawBytes, _) = indexer.BuildRecordWithRawBytes(filePath); + requestToken.ThrowIfCancellationRequested(); + var (record, content, rawBytes, _) = indexer.BuildRecordWithRawBytes(filePath, requestToken); var existingId = writer.GetUnchangedFileId( record.Path, record.Modified, @@ -2450,7 +2453,7 @@ void WriteProjectRootOnce() var fileId = writer.UpsertFile(record); var chunks = ChunkSplitter.Split(fileId, content); writer.InsertChunks(chunks); - var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, filePath, projectPath); + var symbols = SymbolExtractor.Extract(fileId, record.Lang, content, filePath, projectPath, requestToken); SymbolExtractor.ApplyFamilyScope(symbols, indexer.GetFamilyScopeKey(filePath, record.Lang)); var fileContext = new FileContext(projectPath, record.Path, filePath, record.Lang); postExtractionHooks.OnSymbolsExtracted(fileContext, symbols); @@ -2461,7 +2464,8 @@ void WriteProjectRootOnce() content, symbols, record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null); + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + requestToken); postExtractionHooks.OnReferencesExtracted(fileContext, references); writer.InsertReferences(references); // Keep MCP index parity with CLI index: persist file-level validation issues too. @@ -2919,12 +2923,14 @@ private static CSharpStaticInterfaceWorkspaceSymbols BuildMcpCSharpStaticInterfa DbWriter writer, FileIndexer indexer, string projectRoot, - IEnumerable filePaths) + IEnumerable filePaths, + CancellationToken cancellationToken = default) { var pendingSymbols = new List(); var pendingPaths = new HashSet(StringComparer.Ordinal); foreach (var filePath in filePaths) { + cancellationToken.ThrowIfCancellationRequested(); var absolutePath = Path.IsPathRooted(filePath) ? filePath : Path.Combine(projectRoot, filePath.Replace('/', Path.DirectorySeparatorChar)); @@ -2940,11 +2946,11 @@ private static CSharpStaticInterfaceWorkspaceSymbols BuildMcpCSharpStaticInterfa try { - var (record, content, _, _) = indexer.BuildRecordWithRawBytes(absolutePath); + var (record, content, _, _) = indexer.BuildRecordWithRawBytes(absolutePath, cancellationToken); if (record.Lang != "csharp") continue; - pendingSymbols.AddRange(SymbolExtractor.Extract(0, record.Lang, content, record.Path)); + pendingSymbols.AddRange(SymbolExtractor.Extract(0, record.Lang, content, record.Path, cancellationToken: cancellationToken)); } catch { diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index 06773c537c..bc555f1372 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -16,6 +16,24 @@ namespace CodeIndex.Tests; /// public class FileIndexerTests { + [Fact] + public void ScanFilesDetailed_CancelledToken_ThrowsBeforeEnumeration() + { + var tempDir = Path.Combine(Path.GetTempPath(), $"cdidx-cancel-scan-{Guid.NewGuid():N}"); + Directory.CreateDirectory(tempDir); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + try + { + Assert.Throws(() => + new FileIndexer(tempDir).ScanFilesDetailed(cancellationToken: cancellation.Token)); + } + finally + { + TestProjectHelper.DeleteDirectory(tempDir); + } + } + [Fact] public void ScanFilesDetailed_CaseInsensitiveChildDirectory_SkipsCaseOnlyDuplicatePathWithWarning() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index be0acd776d..7529fb7e3e 100644 --- a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs +++ b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs @@ -11,6 +11,16 @@ namespace CodeIndex.Tests; /// public class ReferenceExtractorTests { + [Fact] + public void Extract_CancelledToken_ThrowsBeforeWork() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.Throws(() => + ReferenceExtractor.Extract(1, "csharp", "public class App { }", [], cancellationToken: cancellation.Token)); + } + [Fact] public void Extract_CSharpSelfCall_StampsSelfReference() { diff --git a/tests/CodeIndex.Tests/SymbolExtractorTests.cs b/tests/CodeIndex.Tests/SymbolExtractorTests.cs index ecd7fb5960..e0e91ae3e1 100644 --- a/tests/CodeIndex.Tests/SymbolExtractorTests.cs +++ b/tests/CodeIndex.Tests/SymbolExtractorTests.cs @@ -14,6 +14,16 @@ namespace CodeIndex.Tests; /// public class SymbolExtractorTests { + [Fact] + public void Extract_CancelledToken_ThrowsBeforeWork() + { + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + + Assert.Throws(() => + SymbolExtractor.Extract(1, "csharp", "public class App { }", cancellationToken: cancellation.Token)); + } + [Fact] public void Extract_CustomSymbolPlugin_HandlesUnsupportedLanguage() { From 2954b9d7c8da92c669bbaf9dbd72c467aa92922b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:40:00 +0900 Subject: [PATCH 2/7] Add index cancellation regression test (#1818) --- TESTING_GUIDE.md | 4 ++ changelog.d/unreleased/1818.fixed.md | 19 ++++++++++ .../IndexCommandRunnerTests.cs | 38 +++++++++++++++++++ 3 files changed, 61 insertions(+) create mode 100644 changelog.d/unreleased/1818.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index fa2676ab72..4afe69fa4d 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -40,6 +40,8 @@ The test project mirrors the production areas closely. End-to-end upgrade path: seeds a pre-column legacy DB, opens it through `TryMigrateForRead`, and exercises the read paths that touch nullable symbol ordinals (outline, symbol search, nearby, unused, analyze bundle) to lock in the real-world failure mode behind #58 / #49. - `IndexCommandRunnerTests.cs`, `QueryCommandRunnerTests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` CLI parsing, command execution, and installer behavior. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. +- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson` + exercises the same in-process cancellation path used after Ctrl-C/SIGINT wiring so interrupted index runs keep returning the canonical JSON error contract. - `SymbolExtractorTests.Extract_CSharp_InstallScriptFixture_CompletesWithinPracticalBudget` is a coarse runaway guard for the real `InstallScriptTests.cs` C# extraction fixture. Its wall-clock budget is intentionally broader than a benchmark so slower or noisy CI hosts do not fail the suite for ordinary variance. - `IndexCommandRunnerTests.RunBackfillFold_PublishedTrimmedBinary_SerializesSuccessAndErrorJson` @@ -225,6 +227,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" エンドツーエンドのアップグレード経路: カラム追加前のレガシー DB を用意し、`TryMigrateForRead` 経由で開いてから NULL になりうるシンボル列を触る read path(outline、シンボル検索、近傍、unused、analyze バンドル)を一通り叩き、#58 / #49 の実機失敗モードを固定する。 - `IndexCommandRunnerTests.cs`、`QueryCommandRunnerTests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` CLI の引数解析、コマンド実行、installer 挙動のテスト。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。 +- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson` + Ctrl-C/SIGINT 配線後に使われる in-process cancellation 経路を検証し、interrupted index run が標準の JSON error contract を返し続けることを固定する。 - `SymbolExtractorTests.Extract_CSharp_InstallScriptFixture_CompletesWithinPracticalBudget` は実ファイル `InstallScriptTests.cs` を C# 抽出に通す coarse な runaway guard です。wall-clock の予算は benchmark より意図的に広く取り、遅い / 混雑した CI host で通常の揺れだけにより suite が失敗しないようにしています。 - `IndexCommandRunnerTests.RunBackfillFold_PublishedTrimmedBinary_SerializesSuccessAndErrorJson` diff --git a/changelog.d/unreleased/1818.fixed.md b/changelog.d/unreleased/1818.fixed.md new file mode 100644 index 0000000000..40ef3c1634 --- /dev/null +++ b/changelog.d/unreleased/1818.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 1818 +affected: + - tests/CodeIndex.Tests/IndexCommandRunnerTests.cs + - tests/CodeIndex.Tests/FileIndexerTests.cs + - tests/CodeIndex.Tests/SymbolExtractorTests.cs + - tests/CodeIndex.Tests/ReferenceExtractorTests.cs + - TESTING_GUIDE.md +--- + +## English + +- **Added cancellation-path regression tests for indexing and extractors (#1818)** — the test suite now exercises in-process index cancellation and cancelled-token entry points for the scanner, symbol extractor, and reference extractor so signal/Ctrl-C wiring regressions are caught earlier. + +## 日本語 + +- **index と抽出器のキャンセル経路の回帰テストを追加しました (#1818)** — テストスイートは in-process index cancellation と、scanner・symbol extractor・reference extractor の cancelled-token 入口を検証し、signal / Ctrl-C 配線の退行をより早く検出します。 diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 40a2119581..88b1a4626e 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -178,6 +178,44 @@ public void Run_NewIndexDatabase_RunsAnalyzeAfterSuccessfulIndex() } } + [Fact] + public void Run_CancelDuringFreshIndex_ReturnsInterruptedJson() + { + var projectRoot = CreateTempProject(); + using var cancellation = new CancellationTokenSource(); + try + { + File.WriteAllText(Path.Combine(projectRoot, "app.cs"), "public class App { public void Run() { } }\n"); + IndexCommandRunner.FullScanExtractionSchedulingForTesting = (_, _) => cancellation.Cancel(); + + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var stdout = new StringWriter(); + try + { + Console.SetOut(stdout); + var exitCode = IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions, cancellation); + + Assert.Equal(CommandExitCodes.Interrupted, exitCode); + using var doc = JsonDocument.Parse(stdout.ToString()); + Assert.Equal("error", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.Interrupted, doc.RootElement.GetProperty("error_code").GetString()); + } + finally + { + Console.SetOut(originalOut); + } + } + } + finally + { + IndexCommandRunner.FullScanExtractionSchedulingForTesting = null; + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_ExistingIndexDatabase_RunsPragmaOptimizeAfterSuccessfulIndex() { From 696ca2b58494d1493aa0f5871fade873f8b39fb4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 04:59:44 +0900 Subject: [PATCH 3/7] Preserve MCP cancellation during indexing (#1841) --- src/CodeIndex/Mcp/McpToolHandlers.cs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 08888c6f51..5ec49e1bbb 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2515,6 +2515,12 @@ void WriteProjectRootOnce() errors++; } } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + if (fileBatchMarked) + writer.ClearBatchInProgress(); + throw; + } catch { if (fileBatchMarked) From 34ec84f3eebf59196f91921c0452af75c166aa90 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:05:47 +0900 Subject: [PATCH 4/7] Complete CLI cancellation plumbing (#1841) --- src/CodeIndex/Cli/IndexCommandRunner.cs | 2 +- tests/CodeIndex.Tests/IndexCommandRunnerTests.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 1861d0b8a8..c17ee01087 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -2026,7 +2026,7 @@ void ThrowIfUpdateCancelled() continue; } - var (record, content, rawBytes, warning) = indexer.BuildRecordWithRawBytes(absPath); + var (record, content, rawBytes, warning) = indexer.BuildRecordWithRawBytes(absPath, cancellationToken); if (warning != null && !options.Json && !options.Quiet) { diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 79018b1121..ddb6f611bf 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -7228,7 +7228,7 @@ public void Call(Api api) var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); var (hotspotsExitCode, hotspotsJson) = RunHotspotsJsonWithPaths(dbPath, "csharp", "function", ["projA/", "projB/"]); - Assert.Equal(CommandExitCodes.NotFound, hotspotsExitCode); + Assert.Equal(CommandExitCodes.Success, hotspotsExitCode); Assert.True(hotspotsJson.GetProperty("hotspot_family_ready").GetBoolean()); Assert.Equal(0, hotspotsJson.GetProperty("count").GetInt32()); Assert.Empty(hotspotsJson.GetProperty("hotspots").EnumerateArray()); From 86fd2e72f71595319d0b81a229b0c430ca05049b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:08:15 +0900 Subject: [PATCH 5/7] Keep MCP prepass cancellation interruptible (#1841) --- src/CodeIndex/Mcp/McpToolHandlers.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 7d27e3c69f..1f7070b2fd 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3071,6 +3071,10 @@ private static CSharpStaticInterfaceWorkspaceSymbols BuildMcpCSharpStaticInterfa pendingSymbols.AddRange(SymbolExtractor.Extract(0, record.Lang, content, record.Path, cancellationToken: cancellationToken)); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch { } From 24b4898ff8d7f195f108d0ce410d20a2c81400c4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:18:12 +0900 Subject: [PATCH 6/7] Propagate scan cancellation through CLI entrypoints (#1841) --- TESTING_GUIDE.md | 8 ++-- src/CodeIndex/Cli/IndexCommandRunner.cs | 42 ++++++++++++++++--- .../IndexCommandRunnerTests.cs | 37 ++++++++++++++++ 3 files changed, 78 insertions(+), 9 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 4afe69fa4d..cecadb789d 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -40,8 +40,8 @@ The test project mirrors the production areas closely. End-to-end upgrade path: seeds a pre-column legacy DB, opens it through `TryMigrateForRead`, and exercises the read paths that touch nullable symbol ordinals (outline, symbol search, nearby, unused, analyze bundle) to lock in the real-world failure mode behind #58 / #49. - `IndexCommandRunnerTests.cs`, `QueryCommandRunnerTests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` CLI parsing, command execution, and installer behavior. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. -- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson` - exercises the same in-process cancellation path used after Ctrl-C/SIGINT wiring so interrupted index runs keep returning the canonical JSON error contract. +- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson` and `Run_CancelDuringDryRunScan_ReturnsInterruptedJson` + exercise the same in-process cancellation paths used after Ctrl-C/SIGINT wiring, including scan-time cancellation, so interrupted index runs keep returning the canonical JSON error contract. - `SymbolExtractorTests.Extract_CSharp_InstallScriptFixture_CompletesWithinPracticalBudget` is a coarse runaway guard for the real `InstallScriptTests.cs` C# extraction fixture. Its wall-clock budget is intentionally broader than a benchmark so slower or noisy CI hosts do not fail the suite for ordinary variance. - `IndexCommandRunnerTests.RunBackfillFold_PublishedTrimmedBinary_SerializesSuccessAndErrorJson` @@ -227,8 +227,8 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" エンドツーエンドのアップグレード経路: カラム追加前のレガシー DB を用意し、`TryMigrateForRead` 経由で開いてから NULL になりうるシンボル列を触る read path(outline、シンボル検索、近傍、unused、analyze バンドル)を一通り叩き、#58 / #49 の実機失敗モードを固定する。 - `IndexCommandRunnerTests.cs`、`QueryCommandRunnerTests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` CLI の引数解析、コマンド実行、installer 挙動のテスト。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。 -- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson` - Ctrl-C/SIGINT 配線後に使われる in-process cancellation 経路を検証し、interrupted index run が標準の JSON error contract を返し続けることを固定する。 +- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson` と `Run_CancelDuringDryRunScan_ReturnsInterruptedJson` + Ctrl-C/SIGINT 配線後に使われる in-process cancellation 経路を、scan 中のキャンセルも含めて検証し、interrupted index run が標準の JSON error contract を返し続けることを固定する。 - `SymbolExtractorTests.Extract_CSharp_InstallScriptFixture_CompletesWithinPracticalBudget` は実ファイル `InstallScriptTests.cs` を C# 抽出に通す coarse な runaway guard です。wall-clock の予算は benchmark より意図的に広く取り、遅い / 混雑した CI host で通常の揺れだけにより suite が失敗しないようにしています。 - `IndexCommandRunnerTests.RunBackfillFold_PublishedTrimmedBinary_SerializesSuccessAndErrorJson` diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index c17ee01087..5825a51b90 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -278,6 +278,14 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) } } + int WriteDryRunInterrupted() => WriteCommandError( + options.Json, + jsonOptions, + "Interrupted before dry-run scan completed.", + CommandExitCodes.Interrupted, + "Rerun `cdidx index --dry-run` when you are ready to inspect the candidate files again.", + CommandErrorCodes.Interrupted); + if (options.UpdateFiles.Count > 0) { // --files: only the specified files / --files: 指定ファイルのみ @@ -285,7 +293,15 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) var updatePaths = NormalizeUpdateFileTargets(options.ProjectPath, options.UpdateFiles, options.Json); if (relevantIgnoreFileChanged || ContainsIgnoreFilePath(updatePaths)) { - var scanResult = dryIndexer.ScanFilesDetailed(); + FileIndexer.ScanFilesResult scanResult; + try + { + scanResult = dryIndexer.ScanFilesDetailed(cancellationToken: indexCancellation.Token); + } + catch (OperationCanceledException) when (indexCancellation.IsCancellationRequested) + { + return WriteDryRunInterrupted(); + } dryCandidates = scanResult.Files; RecordDryRunScanErrors(scanResult.Errors); } @@ -340,7 +356,15 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) if (relevantIgnoreFileChanged || ContainsIgnoreFilePath(changedFiles)) { - var scanResult = dryIndexer.ScanFilesDetailed(); + FileIndexer.ScanFilesResult scanResult; + try + { + scanResult = dryIndexer.ScanFilesDetailed(cancellationToken: indexCancellation.Token); + } + catch (OperationCanceledException) when (indexCancellation.IsCancellationRequested) + { + return WriteDryRunInterrupted(); + } dryCandidates = scanResult.Files; RecordDryRunScanErrors(scanResult.Errors); } @@ -354,7 +378,15 @@ void RecordDryRunScanErrors(IEnumerable scanErrors) } else { - var scanResult = dryIndexer.ScanFilesDetailed(); + FileIndexer.ScanFilesResult scanResult; + try + { + scanResult = dryIndexer.ScanFilesDetailed(cancellationToken: indexCancellation.Token); + } + catch (OperationCanceledException) when (indexCancellation.IsCancellationRequested) + { + return WriteDryRunInterrupted(); + } dryCandidates = scanResult.Files; RecordDryRunScanErrors(scanResult.Errors); } @@ -1715,7 +1747,7 @@ void ThrowIfUpdateCancelled() var expandHeartbeat = StartJsonPhaseHeartbeat("expanding C# update set for static interface contracts"); try { - foreach (var filePath in indexer.ScanFilesDetailed().Files) + foreach (var filePath in indexer.ScanFilesDetailed(cancellationToken: cancellationToken).Files) { var detection = FileIndexer.TryDetectLanguage(filePath); if (detection.Status == FileIndexer.FileProbeStatus.Supported @@ -3188,7 +3220,7 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) try { ThrowIfFullScanCancelled(0, null); - scanResult = indexer.ScanFilesDetailed(checkpointedDirectories, continueOnError: true); + scanResult = indexer.ScanFilesDetailed(checkpointedDirectories, continueOnError: true, cancellationToken: cancellationToken); ThrowIfFullScanCancelled(0, null); } finally diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index ddb6f611bf..9b9e67e117 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -257,6 +257,43 @@ public void Run_CancelDuringFreshIndex_ReturnsInterruptedJson() } } + [Fact] + public void Run_CancelDuringDryRunScan_ReturnsInterruptedJson() + { + var projectRoot = CreateTempProject(); + using var cancellation = new CancellationTokenSource(); + try + { + File.WriteAllText(Path.Combine(projectRoot, "app.cs"), "public class App { }\n"); + cancellation.Cancel(); + + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var stdout = new StringWriter(); + try + { + Console.SetOut(stdout); + var exitCode = IndexCommandRunner.Run([projectRoot, "--dry-run", "--json"], _jsonOptions, cancellation); + + Assert.Equal(CommandExitCodes.Interrupted, exitCode); + using var doc = JsonDocument.Parse(stdout.ToString()); + Assert.Equal("error", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.Interrupted, doc.RootElement.GetProperty("error_code").GetString()); + } + finally + { + Console.SetOut(originalOut); + } + } + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_ExistingIndexDatabase_RunsPragmaOptimizeAfterSuccessfulIndex() { From d38c9ec667cebf6bdfcd58e4dce269b217f00db9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Mon, 25 May 2026 05:22:40 +0900 Subject: [PATCH 7/7] Normalize scan cancellation results (#1841) --- TESTING_GUIDE.md | 4 +- src/CodeIndex/Cli/IndexCommandRunner.cs | 4 ++ .../IndexCommandRunnerTests.cs | 37 +++++++++++++++++++ 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index cecadb789d..a07dd80258 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -40,7 +40,7 @@ The test project mirrors the production areas closely. End-to-end upgrade path: seeds a pre-column legacy DB, opens it through `TryMigrateForRead`, and exercises the read paths that touch nullable symbol ordinals (outline, symbol search, nearby, unused, analyze bundle) to lock in the real-world failure mode behind #58 / #49. - `IndexCommandRunnerTests.cs`, `QueryCommandRunnerTests.cs`, `ProgramCliTests.cs`, `InstallScriptTests.cs` CLI parsing, command execution, and installer behavior. `ProgramCliTests.cs` covers top-level entrypoint behavior that must be exercised through a subprocess, while `InstallScriptTests.cs` runs focused bash snippets against `install.sh` in library mode to lock in release-installer regressions without performing real network installs. -- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson` and `Run_CancelDuringDryRunScan_ReturnsInterruptedJson` +- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`, `Run_CancelDuringDryRunScan_ReturnsInterruptedJson`, and `Run_CancelBeforeFreshScan_ReturnsInterruptedJson` exercise the same in-process cancellation paths used after Ctrl-C/SIGINT wiring, including scan-time cancellation, so interrupted index runs keep returning the canonical JSON error contract. - `SymbolExtractorTests.Extract_CSharp_InstallScriptFixture_CompletesWithinPracticalBudget` is a coarse runaway guard for the real `InstallScriptTests.cs` C# extraction fixture. Its wall-clock budget is intentionally broader than a benchmark so slower or noisy CI hosts do not fail the suite for ordinary variance. @@ -227,7 +227,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" エンドツーエンドのアップグレード経路: カラム追加前のレガシー DB を用意し、`TryMigrateForRead` 経由で開いてから NULL になりうるシンボル列を触る read path(outline、シンボル検索、近傍、unused、analyze バンドル)を一通り叩き、#58 / #49 の実機失敗モードを固定する。 - `IndexCommandRunnerTests.cs`、`QueryCommandRunnerTests.cs`、`ProgramCliTests.cs`、`InstallScriptTests.cs` CLI の引数解析、コマンド実行、installer 挙動のテスト。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。 -- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson` と `Run_CancelDuringDryRunScan_ReturnsInterruptedJson` +- `IndexCommandRunnerTests.Run_CancelDuringFreshIndex_ReturnsInterruptedJson`、`Run_CancelDuringDryRunScan_ReturnsInterruptedJson`、`Run_CancelBeforeFreshScan_ReturnsInterruptedJson` Ctrl-C/SIGINT 配線後に使われる in-process cancellation 経路を、scan 中のキャンセルも含めて検証し、interrupted index run が標準の JSON error contract を返し続けることを固定する。 - `SymbolExtractorTests.Extract_CSharp_InstallScriptFixture_CompletesWithinPracticalBudget` は実ファイル `InstallScriptTests.cs` を C# 抽出に通す coarse な runaway guard です。wall-clock の予算は benchmark より意図的に広く取り、遅い / 混雑した CI host で通常の揺れだけにより suite が失敗しないようにしています。 diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 5825a51b90..de9c301fe3 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -3223,6 +3223,10 @@ void ThrowIfFullScanCancelled(int filesProcessed, int? filesTotal) scanResult = indexer.ScanFilesDetailed(checkpointedDirectories, continueOnError: true, cancellationToken: cancellationToken); ThrowIfFullScanCancelled(0, null); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw new IndexInterruptedException(0, null); + } finally { StopJsonPhaseHeartbeat(scanHeartbeat); diff --git a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index 9b9e67e117..9a00910df6 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -294,6 +294,43 @@ public void Run_CancelDuringDryRunScan_ReturnsInterruptedJson() } } + [Fact] + public void Run_CancelBeforeFreshScan_ReturnsInterruptedJson() + { + var projectRoot = CreateTempProject(); + using var cancellation = new CancellationTokenSource(); + try + { + File.WriteAllText(Path.Combine(projectRoot, "app.cs"), "public class App { }\n"); + cancellation.Cancel(); + + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var stdout = new StringWriter(); + try + { + Console.SetOut(stdout); + var exitCode = IndexCommandRunner.Run([projectRoot, "--json"], _jsonOptions, cancellation); + + Assert.Equal(CommandExitCodes.Interrupted, exitCode); + using var doc = JsonDocument.Parse(stdout.ToString()); + Assert.Equal("error", doc.RootElement.GetProperty("status").GetString()); + Assert.Equal(CommandErrorCodes.Interrupted, doc.RootElement.GetProperty("error_code").GetString()); + } + finally + { + Console.SetOut(originalOut); + } + } + } + finally + { + SqliteConnection.ClearAllPools(); + DeleteDirectory(projectRoot); + } + } + [Fact] public void Run_ExistingIndexDatabase_RunsPragmaOptimizeAfterSuccessfulIndex() {