diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index fa2676ab72..a07dd80258 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`, `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. - `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`、`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 が失敗しないようにしています。 - `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/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 62166cf943..daf07cfff1 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); } @@ -1706,7 +1738,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 @@ -2018,7 +2050,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) { @@ -2080,7 +2112,7 @@ 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!)); + 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); @@ -2094,7 +2126,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 / エンコーディング問題を検証 @@ -3179,9 +3212,13 @@ 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); } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw new IndexInterruptedException(0, null); + } finally { StopJsonPhaseHeartbeat(scanHeartbeat); @@ -3508,7 +3545,7 @@ void StopJsonHeartbeat() { var relativeFilePath = FileIndexer.NormalizePathSeparators(Path.GetRelativePath(projectRoot, filePath)); activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(relativeFilePath, "reading"); - 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; @@ -3518,7 +3555,7 @@ 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!)); + 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( @@ -3527,7 +3564,8 @@ void StopJsonHeartbeat() content, symbols, record.Path, - record.Lang == "csharp" ? csharpWorkspace.Symbols : null); + record.Lang == "csharp" ? csharpWorkspace.Symbols : null, + cancellationToken); activeJsonExtractionPhases[workerIndex] = FormatIndexPhasePath(record.Path, "validating"); issues = FileIndexer.ValidateContent(record.Path, rawBytes, content); } @@ -3698,7 +3736,7 @@ void StopJsonHeartbeat() writer.InsertChunks(chunks); currentJsonIndexFile = FormatIndexPhasePath(record.Path, "symbols"); 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)); @@ -3717,7 +3755,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); @@ -4289,14 +4328,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 41ae9b1f99..9a7095a5ed 100644 --- a/src/CodeIndex/Indexer/References/ReferenceExtractor.cs +++ b/src/CodeIndex/Indexer/References/ReferenceExtractor.cs @@ -908,8 +908,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)) @@ -924,6 +926,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, @@ -941,11 +944,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; @@ -958,6 +963,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; @@ -1167,6 +1173,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 caf0dda079..a3b737ee30 100644 --- a/src/CodeIndex/Indexer/Scanning/FileIndexer.cs +++ b/src/CodeIndex/Indexer/Scanning/FileIndexer.cs @@ -1683,8 +1683,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); @@ -1702,7 +1704,7 @@ internal ScanFilesResult ScanFilesDetailed( var preloadResult = LoadAncestorIgnoreRules(errors, ref fullyScanned); if (preloadResult.IgnoreRulesAvailable) { - ScanDirectory(_projectRoot, files, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, activeCheckpointedDirectories, attributePrunedDirectories, nestedRepositories, visitedFileIdentities, preloadResult.Rules, isProjectRoot: true, continueOnError); + ScanDirectory(_projectRoot, files, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, activeCheckpointedDirectories, attributePrunedDirectories, nestedRepositories, visitedFileIdentities, preloadResult.Rules, isProjectRoot: true, continueOnError, cancellationToken); } return new ScanFilesResult( files, @@ -1733,8 +1735,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)) @@ -1748,7 +1752,7 @@ private bool ScanDirectory( return true; } - return EnumerateDirectory(dir, results, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, checkpointedDirectories, attributePrunedDirectories, nestedRepositories, visitedFileIdentities, activeIgnoreRules, continueOnError); + return EnumerateDirectory(dir, results, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, checkpointedDirectories, attributePrunedDirectories, nestedRepositories, visitedFileIdentities, activeIgnoreRules, continueOnError, cancellationToken); } private bool IsNestedGitRepository(string dir) @@ -1774,8 +1778,10 @@ private bool EnumerateDirectory( HashSet nestedRepositories, HashSet visitedFileIdentities, IgnoreRuleSet inheritedIgnoreRules, - bool continueOnError) + bool continueOnError, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var fullyScanned = true; try { @@ -1807,6 +1813,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. @@ -1924,6 +1931,7 @@ private bool EnumerateDirectory( foreach (var enumeratedSubDir in Directory.EnumerateDirectories(LongPath.EnsureWindowsPrefix(dir))) { + cancellationToken.ThrowIfCancellationRequested(); var subDir = LongPath.RemoveWindowsPrefix(enumeratedSubDir); if (IsNestedGitRepository(subDir) && !IsSubmoduleOrAncestor(subDir)) { @@ -1967,7 +1975,7 @@ private bool EnumerateDirectory( continue; } - var childFullyScanned = ScanDirectory(subDir, results, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, checkpointedDirectories, attributePrunedDirectories, nestedRepositories, visitedFileIdentities, activeIgnoreRules, continueOnError: continueOnError); + var childFullyScanned = ScanDirectory(subDir, results, errors, nonIndexablePaths, unknownExtensionFiles, probeFailedFilePaths, listedDirectories, fullyScannedDirectories, checkpointedDirectories, attributePrunedDirectories, nestedRepositories, visitedFileIdentities, activeIgnoreRules, continueOnError: continueOnError, cancellationToken: cancellationToken); fullyScanned &= childFullyScanned; if (!continueOnError && !childFullyScanned) break; @@ -2540,9 +2548,9 @@ public static string NormalizeIndexPath(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); } @@ -2552,8 +2560,9 @@ public static string NormalizeIndexPath(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."); @@ -2612,6 +2621,7 @@ public static string NormalizeIndexPath(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 66f3b7c413..3cc561af32 100644 --- a/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs +++ b/src/CodeIndex/Indexer/Symbols/SymbolExtractor.cs @@ -2143,8 +2143,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); @@ -2179,6 +2180,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) @@ -2220,6 +2222,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; @@ -2288,6 +2291,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 3741a0b2b1..d9675d782f 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2577,10 +2577,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; @@ -2591,7 +2593,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, @@ -2618,7 +2621,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); @@ -2629,7 +2632,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. @@ -2679,6 +2683,12 @@ void WriteProjectRootOnce() errors++; } } + catch (OperationCanceledException) when (requestToken.IsCancellationRequested) + { + if (fileBatchMarked) + writer.ClearBatchInProgress(); + throw; + } catch { if (fileBatchMarked) @@ -3091,12 +3101,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)); @@ -3112,11 +3124,15 @@ 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 (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; } catch { diff --git a/tests/CodeIndex.Tests/FileIndexerTests.cs b/tests/CodeIndex.Tests/FileIndexerTests.cs index c98be6b6c2..867fe0b024 100644 --- a/tests/CodeIndex.Tests/FileIndexerTests.cs +++ b/tests/CodeIndex.Tests/FileIndexerTests.cs @@ -17,6 +17,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/IndexCommandRunnerTests.cs b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs index c227d2575e..2ae20d0276 100644 --- a/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexCommandRunnerTests.cs @@ -219,6 +219,118 @@ 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_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_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() { diff --git a/tests/CodeIndex.Tests/ReferenceExtractorTests.cs b/tests/CodeIndex.Tests/ReferenceExtractorTests.cs index 12f929d97f..132df7f6ec 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 100f38231c..af6fec0526 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() {