From 1f78752567f4b16f0ed5bd177276436df871776f Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 23 Sep 2026 07:59:46 +0900 Subject: [PATCH 1/3] Propagate cancellation through plain search execution (#5421) --- TESTING_GUIDE.md | 2 + USER_GUIDE.md | 8 + changelog.d/unreleased/5421.fixed.md | 16 ++ .../Cli/QueryCommandRunner.SearchExecution.cs | 21 ++- .../Cli/QueryCommandRunner.SearchRouting.cs | 3 +- ...eryCommandRunnerSearchCancellationTests.cs | 148 ++++++++++++++++++ 6 files changed, 192 insertions(+), 6 deletions(-) create mode 100644 changelog.d/unreleased/5421.fixed.md create mode 100644 tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 22e444260f..bd62d1631c 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -836,6 +836,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. Search-core orchestration coverage must keep validation ahead of database execution, preserve list-recipes > named-query > recipe > plain route precedence and the recipe-field-before-conflict / plain-conflict-before-field aggregation error order, keep count-mode zero/nonzero payloads aligned, retain strict-not-found exit semantics, and order profiled NDJSON as result records, profile metadata, then the terminal record. Plain search strict-count coverage (#5406) extends the shared #5393 direct/batch fixture and `OriginContinuationIssue5348Tests`. On net8/net9, preserve zero/nonzero numeric and JSON counts, `--format count`, serial/parallel batch classification, snapshot guards, and byte-budget precedence. Filtered unknown origins must remain non-authoritative even with `--allow-partial`; completed classification may establish a strict zero. Keep grouped/named/recipe and MCP count regressions as controls. + `QueryCommandRunnerSearchCancellationTests` (#5421) covers pre-cancelled plain rows/counts/aggregations and cancellation inside real SQLite search statements through direct, CLI and serial/parallel batch entry points. Keep FTS hits, exact misses, numeric/JSON output, strict/allow-partial precedence, native interruption and successful retry on net8/net9. The console-sensitive fixture restores its async-local connection factory; connection-local SQL callbacks trigger cancellation without sleeps or global pool resets. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `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. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. Suggestions usage-hint coverage for #5196 must keep unknown-option human diagnostics and missing-id / invalid-combination JSON errors aligned on `cdidx suggestions --help`, preserve the existing error-code, category, usage, and exit-code contracts, and assert that the literal `` placeholder is never emitted. `CommandErrorWriterTests.cs` must keep catalog-backed known-command hints and the global fallback for missing, unknown, nested, or control-bearing command identities. Explicit `--files` preflight coverage for #5091 / #5122 keeps real/dry-run and JSON/text failure behavior in one focused contract matrix: bare `--files`, absolute and relative outside-root paths, platform-supported symlink escapes, `none`-mode directory-segment rejection, `internal`/`all` acceptance, nonexistent, directory, filtered, and unsupported targets, canonical duplicates (including target-specific casing and native Unicode/alias spelling), mixed valid/invalid atomicity, raw-token provenance beside `--project` expansion, already-indexed cleanup and existing or deleted reconciliation-control exceptions, generated-code acceptance, unreadable membership snapshots, and the implicit empty full-scan control without `--files`. Include Windows 8.3 ancestor aliases that must map back to established database spelling. Indexed controls replaced by a directory, FIFO, or policy-disallowed symlink must remain cleanup tombstones without opening or following the replacement. TypeScript path-alias configuration tests must cover `none`, inside/outside-root `internal`, and `all`, prove that only regular resolved targets are secure-opened, and keep real/dry-run plus in-process/worker paths aligned. Assert `UsageError`, bounded and redacted per-input reason diagnostics, and unchanged indexed rows and metadata whenever any explicit token is rejected; snapshot-read failures fail closed with a database error. @@ -2382,6 +2383,7 @@ results-only・array・envelope・compact の形状、終端と改行の厳密 YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 search core の orchestration coverage では、validation が database execution より先に行われること、list-recipes > named-query > recipe > plain の route precedence と recipe では field error が conflict より先・plain では conflict が field error より先になる aggregation error 順、count mode の zero/nonzero payload、strict-not-found の exit semantics、profile 付き NDJSON が result record、profile metadata、terminal record の順になることを維持してください。 通常検索の strict 件数テスト (#5406) は、#5393 の直接実行/batch 共有 fixture と `OriginContinuationIssue5348Tests` を拡張します。net8/net9 でゼロ件/非ゼロ件の数値・JSON、`--format count`、直列/並列 batch の分類、スナップショットの保護、応答サイズエラーの優先順位を維持してください。フィルターで除外した分類不明の候補は `--allow-partial` 指定時も不在の根拠にせず、分類完了時だけ strict の確定したゼロ件として扱います。grouped/named/recipe と MCP の既存件数テストも対照として実行します。 + `QueryCommandRunnerSearchCancellationTests` (#5421) は、通常検索の行・件数・集計の事前取消と、直接実行・CLI・直列/並列バッチでの SQLite 検索文の実行中の取消を検証します。FTS の一致、完全一致検索の未検出、数値/JSON 出力、strict/allow-partial より取消が優先すること、SQLite の中断と再実行の成功を net8/net9 で維持してください。console-sensitive なフィクスチャは非同期コンテキスト単位の接続ファクトリを復元し、接続単位の SQL コールバックで取消を発生させます。待ち時間や全体の接続プール初期化には依存しません。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 #5196 の suggestions usage-hint coverage では、unknown option の human 診断と missing id / invalid combination の JSON error がともに `cdidx suggestions --help` を案内し、既存の error code、category、usage、exit code 契約を維持し、literal `` placeholder を出力しないことを固定してください。`CommandErrorWriterTests.cs` では catalog に基づく既知 command の hint と、command identity が欠落・未知・nested・control character を含む場合の global fallback を維持してください。 #5091 / #5122 の明示的な `--files` preflight coverage では、通常実行 / dry-run と JSON / text の failure behavior を 1 つの focused contract matrix に保ちます。path token のない `--files`、absolute / relative の project root 外 path、対応 platform での symlink escape、`none` mode での directory segment symlink の拒否、`internal` / `all` mode での受理、存在しない target、directory、filter 対象、未対応 target、canonical duplicate(target ごとの casing と native Unicode/alias spelling を含む)、valid / invalid 混在時の atomicity、`--project` 展開と並ぶ raw token provenance、既に index 済みの cleanup、既存または削除済みの reconciliation control の例外、generated code の受理、membership snapshot の読取不能、`--files` のない暗黙の空 full scan control を検証してください。Windows の 8.3 ancestor alias は DB に確立済みの spelling へ戻ることを検証してください。indexed control が directory、FIFO、または policy で禁止された symlink に置換された場合は、置換 object を open / follow せず cleanup tombstone を維持する必要があります。TypeScript path alias configuration test は `none`、root 内外の `internal`、`all` を網羅し、解決後の regular target だけが secure-open されることを証明し、通常実行 / dry-run と in-process / worker path を一致させてください。明示 token が 1 件でも拒否された場合は、`UsageError`、上限付き・伏字化済みの入力別 reason diagnostic、indexed row と metadata の不変性を assertion し、snapshot 読取失敗は database error で fail closed することを確認します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a13d6151e7..e2043dad67 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -441,6 +441,10 @@ find counts, incomplete search-origin classification, and potentially stale snapshots cannot establish absence. `--allow-partial` accepts partial output without making its zero authoritative; cancellation, timeout, cursor, and output errors retain their precedence. +Plain search propagates cancellation through database setup and query execution for +rows, counts, and aggregations, including serial and parallel batch children. +Cancelling the CLI returns exit `130` (`E012_INTERRUPTED`); `--strict-not-found` +and `--allow-partial` cannot turn that cancellation into a miss or success. Final and zero-result pages do not advertise a cursor. If a partial stream cannot make safe progress—for example, a byte cap leaves room only for the terminal record, the query uses row selectors or a recipe/named search, the @@ -4709,6 +4713,10 @@ command、query、filter、ordering、page limit を変えずに再利用して 検索元の分類が未完了の場合、古い可能性があるスナップショットでは不在を断定しません。 `--allow-partial` は部分結果を受け入れるだけで 0 件の確実性を高めず、 取消・タイムアウト・カーソル・出力エラーを優先します。 +通常検索は、行・件数・集計の各出力で、DB の準備から検索の実行まで取消を伝播します。 +直列・並列バッチ内の検索も対象です。CLI の取消は終了コード `130` +(`E012_INTERRUPTED`)を返し、`--strict-not-found` や `--allow-partial` を指定しても +未検出や成功には変換しません。 最終 page と 0 件 page は cursor を公開しません。byte cap により terminal record しか出力できない場合、row selector または recipe / named search を使う場合、 10,000 row の pagination window を使い切ったか、同じ page limit での再開時にその上限を diff --git a/changelog.d/unreleased/5421.fixed.md b/changelog.d/unreleased/5421.fixed.md new file mode 100644 index 0000000000..18b03be450 --- /dev/null +++ b/changelog.d/unreleased/5421.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 5421 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs +--- + +## English + +- **Plain search now honors caller cancellation (#5421)** — cancellation reaches database setup and running SQLite queries for rows, counts and aggregations, including batch execution. Cancellation remains an interruption instead of becoming a strict zero-result exit or an accepted partial success. + +## 日本語 + +- **通常検索が呼出元の取消に従うよう修正 (#5421)** — バッチ内の検索を含め、行・件数・集計の DB 準備と実行中の SQLite 検索に取消を伝播します。取消を strict のゼロ件終了や許容された部分成功へ変換せず、中断として扱います。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs index 22388cad9b..fbb58187e8 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs @@ -12,7 +12,8 @@ private sealed record SearchExecutionPlan( JsonSerializerOptions NdjsonOptions, bool ExactSearch, string Query, - SearchQueryHint? ExactSubstringHint); + SearchQueryHint? ExactSubstringHint, + CancellationToken CancellationToken); private sealed record SearchRowExecution( FtsQueryDiagnostics FtsQueryDiagnostics, @@ -31,7 +32,8 @@ private static SearchExecutionPlan CreateSearchExecutionPlan( QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool exactSearch, - string query) + string query, + CancellationToken cancellationToken) { var exactSubstringHint = SearchQueryAdvisor.BuildExactSubstringHint( query, @@ -47,17 +49,24 @@ private static SearchExecutionPlan CreateSearchExecutionPlan( ndjsonOptions, exactSearch, query, - exactSubstringHint); + exactSubstringHint, + cancellationToken); } private static int ExecutePlainSearch(SearchExecutionPlan plan) { + plan.CancellationToken.ThrowIfCancellationRequested(); var outcome = new SearchExecutionOutcome(); return WithDb( plan.Options, plan.JsonOptions, - reader => ExecutePlainSearch(reader, plan, outcome), - _ => WritePlainSearchTerminal(plan, outcome)); + reader => + { + using var cancellationScope = reader.BeginCancellationScope(plan.CancellationToken); + return reader.RunWithCancellationInterrupt(() => ExecutePlainSearch(reader, plan, outcome)); + }, + _ => WritePlainSearchTerminal(plan, outcome), + cancellationToken: plan.CancellationToken); } private static int ExecutePlainSearch( @@ -88,6 +97,7 @@ private static int ExecutePlainSearch( return WritePlainSearchCount(reader, plan); var rows = PreparePlainSearchRows(reader, plan); + plan.CancellationToken.ThrowIfCancellationRequested(); var exitCode = rows.DisplayRows.Count == 0 ? WriteEmptyPlainSearchResults(reader, plan, rows, outcome) : WritePlainSearchResults(reader, plan, rows, outcome); @@ -101,6 +111,7 @@ private static int WritePlainSearchCount(DbReader reader, SearchExecutionPlan pl var options = plan.Options; var originCoverage = new SearchCountOriginCoverage(options); var counts = CountSearchMatches(reader, options, plan.ExactSearch, originCoverage); + plan.CancellationToken.ThrowIfCancellationRequested(); var queryDiagnostics = DbReader.AnalyzeFtsQuery( plan.Query, options.RawFts, diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs index 957a41e89a..c20a5965bf 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs @@ -115,7 +115,8 @@ private static int ExecuteSearchRoute(SearchRoutePlan route) route.Options, route.JsonOptions, route.ExactSearch, - route.Options.Query!)), + route.Options.Query!, + route.CancellationToken)), _ => throw new InvalidOperationException("Unknown search execution route."), }; } diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs new file mode 100644 index 0000000000..cdc7d3b0eb --- /dev/null +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs @@ -0,0 +1,148 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using CodeIndex.Cli; +using CodeIndex.Database; +using Microsoft.Data.Sqlite; +using static CodeIndex.Tests.QueryCommandTestSupport; + +namespace CodeIndex.Tests; + +[Collection("Console sensitive")] +public class QueryCommandRunnerSearchCancellationTests +{ + private static readonly string[][] OutputModes = + [ + [], ["--json"], ["--count"], ["--count", "--json"], ["--format", "count"], + ["--format", "grouped"], ["--group-by", "file", "--count"], ["--count-by", "path"], + ]; + + [Fact] + public void PlainSearch_PreCancelledTokenPrecedesResults_Issue5421() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_search_cancel_5421"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Widget.cs", "csharp", "public class Widget { }\n"); + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + foreach (var query in new[] { "NoMatch5421", "Widget" }) + foreach (var mode in OutputModes) + foreach (var allowPartial in new[] { false, true }) + { + string[] args = [query, "--strict-not-found", "--db", dbPath, .. mode, + .. allowPartial ? new[] { "--allow-partial" } : Array.Empty()]; + var (_, stdout, stderr) = CaptureConsole(() => + { + var exception = Assert.ThrowsAny(() => + QueryCommandRunner.RunSearch(args, JsonOptions, cancellation.Token)); + Assert.Equal(cancellation.Token, exception.CancellationToken); + return 0; + }); + Assert.Empty(stdout); + Assert.Empty(stderr); + } + } + + [Fact] + public void PlainSearch_CancellationInsideSqlitePropagatesAcrossEntryPoints_Issue5421() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_search_sql_cancel_5421"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Widget.cs", "csharp", "public class Widget { }\n"); + var previousOpen = DbConnectionFactory.OpenReadOnlyForTesting; + try + { + foreach (var entry in new[] { "direct", "cli", "batch-1", "batch-2" }) + foreach (var exactMiss in new[] { false, true }) + foreach (var mode in new string[][] { [], ["--json"], ["--count"], ["--count", "--json"] }) + foreach (var allowPartial in new[] { false, true }) + { + using var cancellation = new CancellationTokenSource(); + var callbacks = 0; + DbConnectionFactory.OpenReadOnlyForTesting = path => + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = path, Mode = SqliteOpenMode.ReadOnly, Pooling = false, + }.ToString()); + connection.Open(); + // Cancel inside the real search statement, after DB setup. Returning normally + // requires the production interrupt registration to stop the SQLite VM. + connection.CreateFunction("instr", (text, query) => + { + if (exactMiss && query == "NoMatch5421") + { + Interlocked.Increment(ref callbacks); + cancellation.Cancel(); + } + return text.IndexOf(query, StringComparison.Ordinal) + 1; + }); + connection.CreateFunction("like", (pattern, pathValue, escape) => + { + if (!exactMiss && pattern == "src/%.cs") + { + Interlocked.Increment(ref callbacks); + cancellation.Cancel(); + } + return pathValue == "src/Widget.cs" ? 1 : 0; + }); + return connection; + }; + string[] args = [exactMiss ? "NoMatch5421" : "Widget", "--path", "src/*.cs", + "--strict-not-found", .. mode, + .. exactMiss ? new[] { "--exact" } : Array.Empty(), + .. allowPartial ? new[] { "--allow-partial" } : Array.Empty()]; + (int Exit, string Stdout, string Stderr) result; + Exception? cancellationError = null; + if (entry.StartsWith("batch-", StringComparison.Ordinal)) + { + result = CaptureConsoleWithInput(JsonSerializer.Serialize(new[] { "search" }.Concat(args)) + "\n", + () => QueryCommandRunner.RunBatch(["--db", dbPath, "--json-summary", "--parallel", entry[6..]], + JsonOptions, cancellationToken: cancellation.Token)); + } + else + { + result = CaptureConsole(() => + { + if (entry == "cli") + return ProgramRunner.Run(["search", .. args, "--db", dbPath], JsonOptions, + configStartDirectory: project.Root, cancellationToken: cancellation.Token); + cancellationError = Record.Exception(() => + QueryCommandRunner.RunSearch([.. args, "--db", dbPath], JsonOptions, cancellation.Token)); + return CommandExitCodes.CancelledBySignal; + }); + } + Assert.True(callbacks > 0, $"Search statement was not reached: {entry}, {string.Join(' ', args)}"); + Assert.Equal(CommandExitCodes.CancelledBySignal, result.Exit); + if (entry == "direct") + { + Assert.True(cancellationError is OperationCanceledException, + $"Expected cancellation, got {cancellationError}; stdout={result.Stdout}; stderr={result.Stderr}"); + var exception = (OperationCanceledException)cancellationError!; + Assert.Equal(cancellation.Token, exception.CancellationToken); + Assert.Equal(9, Assert.IsType(exception.InnerException).SqliteErrorCode); + Assert.Empty(result.Stdout); + Assert.Empty(result.Stderr); + } + else if (entry.StartsWith("batch-", StringComparison.Ordinal)) + { + Assert.Empty(result.Stderr); + var records = result.Stdout.Split('\n', StringSplitOptions.RemoveEmptyEntries) + .Select(line => JsonNode.Parse(line)!).ToArray(); + Assert.Equal(2, records.Length); + Assert.Equal(CommandErrorCodes.Interrupted, records[0]["error"]!["error_code"]!.GetValue()); + Assert.Null(records[0]["result"]); + Assert.Null(records[0]["results"]); + Assert.Equal(CommandExitCodes.CancelledBySignal, records[1]["exit_code"]!.GetValue()); + } + } + } + finally + { + DbConnectionFactory.OpenReadOnlyForTesting = previousOpen; + } + var (retryExit, retryOutput, _) = CaptureConsole(() => + QueryCommandRunner.RunSearch(["Widget", "--count", "--db", dbPath], JsonOptions)); + Assert.Equal(CommandExitCodes.Success, retryExit); + Assert.Equal("1", retryOutput.Trim()); + } +} From 6ea878587a3a220224f6c24e25ccdb8171d6ec8e Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 23 Sep 2026 08:21:59 +0900 Subject: [PATCH 2/3] Honor scoped cancellation during search classification and aggregation (#5421) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 11 ++- changelog.d/unreleased/5421.fixed.md | 8 +- .../Cli/QueryCommandRunner.SearchResults.cs | 95 +++++++++++++++---- .../Database/DbSearchReader.PythonOrigins.cs | 2 +- .../Database/DbSearchReader.SymbolGuards.cs | 2 +- src/CodeIndex/Database/DbSearchReader.cs | 2 +- ...eryCommandRunnerSearchCancellationTests.cs | 88 +++++++++++++++++ 8 files changed, 181 insertions(+), 31 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index bd62d1631c..767ed0587e 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -836,7 +836,7 @@ Candidate-ordered parallel-index recovery tests must prove that the fatal result YAML `outline` integration coverage must assert nested sequence paths, mapping-parent depth, unchanged source ranges and symbol counts, deterministic sorting, and cursor replay across a page boundary. Search-core orchestration coverage must keep validation ahead of database execution, preserve list-recipes > named-query > recipe > plain route precedence and the recipe-field-before-conflict / plain-conflict-before-field aggregation error order, keep count-mode zero/nonzero payloads aligned, retain strict-not-found exit semantics, and order profiled NDJSON as result records, profile metadata, then the terminal record. Plain search strict-count coverage (#5406) extends the shared #5393 direct/batch fixture and `OriginContinuationIssue5348Tests`. On net8/net9, preserve zero/nonzero numeric and JSON counts, `--format count`, serial/parallel batch classification, snapshot guards, and byte-budget precedence. Filtered unknown origins must remain non-authoritative even with `--allow-partial`; completed classification may establish a strict zero. Keep grouped/named/recipe and MCP count regressions as controls. - `QueryCommandRunnerSearchCancellationTests` (#5421) covers pre-cancelled plain rows/counts/aggregations and cancellation inside real SQLite search statements through direct, CLI and serial/parallel batch entry points. Keep FTS hits, exact misses, numeric/JSON output, strict/allow-partial precedence, native interruption and successful retry on net8/net9. The console-sensitive fixture restores its async-local connection factory; connection-local SQL callbacks trigger cancellation without sleeps or global pool resets. + `QueryCommandRunnerSearchCancellationTests` (#5421) covers pre-cancelled plain rows/counts/aggregations and cancellation inside real SQLite search statements through direct, CLI and serial/parallel batch entry points. Keep FTS hits, exact misses, numeric/JSON output, strict/allow-partial precedence, native interruption and successful retry on net8/net9. Also verify that C#/Python managed origin classification retains the effective scoped token, reader reuse restores its lifetime token, and cancellation at the first/last prepared aggregation group emits no success. Direct CLI cancellation writes a human error; batch children report `E012_INTERRUPTED`. The console-sensitive fixture restores its async-local connection factory and aggregation seam; callbacks trigger cancellation without sleeps or global pool resets. CLI parsing, command execution, and installer behavior. Index command coverage is split by run mode or feature area, and query command coverage is split by command family with partial test classes so shared console and fixture helpers stay centralized. Keep repeated query-result fixtures, such as overlapping chunk content used by multiple search deduplication tests, in narrow class-level helpers instead of duplicating local builders. `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. Installer bundle-generation tests must also verify that `install.sh` is marked generated while every canonical `install_modules/` source remains unmarked. Suggestions usage-hint coverage for #5196 must keep unknown-option human diagnostics and missing-id / invalid-combination JSON errors aligned on `cdidx suggestions --help`, preserve the existing error-code, category, usage, and exit-code contracts, and assert that the literal `` placeholder is never emitted. `CommandErrorWriterTests.cs` must keep catalog-backed known-command hints and the global fallback for missing, unknown, nested, or control-bearing command identities. Explicit `--files` preflight coverage for #5091 / #5122 keeps real/dry-run and JSON/text failure behavior in one focused contract matrix: bare `--files`, absolute and relative outside-root paths, platform-supported symlink escapes, `none`-mode directory-segment rejection, `internal`/`all` acceptance, nonexistent, directory, filtered, and unsupported targets, canonical duplicates (including target-specific casing and native Unicode/alias spelling), mixed valid/invalid atomicity, raw-token provenance beside `--project` expansion, already-indexed cleanup and existing or deleted reconciliation-control exceptions, generated-code acceptance, unreadable membership snapshots, and the implicit empty full-scan control without `--files`. Include Windows 8.3 ancestor aliases that must map back to established database spelling. Indexed controls replaced by a directory, FIFO, or policy-disallowed symlink must remain cleanup tombstones without opening or following the replacement. TypeScript path-alias configuration tests must cover `none`, inside/outside-root `internal`, and `all`, prove that only regular resolved targets are secure-opened, and keep real/dry-run plus in-process/worker paths aligned. Assert `UsageError`, bounded and redacted per-input reason diagnostics, and unchanged indexed rows and metadata whenever any explicit token is rejected; snapshot-read failures fail closed with a database error. @@ -2383,7 +2383,7 @@ results-only・array・envelope・compact の形状、終端と改行の厳密 YAML `outline` の integration coverage では、入れ子 sequence path、mapping parent の depth、不変な source range と symbol count、決定的な sort、page 境界を越える cursor replay を検証してください。 search core の orchestration coverage では、validation が database execution より先に行われること、list-recipes > named-query > recipe > plain の route precedence と recipe では field error が conflict より先・plain では conflict が field error より先になる aggregation error 順、count mode の zero/nonzero payload、strict-not-found の exit semantics、profile 付き NDJSON が result record、profile metadata、terminal record の順になることを維持してください。 通常検索の strict 件数テスト (#5406) は、#5393 の直接実行/batch 共有 fixture と `OriginContinuationIssue5348Tests` を拡張します。net8/net9 でゼロ件/非ゼロ件の数値・JSON、`--format count`、直列/並列 batch の分類、スナップショットの保護、応答サイズエラーの優先順位を維持してください。フィルターで除外した分類不明の候補は `--allow-partial` 指定時も不在の根拠にせず、分類完了時だけ strict の確定したゼロ件として扱います。grouped/named/recipe と MCP の既存件数テストも対照として実行します。 - `QueryCommandRunnerSearchCancellationTests` (#5421) は、通常検索の行・件数・集計の事前取消と、直接実行・CLI・直列/並列バッチでの SQLite 検索文の実行中の取消を検証します。FTS の一致、完全一致検索の未検出、数値/JSON 出力、strict/allow-partial より取消が優先すること、SQLite の中断と再実行の成功を net8/net9 で維持してください。console-sensitive なフィクスチャは非同期コンテキスト単位の接続ファクトリを復元し、接続単位の SQL コールバックで取消を発生させます。待ち時間や全体の接続プール初期化には依存しません。 + `QueryCommandRunnerSearchCancellationTests` (#5421) は、通常検索の行・件数・集計の事前取消と、直接実行・CLI・直列/並列バッチでの SQLite 検索文の実行中の取消を検証します。FTS の一致、完全一致検索の未検出、数値/JSON 出力、strict/allow-partial より取消が優先すること、SQLite の中断と再実行の成功を net8/net9 で維持してください。C#/Python の管理コードによる検索元分類がスコープの有効なトークンを保持すること、reader の再利用時に元の寿命トークンを復元すること、最初/最後の集計グループ準備時の取消で成功出力を行わないことも検証します。直接 CLI は人間向けエラーを出し、バッチ内では `E012_INTERRUPTED` を報告します。console-sensitive なフィクスチャは非同期コンテキスト単位の接続ファクトリと集計用テストフックを復元し、待ち時間や全体の接続プール初期化に依存せずコールバックで取消を発生させます。 CLI の引数解析、コマンド実行、installer 挙動のテスト。Index command coverage は run mode または機能領域ごとの partial suite に分割し、Query command coverage は command family ごとの partial test class に分割して、共有 console / fixture helper は一箇所に保ちます。`ProgramCliTests.cs` はグローバル引数の解釈や完全な CLI 起動フローのように subprocess 経由で確認すべき Program エントリポイント挙動を扱い、`InstallScriptTests.cs` は `install.sh` を library mode で source した bash snippet を実行して、実ネットワーク install を行わずに release installer の回帰を固定する。installer bundle 生成テストでは、`install.sh` が generated と判定される一方、canonical な `install_modules/` source はすべて unmarked のままであることも検証してください。 #5196 の suggestions usage-hint coverage では、unknown option の human 診断と missing id / invalid combination の JSON error がともに `cdidx suggestions --help` を案内し、既存の error code、category、usage、exit code 契約を維持し、literal `` placeholder を出力しないことを固定してください。`CommandErrorWriterTests.cs` では catalog に基づく既知 command の hint と、command identity が欠落・未知・nested・control character を含む場合の global fallback を維持してください。 #5091 / #5122 の明示的な `--files` preflight coverage では、通常実行 / dry-run と JSON / text の failure behavior を 1 つの focused contract matrix に保ちます。path token のない `--files`、absolute / relative の project root 外 path、対応 platform での symlink escape、`none` mode での directory segment symlink の拒否、`internal` / `all` mode での受理、存在しない target、directory、filter 対象、未対応 target、canonical duplicate(target ごとの casing と native Unicode/alias spelling を含む)、valid / invalid 混在時の atomicity、`--project` 展開と並ぶ raw token provenance、既に index 済みの cleanup、既存または削除済みの reconciliation control の例外、generated code の受理、membership snapshot の読取不能、`--files` のない暗黙の空 full scan control を検証してください。Windows の 8.3 ancestor alias は DB に確立済みの spelling へ戻ることを検証してください。indexed control が directory、FIFO、または policy で禁止された symlink に置換された場合は、置換 object を open / follow せず cleanup tombstone を維持する必要があります。TypeScript path alias configuration test は `none`、root 内外の `internal`、`all` を網羅し、解決後の regular target だけが secure-open されることを証明し、通常実行 / dry-run と in-process / worker path を一致させてください。明示 token が 1 件でも拒否された場合は、`UsageError`、上限付き・伏字化済みの入力別 reason diagnostic、indexed row と metadata の不変性を assertion し、snapshot 読取失敗は database error で fail closed することを確認します。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index e2043dad67..32f791f038 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -443,8 +443,9 @@ snapshots cannot establish absence. cancellation, timeout, cursor, and output errors retain their precedence. Plain search propagates cancellation through database setup and query execution for rows, counts, and aggregations, including serial and parallel batch children. -Cancelling the CLI returns exit `130` (`E012_INTERRUPTED`); `--strict-not-found` -and `--allow-partial` cannot turn that cancellation into a miss or success. +Cancelling the CLI returns exit `130`; cancelled batch children also report +`E012_INTERRUPTED`. `--strict-not-found` and `--allow-partial` cannot turn that +cancellation into a miss or success. Final and zero-result pages do not advertise a cursor. If a partial stream cannot make safe progress—for example, a byte cap leaves room only for the terminal record, the query uses row selectors or a recipe/named search, the @@ -4714,9 +4715,9 @@ command、query、filter、ordering、page limit を変えずに再利用して `--allow-partial` は部分結果を受け入れるだけで 0 件の確実性を高めず、 取消・タイムアウト・カーソル・出力エラーを優先します。 通常検索は、行・件数・集計の各出力で、DB の準備から検索の実行まで取消を伝播します。 -直列・並列バッチ内の検索も対象です。CLI の取消は終了コード `130` -(`E012_INTERRUPTED`)を返し、`--strict-not-found` や `--allow-partial` を指定しても -未検出や成功には変換しません。 +直列・並列バッチ内の検索も対象です。CLI の取消は終了コード `130` を返し、 +バッチ内の取消では `E012_INTERRUPTED` も報告します。`--strict-not-found` や +`--allow-partial` を指定しても、取消を未検出や成功には変換しません。 最終 page と 0 件 page は cursor を公開しません。byte cap により terminal record しか出力できない場合、row selector または recipe / named search を使う場合、 10,000 row の pagination window を使い切ったか、同じ page limit での再開時にその上限を diff --git a/changelog.d/unreleased/5421.fixed.md b/changelog.d/unreleased/5421.fixed.md index 18b03be450..4fecdd8e71 100644 --- a/changelog.d/unreleased/5421.fixed.md +++ b/changelog.d/unreleased/5421.fixed.md @@ -5,12 +5,16 @@ issues: affected: - src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs - src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs + - src/CodeIndex/Database/DbSearchReader.cs + - src/CodeIndex/Database/DbSearchReader.PythonOrigins.cs + - src/CodeIndex/Database/DbSearchReader.SymbolGuards.cs --- ## English -- **Plain search now honors caller cancellation (#5421)** — cancellation reaches database setup and running SQLite queries for rows, counts and aggregations, including batch execution. Cancellation remains an interruption instead of becoming a strict zero-result exit or an accepted partial success. +- **Plain search now honors caller cancellation (#5421)** — cancellation reaches database setup, running SQLite queries, managed origin classification and aggregation for rows and counts, including batch execution. Cancellation remains an interruption instead of becoming a strict zero-result exit or an accepted partial success. ## 日本語 -- **通常検索が呼出元の取消に従うよう修正 (#5421)** — バッチ内の検索を含め、行・件数・集計の DB 準備と実行中の SQLite 検索に取消を伝播します。取消を strict のゼロ件終了や許容された部分成功へ変換せず、中断として扱います。 +- **通常検索が呼出元の取消に従うよう修正 (#5421)** — バッチ内の検索を含め、行・件数の DB 準備、実行中の SQLite 検索、管理コード側の検索元分類と集計に取消を伝播します。取消を strict のゼロ件終了や許容された部分成功へ変換せず、中断として扱います。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index 33bf4173cd..ed1cd4b3a2 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -10,6 +10,22 @@ namespace CodeIndex.Cli; public static partial class QueryCommandRunner { + private static readonly AsyncLocal ScopedSearchAggregationGroupPreparedForTesting = new(); + + internal static Action? SearchAggregationGroupPreparedForTesting + { + get => ScopedSearchAggregationGroupPreparedForTesting.Value; + set => ScopedSearchAggregationGroupPreparedForTesting.Value = value; + } + + private static SearchGroupedCountItemJsonResult CheckSearchAggregationGroup( + SearchGroupedCountItemJsonResult group, CancellationToken cancellationToken) + { + SearchAggregationGroupPreparedForTesting?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); + return group; + } + private static int RunGroupedSearchCount(DbReader reader, QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool exact, SearchQueryHint? exactSubstringHint) { if (options.GroupBy == "file" && !HasSearchOriginFilters(options)) @@ -27,9 +43,11 @@ private static int RunGroupedSearchCount(DbReader reader, QueryCommandOptions op null, null, null)) + .Select(group => CheckSearchAggregationGroup(group, reader.Cancellation)) .ToList(); var normalizedGroupBy = NormalizeSearchAggregationKey(options.GroupBy!); var fileGroupSelection = ApplySearchGroupOutputSelection(fileCountGroups, options); + reader.ThrowIfCancellationRequested(); if (options.Json) { @@ -46,6 +64,7 @@ private static int RunGroupedSearchCount(DbReader reader, QueryCommandOptions op options.Limit, fileGroupSelection.Groups), CliJsonSerializerContextFactory.Create(jsonOptions).SearchGroupedCountJsonResult); + reader.ThrowIfCancellationRequested(); return WriteJsonObjectWithOptionalByteLimit( json, options, @@ -64,11 +83,12 @@ private static int RunGroupedSearchCount(DbReader reader, QueryCommandOptions op var results = reader.Search(options.Query!, int.MaxValue, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow, guardScope: options.GuardScope, tokenBoundary: options.TokenBoundary); var originCoverage = new SearchCountOriginCoverage(options); - var displayRows = BuildSearchDisplayRows(results, options, exact, countOriginCoverage: originCoverage); + var displayRows = BuildSearchDisplayRows(results, options, exact, countOriginCoverage: originCoverage, cancellationToken: reader.Cancellation); var groupBy = NormalizeSearchAggregationKey(options.GroupBy!); - var groups = BuildSearchGroupedCounts(groupBy, displayRows); + var groups = BuildSearchGroupedCounts(groupBy, displayRows, reader.Cancellation); var fallbackGroupSelection = ApplySearchGroupOutputSelection(groups, options); var fileCount = displayRows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); + reader.ThrowIfCancellationRequested(); if (options.Json) { @@ -85,8 +105,10 @@ private static int RunGroupedSearchCount(DbReader reader, QueryCommandOptions op options.Limit, fallbackGroupSelection.Groups), CliJsonSerializerContextFactory.Create(jsonOptions).SearchGroupedCountJsonResult); + json = originCoverage.EnrichJson(json, jsonOptions); + reader.ThrowIfCancellationRequested(); return originCoverage.ExitCode(WriteJsonObjectWithOptionalByteLimit( - originCoverage.EnrichJson(json, jsonOptions), + json, options, "grouped search count", "Reduce --limit or increase --max-json-bytes.", @@ -102,9 +124,12 @@ private static int RunGroupedSearchCount(DbReader reader, QueryCommandOptions op return originCoverage.ExitCode(); } - private static List BuildSearchGroupedCounts(string groupBy, List rows) - => groupBy == "file" - ? rows + private static List BuildSearchGroupedCounts( + string groupBy, List rows, CancellationToken cancellationToken = default) + { + var checkedRows = EnumerateSearchRowsWithCancellation(rows, cancellationToken); + var groups = groupBy == "file" + ? checkedRows .GroupBy(row => row.Result.Path, StringComparer.Ordinal) .Select(group => new SearchGroupedCountItemJsonResult( group.Key, @@ -120,7 +145,7 @@ private static List BuildSearchGroupedCounts(s .ThenBy(group => group.Key, StringComparer.Ordinal) .ToList() : groupBy == "origin" - ? rows + ? checkedRows .SelectMany(row => row.Compact.MatchOrigins.Count == 0 ? [SearchMatchClassifier.Unknown] : row.Compact.MatchOrigins) @@ -139,7 +164,7 @@ private static List BuildSearchGroupedCounts(s .ThenBy(group => group.Key, StringComparer.Ordinal) .ToList() : groupBy == "return_type" - ? rows + ? checkedRows .GroupBy(row => NormalizeSearchReturnTypeGroupKey(row.Result.EnclosingSymbolReturnType), StringComparer.Ordinal) .Select(group => new SearchGroupedCountItemJsonResult( group.Key, @@ -155,7 +180,7 @@ private static List BuildSearchGroupedCounts(s .ThenBy(group => group.Key, StringComparer.Ordinal) .ToList() : groupBy == "subsystem" - ? rows + ? checkedRows .GroupBy(row => BuildSearchSubsystemGroupKey(row.Result.Path), StringComparer.Ordinal) .Select(group => new SearchGroupedCountItemJsonResult( group.Key, @@ -171,7 +196,7 @@ private static List BuildSearchGroupedCounts(s .OrderByDescending(group => group.Count) .ThenBy(group => group.Key, StringComparer.Ordinal) .ToList() - : rows + : checkedRows .GroupBy(row => BuildSearchSymbolGroupKey(row.Result), StringComparer.Ordinal) .Select(group => { @@ -192,6 +217,23 @@ private static List BuildSearchGroupedCounts(s .ThenBy(group => group.Key, StringComparer.Ordinal) .ToList(); + foreach (var group in groups) + CheckSearchAggregationGroup(group, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + return groups; + } + + private static IEnumerable EnumerateSearchRowsWithCancellation( + IEnumerable rows, CancellationToken cancellationToken) + { + foreach (var row in rows) + { + cancellationToken.ThrowIfCancellationRequested(); + yield return row; + } + cancellationToken.ThrowIfCancellationRequested(); + } + private const string NoSearchReturnTypeGroupKey = ""; private const string NoSearchSubsystemGroupKey = ""; @@ -307,12 +349,13 @@ private static int RunSearchAggregation(DbReader reader, QueryCommandOptions opt { var results = reader.Search(options.Query!, int.MaxValue, options.Lang, options.RawFts, options.PathPatterns, options.ExcludePaths, options.ExcludeTests, !options.NoDedup, options.Since, exact, options.Prefix, !options.NoVisibilityRank, guardFilters: options.GuardFilters, guardWindow: options.GuardWindow, guardScope: options.GuardScope, tokenBoundary: options.TokenBoundary); var originCoverage = new SearchCountOriginCoverage(options); - var rows = BuildSearchDisplayRows(results, options, exact, countOriginCoverage: originCoverage); + var rows = BuildSearchDisplayRows(results, options, exact, countOriginCoverage: originCoverage, cancellationToken: reader.Cancellation); var groupBy = NormalizeSearchAggregationKey(options.CountBy ?? options.UniqueBy!); - var groups = BuildSearchGroupedCounts(groupBy, rows); + var groups = BuildSearchGroupedCounts(groupBy, rows, reader.Cancellation); var selection = ApplySearchGroupOutputSelection(groups, options); var uniqueOnly = options.UniqueBy != null; var fileCount = rows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); + reader.ThrowIfCancellationRequested(); if (options.Json) { @@ -331,8 +374,10 @@ private static int RunSearchAggregation(DbReader reader, QueryCommandOptions opt options.Limit, selection.Groups), CliJsonSerializerContextFactory.Create(jsonOptions).SearchAggregationJsonResult); + json = originCoverage.EnrichJson(json, jsonOptions); + reader.ThrowIfCancellationRequested(); return originCoverage.ExitCode(WriteJsonObjectWithOptionalByteLimit( - originCoverage.EnrichJson(json, jsonOptions), + json, options, "search aggregation", "Reduce --limit or increase --max-json-bytes.", @@ -343,7 +388,10 @@ private static int RunSearchAggregation(DbReader reader, QueryCommandOptions opt if (uniqueOnly) { foreach (var group in selection.Groups) + { + reader.ThrowIfCancellationRequested(); Console.WriteLine(group.Key); + } var truncation = selection.Truncated ? $"showing {selection.Groups.Count} of {selection.TotalGroups}" : selection.Groups.Count.ToString(CultureInfo.InvariantCulture); @@ -581,7 +629,8 @@ private static AdHocSearchSarifSourceResultCount CountAdHocSearchSarifSourceResu var rows = BuildSearchDisplayRows( ReadSearchResults(reader, options, exact, int.MaxValue), options, - exact); + exact, + cancellationToken: reader.Cancellation); return CountAdHocSearchSarifResultUnits(rows, options.Query!, exact, authoritative: true); } @@ -1151,8 +1200,10 @@ private static List BuildSearchDisplayRows( bool? rawFtsOverride = null, SearchAuditRecipeQuery? recipeQuery = null, Action? originCoverageObserver = null, - SearchCountOriginCoverage? countOriginCoverage = null) + SearchCountOriginCoverage? countOriginCoverage = null, + CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var rows = new List(results.Count); var seenMatchLocations = options.NoDedup ? null : new HashSet(StringComparer.Ordinal); var displayQuery = queryOverride ?? options.Query!; @@ -1164,6 +1215,7 @@ private static List BuildSearchDisplayRows( : SearchSnippetFormatter.PrepareQueryContext(displayQuery); foreach (var result in results) { + cancellationToken.ThrowIfCancellationRequested(); var compact = SearchSnippetFormatter.ToCompactResult( result, queryContext, @@ -1217,6 +1269,7 @@ private static List BuildSearchDisplayRows( var keptLines = new List(compact.MatchLines.Count); foreach (var line in compact.MatchLines) { + cancellationToken.ThrowIfCancellationRequested(); var key = result.Path + "\0" + line.ToString(CultureInfo.InvariantCulture); if (seenMatchLocations.Add(key)) keptLines.Add(line); @@ -1232,6 +1285,7 @@ private static List BuildSearchDisplayRows( rows.Add(new SearchDisplayRow(result, compact)); } + cancellationToken.ThrowIfCancellationRequested(); return rows; } @@ -1335,7 +1389,8 @@ private static List ReadSearchDisplayRows( SearchOriginFilterMaxCandidates, guardRequestedLimit: options.Limit), options, - exact); + exact, + cancellationToken: reader.Cancellation); } else { @@ -1386,7 +1441,8 @@ private static List ReadSearchDisplayRows( rows = BuildSearchDisplayRows( ReadSearchResults(reader, options, exact, requestedThroughOffset), options, - exact); + exact, + cancellationToken: reader.Cancellation); } else { @@ -1463,7 +1519,8 @@ private static List ReadOriginFilteredSearchDisplayRows( candidates.AddRange(page); displayRows = BuildSearchDisplayRows(candidates, options, exact, - recipeQuery: recipeQuery, originCoverageObserver: originCoverageObserver, countOriginCoverage: countOriginCoverage); + recipeQuery: recipeQuery, originCoverageObserver: originCoverageObserver, countOriginCoverage: countOriginCoverage, + cancellationToken: reader.Cancellation); var last = page[^1]; if (last.NextOffset <= currentOffset) @@ -1508,7 +1565,7 @@ private static List ReadSearchResults(DbReader reader, QueryComman private static QueryCountResult CountFilteredSearchResults(DbReader reader, QueryCommandOptions options, bool exact, SearchCountOriginCoverage? originCoverage) { var results = ReadSearchResults(reader, options, exact, int.MaxValue); - var rows = BuildSearchDisplayRows(results, options, exact, countOriginCoverage: originCoverage); + var rows = BuildSearchDisplayRows(results, options, exact, countOriginCoverage: originCoverage, cancellationToken: reader.Cancellation); if (options.GuardFilters.Count > 0 && !options.TokenBoundary) return CountFilteredSearchResultUnits(rows); diff --git a/src/CodeIndex/Database/DbSearchReader.PythonOrigins.cs b/src/CodeIndex/Database/DbSearchReader.PythonOrigins.cs index 34253ef264..a89508a467 100644 --- a/src/CodeIndex/Database/DbSearchReader.PythonOrigins.cs +++ b/src/CodeIndex/Database/DbSearchReader.PythonOrigins.cs @@ -9,7 +9,7 @@ private void AttachPythonOriginLines(List results, CancellationTok .GroupBy(r => r.Path, StringComparer.Ordinal)) { var generation = ReadOriginGeneration(); - var token = cancellationToken.CanBeCanceled ? cancellationToken : _cancellation; + var token = cancellationToken.CanBeCanceled ? cancellationToken : Cancellation; var retainedLines = new Dictionary(); var conflictingContext = false; var origins = new SearchMatchClassifier.PythonOriginContext(start => diff --git a/src/CodeIndex/Database/DbSearchReader.SymbolGuards.cs b/src/CodeIndex/Database/DbSearchReader.SymbolGuards.cs index 289092d7d7..95864bafcc 100644 --- a/src/CodeIndex/Database/DbSearchReader.SymbolGuards.cs +++ b/src/CodeIndex/Database/DbSearchReader.SymbolGuards.cs @@ -68,7 +68,7 @@ private void ValidateSameSymbolGuardSource(string path) var checksum = cmd.ExecuteScalar() as string; try { - var current = new FileContentLoader(4 * 1024 * 1024).Load(absolute, path, path, _cancellation).Checksum; + var current = new FileContentLoader(4 * 1024 * 1024).Load(absolute, path, path, Cancellation).Checksum; if (string.IsNullOrEmpty(checksum) || !string.Equals(checksum, current, StringComparison.OrdinalIgnoreCase)) throw SameSymbolGuardUnavailable("source_stale_missing_or_over_budget"); } diff --git a/src/CodeIndex/Database/DbSearchReader.cs b/src/CodeIndex/Database/DbSearchReader.cs index cb2408b30e..e216bc175c 100644 --- a/src/CodeIndex/Database/DbSearchReader.cs +++ b/src/CodeIndex/Database/DbSearchReader.cs @@ -396,7 +396,7 @@ private void AttachCSharpOriginLines(List results, CancellationTok .GroupBy(r => r.Path, StringComparer.Ordinal)) { var generation = ReadOriginGeneration(); - var token = cancellationToken.CanBeCanceled ? cancellationToken : _cancellation; + var token = cancellationToken.CanBeCanceled ? cancellationToken : Cancellation; var retainedLines = new Dictionary(); var conflictingContext = false; var origins = new SearchMatchClassifier.CSharpOriginContext(group.Key, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs index cdc7d3b0eb..22f1c4dacb 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs @@ -16,6 +16,88 @@ public class QueryCommandRunnerSearchCancellationTests ["--format", "grouped"], ["--group-by", "file", "--count"], ["--count-by", "path"], ]; + [Fact] + public void PlainSearch_ManagedOriginsRetainScopedCancellationAndReaderRestoresLifetime_Issue5421() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_search_origin_cancel_5421"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "Widget.cs", "csharp", "class Widget { }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "Widget.py", "python", "class Widget:\n pass\n"); + using var db = new DbContext(DbOpenIntent.QueryOnly, dbPath); + using var lifetime = new CancellationTokenSource(); + using var reader = new DbReader(db, lifetime.Token); + foreach (var language in new[] { "csharp", "python" }) + { + using var cancellation = new CancellationTokenSource(); + using (reader.BeginCancellationScope(cancellation.Token)) + { + var row = Assert.Single(reader.Search("Widget", lang: language)); + Action classify = () => + { + if (language == "csharp") + _ = row.CSharpOrigins!.GetOrigin(1, "class Widget { }", 6); + else + _ = row.PythonOrigins!.GetOrigin(1, "class Widget:", 6); + }; + classify(); + cancellation.Cancel(); + var error = Assert.ThrowsAny(classify); + Assert.Equal(cancellation.Token, error.CancellationToken); + } + Assert.Equal(lifetime.Token, reader.Cancellation); + Assert.Single(reader.Search("Widget", lang: language)); + } + lifetime.Cancel(); + Assert.ThrowsAny(reader.ThrowIfCancellationRequested); + } + + [Fact] + public void PlainSearch_ManagedAggregationCancellationEmitsNoSuccess_Issue5421() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_search_group_cancel_5421"); + var dbPath = TestProjectHelper.CreateProjectDb(project.Root); + TestProjectHelper.InsertIndexedFile(dbPath, "First.cs", "csharp", "class Widget { }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "Second.cs", "csharp", "namespace Other { class Widget { } }\n"); + var previousHook = QueryCommandRunner.SearchAggregationGroupPreparedForTesting; + try + { + foreach (var mode in new string[][] + { + ["--group-by", "file", "--count"], ["--group-by", "symbol", "--count"], + ["--count-by", "path"], ["--unique", "path"], + }) + foreach (var json in new[] { false, true }) + foreach (var allowPartial in new[] { false, true }) + foreach (var cancelAt in new[] { 1, 2 }) + { + using var cancellation = new CancellationTokenSource(); + var groupsPrepared = 0; + QueryCommandRunner.SearchAggregationGroupPreparedForTesting = () => + { + if (++groupsPrepared == cancelAt) + cancellation.Cancel(); + }; + var (_, stdout, stderr) = CaptureConsole(() => + { + var error = Assert.ThrowsAny(() => QueryCommandRunner.RunSearch( + ["Widget", "--db", dbPath, "--strict-not-found", .. mode, + .. json ? new[] { "--json" } : Array.Empty(), + .. allowPartial ? new[] { "--allow-partial" } : Array.Empty()], + JsonOptions, cancellation.Token)); + Assert.Equal(cancellation.Token, error.CancellationToken); + return 0; + }); + Assert.Equal(cancelAt, groupsPrepared); + Assert.Empty(stdout); + Assert.Empty(stderr); + } + } + finally + { + QueryCommandRunner.SearchAggregationGroupPreparedForTesting = previousHook; + } + } + [Fact] public void PlainSearch_PreCancelledTokenPrecedesResults_Issue5421() { @@ -123,6 +205,12 @@ public void PlainSearch_CancellationInsideSqlitePropagatesAcrossEntryPoints_Issu Assert.Empty(result.Stdout); Assert.Empty(result.Stderr); } + else if (entry == "cli") + { + Assert.Empty(result.Stdout); + Assert.Contains("Error: command cancelled before it could complete.", result.Stderr); + Assert.DoesNotContain(CommandErrorCodes.Interrupted, result.Stderr); + } else if (entry.StartsWith("batch-", StringComparison.Ordinal)) { Assert.Empty(result.Stderr); From f70ae283a2fa2453f0023a3152c6ad31f475b3eb Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 23 Sep 2026 09:12:52 +0900 Subject: [PATCH 3/3] Fix cancellation test formatting for CI (#5421) --- .../QueryCommandRunnerSearchCancellationTests.cs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs index 22f1c4dacb..efec59b858 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs @@ -144,7 +144,9 @@ public void PlainSearch_CancellationInsideSqlitePropagatesAcrossEntryPoints_Issu { var connection = new SqliteConnection(new SqliteConnectionStringBuilder { - DataSource = path, Mode = SqliteOpenMode.ReadOnly, Pooling = false, + DataSource = path, + Mode = SqliteOpenMode.ReadOnly, + Pooling = false, }.ToString()); connection.Open(); // Cancel inside the real search statement, after DB setup. Returning normally