From 6d2d1db5bcd6afea5a2240cb396b4a33fe407ca3 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 23 Sep 2026 10:28:35 +0900 Subject: [PATCH 1/2] Fix cancellation in named-query and recipe search routes (#5427) --- TESTING_GUIDE.md | 2 + USER_GUIDE.md | 12 +- changelog.d/unreleased/5427.fixed.md | 19 ++ .../Cli/QueryCommandRunner.SearchExecution.cs | 27 +- ...ueryCommandRunner.SearchRecipeExecution.cs | 15 +- .../Cli/QueryCommandRunner.SearchRecipes.cs | 40 ++- .../Cli/QueryCommandRunner.SearchResults.cs | 22 +- .../Cli/QueryCommandRunner.SearchRouting.cs | 16 +- ...eryCommandRunnerSearchCancellationTests.cs | 231 +++++++++++++++++- 9 files changed, 346 insertions(+), 38 deletions(-) create mode 100644 changelog.d/unreleased/5427.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index d2cd7e6ebf..b31ce0fe51 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -843,6 +843,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. + The same cancellation fixture covers named-query and selected recipe routes (#5427): rows, counts, compact/summary/NDJSON, aggregations and plain/recipe issue drafts. Keep successful controls, pre-cancellation, cancellation inside SQLite and at the first/last managed row or group, CLI/batch interruption classification, and nested cancelled queries followed by successful retries on one inherited batch connection. Restore every async-local seam; do not use sleeps to trigger cancellation or classify it as an invalid child query. `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. @@ -2397,6 +2398,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 の既存件数テストも対照として実行します。 + 同じ取消フィクスチャで、名前付き検索と選択したレシピの各経路 (#5427) を検証します。行・件数・compact/summary/NDJSON・集計・通常/レシピの Issue draft を対象に、正常実行、事前取消、SQLite 内および管理コードの最初/最後の行・集計グループでの取消、CLI/バッチの中断分類を維持してください。継承した1つのバッチ接続で入れ子の検索を取り消した後、再実行が成功することも確認します。非同期コンテキスト単位のテストフックをすべて復元し、待ち時間に依存した取消や無効な子クエリへの分類を避けてください。 `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 を維持してください。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 32f791f038..4fa75c2013 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -441,8 +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. +Plain, named-query and recipe search propagate cancellation through database setup, +SQLite queries and managed result processing for rows, counts and aggregations, +including issue-draft exports and serial/parallel batch children. Cancellation stops +the whole invocation; it is never recorded as an invalid child query or a zero count. 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. @@ -4714,8 +4716,10 @@ command、query、filter、ordering、page limit を変えずに再利用して 検索元の分類が未完了の場合、古い可能性があるスナップショットでは不在を断定しません。 `--allow-partial` は部分結果を受け入れるだけで 0 件の確実性を高めず、 取消・タイムアウト・カーソル・出力エラーを優先します。 -通常検索は、行・件数・集計の各出力で、DB の準備から検索の実行まで取消を伝播します。 -直列・並列バッチ内の検索も対象です。CLI の取消は終了コード `130` を返し、 +通常検索・名前付き検索・レシピ検索は、行・件数・集計の各出力で、DB の準備、SQLite 検索、 +管理コードによる結果処理まで取消を伝播します。Issue draft の出力と直列・並列バッチ内の +検索も対象です。取消は呼出し全体を中断し、無効な子クエリやゼロ件として記録しません。 +CLI の取消は終了コード `130` を返し、 バッチ内の取消では `E012_INTERRUPTED` も報告します。`--strict-not-found` や `--allow-partial` を指定しても、取消を未検出や成功には変換しません。 最終 page と 0 件 page は cursor を公開しません。byte cap により terminal diff --git a/changelog.d/unreleased/5427.fixed.md b/changelog.d/unreleased/5427.fixed.md new file mode 100644 index 0000000000..57d4e33d9f --- /dev/null +++ b/changelog.d/unreleased/5427.fixed.md @@ -0,0 +1,19 @@ +--- +category: fixed +issues: + - 5427 +affected: + - src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs + - src/CodeIndex/Cli/QueryCommandRunner.SearchRecipeExecution.cs +--- + +## English + +- **Named-query and recipe search honor caller cancellation (#5427)** — propagate cancellation through database setup, SQLite execution and managed row/count/aggregation processing, including issue-draft exports and serial/parallel batch execution. Cancelled child queries interrupt the invocation instead of producing a degraded success or zero count, and reused readers restore their previous cancellation scope. + +## 日本語 + +- **名前付き検索とレシピ検索で呼出元の取消を反映 (#5427)** — Issue draft の出力と直列・並列バッチを含め、DB の準備、SQLite 実行、管理コードでの行・件数・集計処理に取消を伝播します。子クエリの取消は不完全な成功やゼロ件に変換せず呼出し全体を中断し、再利用する reader の取消スコープを元に戻します。 diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs index fbb58187e8..321c4b3314 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchExecution.cs @@ -55,18 +55,33 @@ private static SearchExecutionPlan CreateSearchExecutionPlan( private static int ExecutePlainSearch(SearchExecutionPlan plan) { - plan.CancellationToken.ThrowIfCancellationRequested(); var outcome = new SearchExecutionOutcome(); - return WithDb( + return WithSearchDb( plan.Options, plan.JsonOptions, + plan.CancellationToken, + reader => ExecutePlainSearch(reader, plan, outcome), + _ => WritePlainSearchTerminal(plan, outcome)); + } + + private static int WithSearchDb( + QueryCommandOptions options, + JsonSerializerOptions jsonOptions, + CancellationToken cancellationToken, + Func action, + Action? afterProfile = null) + { + cancellationToken.ThrowIfCancellationRequested(); + return WithDb( + options, + jsonOptions, reader => { - using var cancellationScope = reader.BeginCancellationScope(plan.CancellationToken); - return reader.RunWithCancellationInterrupt(() => ExecutePlainSearch(reader, plan, outcome)); + using var cancellationScope = reader.BeginCancellationScope(cancellationToken); + return reader.RunWithCancellationInterrupt(() => action(reader)); }, - _ => WritePlainSearchTerminal(plan, outcome), - cancellationToken: plan.CancellationToken); + afterProfile, + cancellationToken: cancellationToken); } private static int ExecutePlainSearch( diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipeExecution.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipeExecution.cs index f87bf83518..c7a9259a6c 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipeExecution.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipeExecution.cs @@ -41,6 +41,7 @@ private readonly record struct SearchRecipeQueryMaterializationResult( private static SearchRecipeQueryMaterializationResult MaterializeSearchRecipeQuery( in SearchRecipeQueryMaterializationRequest request) { + request.Reader.ThrowIfCancellationRequested(); EnsureSearchRecipeCoverage(request.Reader, request.Scope, request.Options); var queryScope = BuildSearchRecipeQueryScope(request.Scope, request.RecipeQuery); var guardFilters = BuildSearchRecipeGuardFilters(request.Options, request.RecipeQuery); @@ -92,7 +93,8 @@ private static SearchRecipeQueryMaterializationResult MaterializeSearchRecipeQue request.RecipeQuery.Query, rawFtsOverride: request.RawFtsOverride, recipeQuery: request.RecipeQuery, - originCoverageObserver: complete => originCoverageComplete &= complete); + originCoverageObserver: complete => originCoverageComplete &= complete, + cancellationToken: request.Reader.Cancellation); if (!originCoverageComplete) coverageRestriction = "origin_classification_incomplete"; var sourceTotalAuthoritative = request.ResultLimit.HasValue && coverageRestriction == null; rows = ApplySearchRecipeSemanticFilter( @@ -101,6 +103,7 @@ private static SearchRecipeQueryMaterializationResult MaterializeSearchRecipeQue request.RecipeQuery, rows); ApplyXmlSettingsAuditClassifications(request.Reader, request.RecipeQuery, rows); + request.Reader.ThrowIfCancellationRequested(); MarkSearchRecipeQueryExecuted(request.Scope, request.RecipeQuery.Name); return new SearchRecipeQueryMaterializationResult(rows, sourceTotalAuthoritative, candidateWindowExhausted, coverageRestriction); } @@ -130,6 +133,7 @@ private static List CollectSearchRecipeQueryR hasFailures = false; foreach (var recipeQuery in recipeQueries) { + reader.ThrowIfCancellationRequested(); try { var exact = ResolveSearchRecipeMatchMode(recipeQuery, options).Exact; @@ -260,6 +264,7 @@ private static List CollectSearchRecipeQueryR } } + reader.ThrowIfCancellationRequested(); return queryResults; } @@ -280,6 +285,7 @@ private static List CollectSearchRecip hasFailures = false; foreach (var recipeQuery in recipeQueries) { + reader.ThrowIfCancellationRequested(); try { var exact = ResolveSearchRecipeMatchMode(recipeQuery, options).Exact; @@ -372,6 +378,7 @@ private static List CollectSearchRecip } } + reader.ThrowIfCancellationRequested(); return queryResults; } @@ -416,6 +423,7 @@ private static List CountSearchRecipeQueryResu hasFailures = false; foreach (var recipeQuery in recipeQueries) { + reader.ThrowIfCancellationRequested(); try { var exact = ResolveSearchRecipeMatchMode(recipeQuery, options).Exact; @@ -472,6 +480,7 @@ private static List CountSearchRecipeQueryResu } fileCount = paths.Count; + reader.ThrowIfCancellationRequested(); return queryCounts; } @@ -490,6 +499,7 @@ private static List CollectSearchRecipeA total = 0; foreach (var recipeQuery in recipeQueries) { + reader.ThrowIfCancellationRequested(); var exact = ResolveSearchRecipeMatchMode(recipeQuery, options).Exact; var materializationRequest = new SearchRecipeQueryMaterializationRequest( reader, @@ -504,7 +514,7 @@ private static List CollectSearchRecipeA foreach (var path in rows.Select(row => row.Result.Path)) paths.Add(path); - var groups = BuildSearchGroupedCounts(groupBy, rows); + var groups = BuildSearchGroupedCounts(groupBy, rows, reader.Cancellation); var selection = ApplySearchGroupOutputSelection(groups, options); total += rows.Count; queryResults.Add(new SearchRecipeAggregationQueryJsonResult( @@ -522,6 +532,7 @@ private static List CollectSearchRecipeA } fileCount = paths.Count; + reader.ThrowIfCancellationRequested(); return queryResults; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs index 38e78b76b2..d57c48f08e 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRecipes.cs @@ -751,7 +751,7 @@ private sealed record SearchRecipeSelection( SearchAuditRecipe Recipe, List Queries); - private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact, CancellationToken cancellationToken) { if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) { @@ -773,7 +773,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp } string? ndjsonTerminalLine = null; - return WithDb(options, jsonOptions, reader => + return WithSearchDb(options, jsonOptions, cancellationToken, reader => { EnsureSearchRecipeCoverage(reader, scope, options); if (options.SearchCursor is { } cursor @@ -837,6 +837,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp compactFreshnessContext, compactFreshnessObservations); var compactJson = compactPayload.ToJsonString(GetJsonNodeSerializationOptions(jsonOptions)); + reader.ThrowIfCancellationRequested(); return CompleteSearchRecipeOutput( WriteJsonObjectWithOptionalByteLimit( compactJson, @@ -898,6 +899,7 @@ private static int RunSearchRecipe(QueryCommandOptions options, JsonSerializerOp freshnessObservations), queryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeRunJsonResult); + reader.ThrowIfCancellationRequested(); return CompleteSearchRecipeOutput( WriteJsonObjectWithOptionalByteLimit( json, @@ -1800,7 +1802,7 @@ private static void AddSearchRecipeCompactReplayOptions( } } - private static int RunSearchRecipeAggregation(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + private static int RunSearchRecipeAggregation(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact, CancellationToken cancellationToken) { if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) { @@ -1816,7 +1818,7 @@ private static int RunSearchRecipeAggregation(QueryCommandOptions options, JsonS var groupBy = NormalizeSearchAggregationKey(options.GroupBy ?? options.CountBy ?? options.UniqueBy!); var uniqueOnly = options.UniqueBy != null; var mode = uniqueOnly ? "unique" : options.GroupBy != null ? "group_by" : "count_by"; - return WithDb(options, jsonOptions, reader => + return WithSearchDb(options, jsonOptions, cancellationToken, reader => { var queryResults = CollectSearchRecipeAggregationResults( reader, @@ -1843,6 +1845,7 @@ private static int RunSearchRecipeAggregation(QueryCommandOptions options, JsonS fileCount, queryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeAggregationRunJsonResult); + reader.ThrowIfCancellationRequested(); return WriteJsonObjectWithOptionalByteLimit( json, options, @@ -1854,6 +1857,7 @@ private static int RunSearchRecipeAggregation(QueryCommandOptions options, JsonS { foreach (var query in queryResults) { + reader.ThrowIfCancellationRequested(); Console.WriteLine($"[{query.Name}] {query.Query}"); if (uniqueOnly) { @@ -2012,7 +2016,7 @@ private static int RunSearchRecipeIssueDrafts( options.InvocationContext.CommandName); var preflight = preflightResult.Preflight; - return WithDb(options, jsonOptions, reader => + return WithSearchDb(options, jsonOptions, cancellationToken, reader => { var freshnessContext = BuildSearchRecipeFreshnessContext( reader, @@ -2070,6 +2074,7 @@ private static int RunSearchRecipeIssueDrafts( drafts, BuildSearchIssueDraftSelectionAccounting(recipe.Name, queryResults)), CliJsonSerializerContextFactory.Create(jsonOptions).SearchIssueDraftExportJsonResult); + reader.ThrowIfCancellationRequested(); return CompleteSearchRecipeOutput( WriteJsonObjectWithOptionalByteLimit( json, @@ -2208,7 +2213,7 @@ private static int WriteSearchRecipeIssueDraftSummary( private static int GetJsonDocumentByteCount(string json) => Encoding.UTF8.GetByteCount(json) + Encoding.UTF8.GetByteCount(Environment.NewLine); - private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact, CancellationToken cancellationToken) { if (!TryResolveSearchRecipeSelection(options, out var selection, out var selectionError)) { @@ -2221,7 +2226,7 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali var recipe = selection.Recipe; var scope = BuildSearchRecipeScope(recipe, options, selection.Queries); - return WithDb(options, jsonOptions, reader => + return WithSearchDb(options, jsonOptions, cancellationToken, reader => { var freshnessContext = options.SummaryOnly ? BuildSearchRecipeFreshnessContext(reader, recipe, selection.Queries, options) @@ -2263,6 +2268,7 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali freshnessObservations), summaryQueries), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeCountSummaryRunJsonResult); + reader.ThrowIfCancellationRequested(); return CompleteSearchRecipeOutput( WriteJsonObjectWithOptionalByteLimit( summaryJson, @@ -2283,6 +2289,7 @@ private static int RunSearchRecipeCount(QueryCommandOptions options, JsonSeriali fileCount, queryCounts), CliJsonSerializerContextFactory.Create(jsonOptions).SearchRecipeCountRunJsonResult); + reader.ThrowIfCancellationRequested(); return WriteJsonObjectWithOptionalByteLimit( json, options, @@ -2321,7 +2328,7 @@ private static int RunSearchIssueDrafts( options.InvocationContext.CommandName); var preflight = preflightResult.Preflight; - return WithDb(options, jsonOptions, reader => + return WithSearchDb(options, jsonOptions, cancellationToken, reader => { var resultLimit = GetAdHocIssueDraftResultLimit(options); var sourceTotalCountAuthoritative = options.GuardFilters.Count == 0 @@ -2335,7 +2342,7 @@ private static int RunSearchIssueDrafts( exact, sourceFetchLimit, guardRequestedLimit: resultLimit); - var sourceRows = BuildSearchDisplayRows(results, options, exact); + var sourceRows = BuildSearchDisplayRows(results, options, exact, cancellationToken: reader.Cancellation); var outputSelection = ApplySearchOutputSelection( sourceRows, options, @@ -2416,6 +2423,7 @@ private static int RunSearchIssueDrafts( drafts, BuildSearchIssueDraftSelectionAccounting(null, [queryResult])), CliJsonSerializerContextFactory.Create(jsonOptions).SearchIssueDraftExportJsonResult); + reader.ThrowIfCancellationRequested(); return WriteJsonObjectWithOptionalByteLimit( json, options, @@ -2472,7 +2480,10 @@ private static void ApplySearchRecipeAuditClassifications( if (taskResultClassifier != null) { foreach (var row in rows) + { + reader.ThrowIfCancellationRequested(); AddSearchRecipeAuditClassification(row, TryClassifyTaskResultIntent(taskResultClassifier, row)); + } } var appliesJsonTrustBoundary = jsonTrustBoundaryClassifier != null @@ -2494,6 +2505,7 @@ private static void ApplySearchRecipeAuditClassifications( var parserGuardLexicalContextCache = new ParserGuardLexicalContextCache(); foreach (var fileRows in rows.GroupBy(row => row.Result.Path, StringComparer.Ordinal)) { + reader.ThrowIfCancellationRequested(); var groupedRows = fileRows.ToList(); var maximumJsonTrustRequiredLine = groupedRows .Select(row => appliesJsonTrustBoundary ? GetJsonTrustRequiredLine(row) : 0) @@ -2523,6 +2535,7 @@ private static void ApplySearchRecipeAuditClassifications( } foreach (var row in groupedRows) { + reader.ThrowIfCancellationRequested(); if (parserGuardClassifier != null) { AddSearchRecipeAuditClassification( @@ -5027,6 +5040,7 @@ private static List CollectSearchNamedBat total = 0; foreach (var namedQuery in options.NamedSearchQueries) { + reader.ThrowIfCancellationRequested(); var selectRows = HasSearchRowSelectors(options); // Use a fixed candidate population so output limits cannot change sampling. var candidateWindowExhausted = false; @@ -5050,7 +5064,8 @@ private static List CollectSearchNamedBat candidateWindowObserver: exhausted => candidateWindowExhausted = exhausted); var originCoverageComplete = true; var rows = BuildSearchDisplayRows(results, options, userExact, namedQuery.Query, - originCoverageObserver: complete => originCoverageComplete &= complete); + originCoverageObserver: complete => originCoverageComplete &= complete, + cancellationToken: reader.Cancellation); SearchOutputSelection? selection = null; bool truncated; if (selectRows) @@ -5088,6 +5103,7 @@ private static List CollectSearchNamedBat }); } + reader.ThrowIfCancellationRequested(); return queryResults; } @@ -5110,6 +5126,7 @@ private static List CountSearchName hasFailures = false; foreach (var namedQuery in options.NamedSearchQueries) { + reader.ThrowIfCancellationRequested(); try { var results = reader.Search( @@ -5131,7 +5148,7 @@ private static List CountSearchName tokenBoundary: options.TokenBoundary); var originCoverage = new SearchCountOriginCoverage(options); var rows = BuildSearchDisplayRows(results, options, userExact, namedQuery.Query, - countOriginCoverage: originCoverage); + countOriginCoverage: originCoverage, cancellationToken: reader.Cancellation); queryOriginCoverage.Add(namedQuery.Name, originCoverage); var count = rows.Count; var fileCountForQuery = rows.Select(row => row.Result.Path).Distinct(StringComparer.Ordinal).Count(); @@ -5176,6 +5193,7 @@ private static List CountSearchName } fileCount = paths.Count; + reader.ThrowIfCancellationRequested(); return queryCounts; } diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs index ed1cd4b3a2..d62c51d32b 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchResults.cs @@ -11,6 +11,13 @@ namespace CodeIndex.Cli; public static partial class QueryCommandRunner { private static readonly AsyncLocal ScopedSearchAggregationGroupPreparedForTesting = new(); + private static readonly AsyncLocal ScopedSearchDisplayRowPreparedForTesting = new(); + + internal static Action? SearchDisplayRowPreparedForTesting + { + get => ScopedSearchDisplayRowPreparedForTesting.Value; + set => ScopedSearchDisplayRowPreparedForTesting.Value = value; + } internal static Action? SearchAggregationGroupPreparedForTesting { @@ -999,9 +1006,9 @@ private static bool TryWriteEmptySearchJsonWithOptionalByteLimit(QueryCommandOpt return false; } - private static int RunSearchNamedBatchCount(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + private static int RunSearchNamedBatchCount(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact, CancellationToken cancellationToken) { - return WithDb(options, jsonOptions, reader => + return WithSearchDb(options, jsonOptions, cancellationToken, reader => { var freshnessContext = options.Json ? BuildNamedSearchFreshnessContext( @@ -1023,6 +1030,7 @@ private static int RunSearchNamedBatchCount(QueryCommandOptions options, JsonSer var originCoverage = new SearchCountOriginCoverage(options); foreach (var coverage in queryOriginCoverage.Values) originCoverage.Merge(coverage); + reader.ThrowIfCancellationRequested(); if (options.Json) { @@ -1044,6 +1052,7 @@ private static int RunSearchNamedBatchCount(QueryCommandOptions options, JsonSer if (hasFailures) payload["degraded"] = true; originCoverage.AddJsonFields(payload); + reader.ThrowIfCancellationRequested(); var writeExitCode = WriteJsonObjectWithOptionalByteLimit( payload.ToJsonString(jsonOptions), options, @@ -1070,11 +1079,12 @@ private static int RunSearchNamedBatchCount(QueryCommandOptions options, JsonSer }); } - private static int RunSearchNamedBatch(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact) + private static int RunSearchNamedBatch(QueryCommandOptions options, JsonSerializerOptions jsonOptions, bool userExact, CancellationToken cancellationToken) { - return WithDb(options, jsonOptions, reader => + return WithSearchDb(options, jsonOptions, cancellationToken, reader => { var queryResults = CollectSearchNamedBatchQueryResults(reader, options, userExact, out var total); + reader.ThrowIfCancellationRequested(); if (options.Json) { @@ -1089,6 +1099,7 @@ private static int RunSearchNamedBatch(QueryCommandOptions options, JsonSerializ total, queryResults), CliJsonSerializerContextFactory.Create(jsonOptions).SearchNamedBatchRunJsonResult); + reader.ThrowIfCancellationRequested(); return WriteJsonObjectWithOptionalByteLimit( json, options, @@ -1101,6 +1112,7 @@ private static int RunSearchNamedBatch(QueryCommandOptions options, JsonSerializ Console.WriteLine(); foreach (var queryResult in queryResults) { + reader.ThrowIfCancellationRequested(); Console.WriteLine($"[{queryResult.Name}] {queryResult.Query}"); Console.WriteLine($"results: {queryResult.Count}"); if (queryResult.SelectionAccounting is { } accounting) @@ -1283,6 +1295,8 @@ private static List BuildSearchDisplayRows( } rows.Add(new SearchDisplayRow(result, compact)); + SearchDisplayRowPreparedForTesting?.Invoke(); + cancellationToken.ThrowIfCancellationRequested(); } cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs b/src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs index c20a5965bf..aa32329389 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.SearchRouting.cs @@ -73,6 +73,7 @@ private static bool TryCreateSearchRoutePlan( private static int ExecuteSearchRoute(SearchRoutePlan route) { + route.CancellationToken.ThrowIfCancellationRequested(); using var exactLanguageScope = CodeIndex.Database.DbReader.BeginExactQueryLanguageScope(route.Options.Lang); return route.Execution switch { @@ -83,19 +84,23 @@ private static int ExecuteSearchRoute(SearchRoutePlan route) SearchExecutionKind.NamedBatchCount => RunSearchNamedBatchCount( route.Options, route.JsonOptions, - route.ExactSearch), + route.ExactSearch, + route.CancellationToken), SearchExecutionKind.NamedBatchRows => RunSearchNamedBatch( route.Options, route.JsonOptions, - route.ExactSearch), + route.ExactSearch, + route.CancellationToken), SearchExecutionKind.RecipeAggregation => RunSearchRecipeAggregation( route.Options, route.JsonOptions, - route.Exact), + route.Exact, + route.CancellationToken), SearchExecutionKind.RecipeCount => RunSearchRecipeCount( route.Options, route.JsonOptions, - route.Exact), + route.Exact, + route.CancellationToken), SearchExecutionKind.RecipeIssueDrafts => RunSearchRecipeIssueDrafts( route.Options, route.JsonOptions, @@ -104,7 +109,8 @@ private static int ExecuteSearchRoute(SearchRoutePlan route) SearchExecutionKind.RecipeRows => RunSearchRecipe( route.Options, route.JsonOptions, - route.Exact), + route.Exact, + route.CancellationToken), SearchExecutionKind.PlainIssueDrafts => RunSearchIssueDrafts( route.Options, route.JsonOptions, diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs index efec59b858..4c4b3ffbf5 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs @@ -16,6 +16,226 @@ public class QueryCommandRunnerSearchCancellationTests ["--format", "grouped"], ["--group-by", "file", "--count"], ["--count-by", "path"], ]; + private static readonly string[][] NamedRecipeRoutes = + [ + ["--named-query", "probe=Widget"], + ["--named-query", "probe=Widget", "--count"], + ["--named-query", "probe=Widget", "--format", "compact"], + ["--recipe", "risky-code/unbounded-json-parse"], + ["--recipe", "risky-code/unbounded-json-parse", "--count"], + ["--recipe", "risky-code/unbounded-json-parse", "--format", "count", "--summary-only"], + ["--recipe", "risky-code/unbounded-json-parse", "--format", "compact"], + ["--recipe", "risky-code/unbounded-json-parse", "--json=ndjson"], + ["--recipe", "risky-code/unbounded-json-parse", "--count-by", "path"], + ["--recipe", "risky-code/unbounded-json-parse", "--unique", "path"], + ["--recipe", "risky-code/unbounded-json-parse", "--group-by", "file", "--count"], + ["--recipe", "risky-code/unbounded-json-parse", "--format", "issue-drafts"], + ["Widget", "--format", "issue-drafts"], + ]; + + [Fact] + public void NamedRecipeSearch_PreCancelledAndManagedCancellationEmitNoSuccess_Issue5427() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_named_recipe_cancel_5427"); + var dbPath = CreateNamedRecipeCancellationDb(project.Root); + var previousHook = QueryCommandRunner.SearchDisplayRowPreparedForTesting; + try + { + foreach (var route in NamedRecipeRoutes) + foreach (var json in new[] { false, true }) + foreach (var allowPartial in new[] { false, true }) + { + string[] args = [.. route, "--db", dbPath, "--strict-not-found", + .. json && !route.Contains("issue-drafts") ? new[] { "--json" } : Array.Empty(), + .. allowPartial ? new[] { "--allow-partial" } : Array.Empty()]; + QueryCommandRunner.SearchDisplayRowPreparedForTesting = null; + var control = CaptureConsole(() => QueryCommandRunner.RunSearch(args, JsonOptions)); + Assert.True(control.Result == CommandExitCodes.Success, + $"Control failed: {string.Join(' ', args)}; {control.Stdout}; {control.Stderr}"); + Assert.NotEmpty(control.Stdout); + foreach (var cancelAt in new[] { 0, 1, 2 }) + { + using var cancellation = new CancellationTokenSource(); + var rowsPrepared = 0; + QueryCommandRunner.SearchDisplayRowPreparedForTesting = () => + { + if (++rowsPrepared == cancelAt) + cancellation.Cancel(); + }; + if (cancelAt == 0) + cancellation.Cancel(); + var (_, stdout, stderr) = CaptureConsole(() => + { + var error = Assert.ThrowsAny(() => + QueryCommandRunner.RunSearch(args, JsonOptions, cancellation.Token)); + Assert.Equal(cancellation.Token, error.CancellationToken); + return 0; + }); + Assert.Equal(cancelAt, rowsPrepared); + Assert.Empty(stdout); + Assert.Empty(stderr); + } + } + } + finally + { + QueryCommandRunner.SearchDisplayRowPreparedForTesting = previousHook; + } + } + + [Fact] + public void NamedRecipeSearch_SqliteCancellationRetainsEntryPointClassification_Issue5427() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_named_recipe_sql_cancel_5427"); + var dbPath = CreateNamedRecipeCancellationDb(project.Root); + var previousOpen = DbConnectionFactory.OpenReadOnlyForTesting; + try + { + foreach (var entry in new[] { "direct", "cli", "batch-1", "batch-2" }) + foreach (var route in NamedRecipeRoutes) + { + using var cancellation = new CancellationTokenSource(); + var callbacks = 0; + DbConnectionFactory.OpenReadOnlyForTesting = path => + { + var connection = OpenCancellationTestConnection(path); + connection.CreateFunction("like", (pattern, pathValue, escape) => + { + if (pattern == "src/%.cs") + { + Interlocked.Increment(ref callbacks); + cancellation.Cancel(); + } + return 1; + }); + return connection; + }; + string[] args = [.. route, "--path", "src/*.cs", "--strict-not-found", "--allow-partial", + .. route.Contains("issue-drafts") ? Array.Empty() : new[] { "--json" }]; + (int Exit, string Stdout, string Stderr) result; + Exception? error = 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); + error = Record.Exception(() => QueryCommandRunner.RunSearch( + [.. args, "--db", dbPath], JsonOptions, cancellation.Token)); + return CommandExitCodes.CancelledBySignal; + }); + } + Assert.True(callbacks > 0, + $"SQL not reached: {entry}, {string.Join(' ', route)}; exit={result.Exit}; stdout={result.Stdout}; stderr={result.Stderr}"); + Assert.Equal(CommandExitCodes.CancelledBySignal, result.Exit); + if (entry == "direct") + { + var cancellationError = Assert.IsAssignableFrom(error); + Assert.Equal(cancellation.Token, cancellationError.CancellationToken); + Assert.Equal(9, Assert.IsType(cancellationError.InnerException).SqliteErrorCode); + 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); + } + else + { + 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; + } + } + + [Fact] + public void NamedRecipeSearch_NestedCancellationRestoresReusedBatchReader_Issue5427() + { + using var project = TestProjectHelper.CreateTempProjectScope("cdidx_named_recipe_reuse_5427"); + var dbPath = CreateNamedRecipeCancellationDb(project.Root); + var previousOpen = DbConnectionFactory.OpenReadOnlyForTesting; + var previousHook = QueryCommandRunner.SearchDisplayRowPreparedForTesting; + try + { + var opens = 0; + DbConnectionFactory.OpenReadOnlyForTesting = path => + { + opens++; + return OpenCancellationTestConnection(path); + }; + var nestedCalls = 0; + QueryCommandRunner.SearchDisplayRowPreparedForTesting = () => + { + // Execute narrower child lifetimes inside the inherited batch reader, then + // retry with the outer lifetime. No extra connection may hide a leaked token. + foreach (var route in NamedRecipeRoutes) + { + using var child = new CancellationTokenSource(); + QueryCommandRunner.SearchDisplayRowPreparedForTesting = child.Cancel; + var error = Assert.ThrowsAny(() => + QueryCommandRunner.RunSearch(route, JsonOptions, child.Token)); + Assert.Equal(child.Token, error.CancellationToken); + QueryCommandRunner.SearchDisplayRowPreparedForTesting = null; + Assert.Equal(CommandExitCodes.Success, + QueryCommandRunner.RunSearch(route, JsonOptions)); + nestedCalls++; + } + }; + var input = JsonSerializer.Serialize(new[] { "search", "--named-query", "outer=Widget", "--count" }) + "\n"; + var result = CaptureConsoleWithInput(input, + () => QueryCommandRunner.RunBatch(["--db", dbPath], JsonOptions)); + Assert.Equal(CommandExitCodes.Success, result.Result); + Assert.Equal(NamedRecipeRoutes.Length, nestedCalls); + Assert.Equal(1, opens); + } + finally + { + QueryCommandRunner.SearchDisplayRowPreparedForTesting = previousHook; + DbConnectionFactory.OpenReadOnlyForTesting = previousOpen; + } + } + + private static string CreateNamedRecipeCancellationDb(string root) + { + var dbPath = TestProjectHelper.CreateProjectDb(root); + TestProjectHelper.InsertIndexedFile(dbPath, "src/First.cs", "csharp", + "public class Widget { void M() { JsonDocument.Parse(input); } }\n"); + TestProjectHelper.InsertIndexedFile(dbPath, "src/Second.cs", "csharp", + "namespace Other { public class Widget { void M() { JsonDocument.Parse(input); } } }\n"); + return dbPath; + } + + private static SqliteConnection OpenCancellationTestConnection(string path) + { + var connection = new SqliteConnection(new SqliteConnectionStringBuilder + { + DataSource = path, + Mode = SqliteOpenMode.ReadOnly, + Pooling = false, + }.ToString()); + connection.Open(); + return connection; + } + [Fact] public void PlainSearch_ManagedOriginsRetainScopedCancellationAndReaderRestoresLifetime_Issue5421() { @@ -52,16 +272,15 @@ public void PlainSearch_ManagedOriginsRetainScopedCancellationAndReaderRestoresL } [Fact] - public void PlainSearch_ManagedAggregationCancellationEmitsNoSuccess_Issue5421() + public void Search_ManagedAggregationCancellationEmitsNoSuccess_Issue5421_Issue5427() { 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 dbPath = CreateNamedRecipeCancellationDb(project.Root); var previousHook = QueryCommandRunner.SearchAggregationGroupPreparedForTesting; try { - foreach (var mode in new string[][] + foreach (var route in new string[][] { ["Widget"], ["--recipe", "risky-code/unbounded-json-parse"] }) + foreach (var mode in new string[][] { ["--group-by", "file", "--count"], ["--group-by", "symbol", "--count"], ["--count-by", "path"], ["--unique", "path"], @@ -80,7 +299,7 @@ public void PlainSearch_ManagedAggregationCancellationEmitsNoSuccess_Issue5421() var (_, stdout, stderr) = CaptureConsole(() => { var error = Assert.ThrowsAny(() => QueryCommandRunner.RunSearch( - ["Widget", "--db", dbPath, "--strict-not-found", .. mode, + [.. route, "--db", dbPath, "--strict-not-found", .. mode, .. json ? new[] { "--json" } : Array.Empty(), .. allowPartial ? new[] { "--allow-partial" } : Array.Empty()], JsonOptions, cancellation.Token)); From 8fad7a355819370963442dea766d4e4890aec243 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Wed, 23 Sep 2026 11:05:45 +0900 Subject: [PATCH 2/2] Fix cancellation test formatting for CI (#5427) --- ...eryCommandRunnerSearchCancellationTests.cs | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs index 4c4b3ffbf5..39fd0d81b3 100644 --- a/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs +++ b/tests/CodeIndex.Tests/QueryCommandRunnerSearchCancellationTests.cs @@ -285,31 +285,31 @@ public void Search_ManagedAggregationCancellationEmitsNoSuccess_Issue5421_Issue5 ["--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(() => + foreach (var json in new[] { false, true }) + foreach (var allowPartial in new[] { false, true }) + foreach (var cancelAt in new[] { 1, 2 }) { - var error = Assert.ThrowsAny(() => QueryCommandRunner.RunSearch( - [.. route, "--db", dbPath, "--strict-not-found", .. mode, + 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( + [.. route, "--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); - } + JsonOptions, cancellation.Token)); + Assert.Equal(cancellation.Token, error.CancellationToken); + return 0; + }); + Assert.Equal(cancelAt, groupsPrepared); + Assert.Empty(stdout); + Assert.Empty(stderr); + } } finally {