diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index b0cb99672..76342351b 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -629,7 +629,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding - `ReportCommandRunnerTests.cs` Report log-tail fixtures should use the local log-directory and log-file helpers instead of repeating `Path.Combine(workDir, "logs")`, `Directory.CreateDirectory`, and ad hoc `File.WriteAllText` setup. JSON summary coverage keeps `output_path` as the artifact basename with and without `--redact-paths`, while asserting that diagnostic paths and parent directories remain redacted. - Keep no-overwrite refusal and explicit replacement in one shared-fixture test. Inject both a staging failure and a post-replace parent-directory flush failure to prove an existing bundle survives every reported failure path. Provenance coverage should reuse one saved-event fixture for current, stale, cross-workspace, cross-database, and missing/unsafe-provenance variants; also pin default report DB precedence, option-before-project index parsing, and the query `--` literal sentinel. Assert both archive membership and manifest disposition/reason on `net8.0` and `net9.0`. + Keep no-overwrite refusal and explicit replacement in one shared-fixture test. Inject both a staging failure and a post-replace parent-directory flush failure to prove an existing bundle survives every reported failure path. Provenance coverage should reuse one saved-event fixture for current, stale, cross-workspace, cross-database, and missing/unsafe-provenance variants; also pin default report DB precedence, option-before-project index parsing, and the query `--` literal sentinel. Persisted-failure coverage must prove that a stack frame that cannot be represented canonically falls back to a valid bounded exception chain rather than producing an event that report rejects as invalid. Assert both archive membership and manifest disposition/reason on `net8.0` and `net9.0`. - `PostExtractionHookTests.cs` Post-extraction hook discovery, mutation, diagnostics, callback budgets, and collectible hook assembly cleanup. Heavy hook worker and collectible assembly-load integration tests use `ProductionRuntimeFactAttribute` and run only on the `net8.0` production target, while direct worker protocol and metadata tests remain cross-target. Timed-out and canceled callback tests use a hook delay shorter than their leak-observation window, not a full one-second absence check, so worker-kill regressions still write the completion marker before the assertion exits. Duplicate-hook isolation coverage copies the dedicated `CodeIndex.HookIsolationFixture` assembly twice, so only the two hooks under test launch callback workers; the healthy worker retains a bounded one-second startup-and-callback budget while the selected slow hook blocks for 30 seconds. These tests mutate hook-related environment variables and test-only callback budget state, so the class belongs to the `SQLite pool sensitive` non-parallel collection. Keep the timed-out hook delay well beyond the callback budget while still below its bounded leak-observation window, so a loaded runner cannot let the hook finish at the timeout boundary and worker-kill regressions remain observable. @@ -1578,7 +1578,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" - `ReportCommandRunnerTests.cs` report log-tail fixture では、`Path.Combine(workDir, "logs")`、`Directory.CreateDirectory`、ad hoc な `File.WriteAllText` setup を繰り返さず、ローカルの log directory / log file helper を使ってください。 JSON summary の coverage では、`--redact-paths` の有無にかかわらず `output_path` が artifact の basename を保持し、診断用 path と親 directory は伏字化されたままであることを確認します。 - no-overwrite の拒否と明示的な置換は shared fixture を使う1つの test にまとめます。staging failure と置換後の parent-directory flush failure の両方を注入し、報告されるすべての failure path で既存 bundle が残ることを証明してください。provenance coverage は current、stale、cross-workspace、cross-database、missing / unsafe provenance で1つの保存 event fixture を再利用し、report の既定 DB precedence、option-before-project の index parsing、query の `--` literal sentinel も固定します。`net8.0` / `net9.0` の両方で archive membership と manifest の disposition / reason を検証します。 + no-overwrite の拒否と明示的な置換は shared fixture を使う1つの test にまとめます。staging failure と置換後の parent-directory flush failure の両方を注入し、報告されるすべての failure path で既存 bundle が残ることを証明してください。provenance coverage は current、stale、cross-workspace、cross-database、missing / unsafe provenance で1つの保存 event fixture を再利用し、report の既定 DB precedence、option-before-project の index parsing、query の `--` literal sentinel も固定します。persisted-failure coverage では canonical に表現できない stack frame が、report で invalid として拒否される event ではなく、有効な上限付き exception chain へ fallback することを証明します。`net8.0` / `net9.0` の両方で archive membership と manifest の disposition / reason を検証します。 - `PostExtractionHookTests.cs` post-extraction hook の discovery、mutation、diagnostics、callback budget、collectible hook assembly cleanup のテスト。重い hook worker と collectible assembly-load の integration test は `ProductionRuntimeFactAttribute` を使って `net8.0` production target でのみ実行し、direct worker protocol と metadata test は cross-target のままにします。timeout / cancel された callback のテストは、hook delay を leak-observation window より短くし、1 秒丸ごとの absence check には戻しません。worker kill の回帰がある場合は assertion が終わる前に completion marker が書かれるようにします。duplicate-hook isolation coverage では専用の `CodeIndex.HookIsolationFixture` assembly を 2 つの名前で copy し、対象の 2 hook だけが callback worker を起動するようにします。正常な worker には bounded な 1 秒の startup-and-callback budget を残し、選択した slow hook は 30 秒 block させます。hook 関連の環境変数と test-only callback budget 状態を変更するため、このクラスは non-parallel な `SQLite pool sensitive` collection に入れます。 timeout 対象 hook の delay は callback budget より十分長く、かつ bounded な leak-observation window より短く保ってください。高負荷 runner で hook が timeout 境界上に完了する競合を避けながら、worker kill の回帰を観測可能にします。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 56951bd13..82a5c08a7 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -2021,7 +2021,7 @@ cdidx report --output report.tgz --json `cdidx report --output ` packages a redacted gzip-compressed tar archive you can attach to a GitHub issue. Use `.tgz` or `.tar.gz`; if the output path has a misleading extension such as `.json`, the command still writes the archive but warns on stderr and records the warning in JSON summary metadata. Existing output is refused by default. With explicit `--overwrite`, the complete replacement is staged and the previous bundle is retained as rollback evidence until publication is durable, so a reported collection, staging, replacement, or durability failure leaves the old destination in place. `--json` only changes the command summary written to stdout; it does not make the output artifact JSON. The bundle includes the cdidx version, .NET runtime, OS / process architecture, and a `schema.txt` with a capped SQLite table list plus bounded row counts (no table row contents). -When an unhandled command failure tells you to run `cdidx report`, cdidx first saves a bounded, redacted event. A report includes that event as `last-failure.json` only when its opaque workspace and database identities and binary version match the report context, its timestamp is no more than 24 hours old (with a five-minute future-clock allowance), and all provenance fields are valid. Stale, cross-workspace, cross-database, version-mismatched, and legacy records without provenance are excluded; `support-manifest.json.last_failure` records the `disposition` and bounded machine-readable `reason`. The event records the failure timestamp, binary version and sanitized path, command category, exit code, exception category/type, sanitized diagnostics, and an opaque source run ID; it never records literal command arguments or raw provenance paths. +When an unhandled command failure tells you to run `cdidx report`, cdidx first saves a bounded, redacted event. A report includes that event as `last-failure.json` only when its opaque workspace and database identities and binary version match the report context, its timestamp is no more than 24 hours old (with a five-minute future-clock allowance), and all provenance fields are valid. Stale, cross-workspace, cross-database, version-mismatched, and legacy records without provenance are excluded; `support-manifest.json.last_failure` records the `disposition` and bounded machine-readable `reason`. Events emitted by the current binary are validated before persistence; if a platform-specific stack frame cannot fit the canonical bounded representation, cdidx retains the sanitized exception chain without stack frames instead of saving an event that report would reject as invalid. The event records the failure timestamp, binary version and sanitized path, command category, exit code, exception category/type, sanitized diagnostics, and an opaque source run ID; it never records literal command arguments or raw provenance paths. The bundle also tails the recent cdidx lifecycle log (`stderr-yyyyMMdd.log`), with the database path, lifecycle-log source directory, `process_path=`, `base_dir=`, `cwd=`, `db=`, `path=`, and `args=` lines replaced by `[redacted]` so local filesystem paths and literal query strings never leave your machine. `support-manifest.json.bundle.members` lists every archive member exactly. `db_inspected` and `db_diagnostics_included` say whether the source database was read and diagnostics were emitted; `db_member_included` is `false` because the database file is not bundled. The legacy `db_included` field remains as a compatibility alias for `db_inspected` and must not be interpreted as archive membership. Tar entry modification times are fixed for reproducible archive metadata; the actual generation timestamp is recorded inside `metadata.json`, `env.txt`, and `support-manifest.json`. @@ -5368,7 +5368,7 @@ cdidx report --output report.tgz --json `cdidx report --output ` は GitHub Issue に添付できる匿名化済みの gzip 圧縮 tar archive を生成します。`.tgz` または `.tar.gz` を使ってください。出力先が `.json` のような誤解を招く拡張子でも archive は書き出されますが、stderr に warning が出力され、JSON summary metadata の `warnings` にも記録されます。既存の出力先は既定で拒否されます。明示的な `--overwrite` では完全な置換 bundle を staging し、公開の durability が確認できるまで旧 bundle を rollback evidence として保持するため、収集、staging、置換、durability の failure が報告された場合は旧 destination が残ります。`--json` は stdout に出す command summary だけを JSON にし、出力 artifact 自体を JSON にするものではありません。バンドルには cdidx のバージョン、.NET ランタイム、OS / プロセスアーキテクチャ、上限付きの SQLite テーブル一覧と bounded な行数を記録した `schema.txt`(table の行内容は含まれません)が入ります。 -想定外の command failure が `cdidx report` の実行を案内する場合、cdidx はその案内より先に上限付き・匿名化済みのイベントを保存します。report は、不透明な workspace / database identity と binary version が report context に一致し、timestamp が24時間以内(未来方向は clock skew として5分まで許容)で、すべての provenance field が有効な場合だけ、その event を `last-failure.json` として含めます。stale、cross-workspace、cross-database、version mismatch、provenance を持たない legacy record は除外され、`support-manifest.json.last_failure` の `disposition` と上限付きの machine-readable `reason` に結果が記録されます。この event には失敗時刻、binary version と匿名化済み path、command category、exit code、exception category / type、匿名化済み diagnostics、不透明な source run ID を記録し、具体的な command 引数や provenance の raw path は記録しません。 +想定外の command failure が `cdidx report` の実行を案内する場合、cdidx はその案内より先に上限付き・匿名化済みのイベントを保存します。report は、不透明な workspace / database identity と binary version が report context に一致し、timestamp が24時間以内(未来方向は clock skew として5分まで許容)で、すべての provenance field が有効な場合だけ、その event を `last-failure.json` として含めます。stale、cross-workspace、cross-database、version mismatch、provenance を持たない legacy record は除外され、`support-manifest.json.last_failure` の `disposition` と上限付きの machine-readable `reason` に結果が記録されます。現在の binary が生成した event は保存前に検証され、platform 固有の stack frame が上限付き canonical 表現に収まらない場合は、report が invalid として拒否する event を保存せず、stack frame を省いた匿名化済み exception chain を保持します。この event には失敗時刻、binary version と匿名化済み path、command category、exit code、exception category / type、匿名化済み diagnostics、不透明な source run ID を記録し、具体的な command 引数や provenance の raw path は記録しません。 さらに直近のライフサイクルログ(`stderr-yyyyMMdd.log`)の末尾も含まれますが、DB パス、ライフサイクルログの source directory、`process_path=`、`base_dir=`、`cwd=`、`db=`、`path=`、`args=` 行は `[redacted]` に置換されるため、ローカルファイルシステムのパスや具体的なクエリ文字列が端末から外に出ることはありません。`support-manifest.json.bundle.members` は archive member を漏れなく列挙します。`db_inspected` と `db_diagnostics_included` は source DB を読んで診断を出力したかを示し、DB file 自体は同梱しないため `db_member_included` は `false` です。legacy の `db_included` は `db_inspected` の互換 alias として残りますが、archive membership を意味しません。tar entry の modification time は再現性のある archive metadata にするため固定され、実際の生成時刻は `metadata.json`、`env.txt`、`support-manifest.json` に記録されます。 diff --git a/changelog.d/unreleased/5012.fixed.md b/changelog.d/unreleased/5012.fixed.md new file mode 100644 index 000000000..f16e9f2d3 --- /dev/null +++ b/changelog.d/unreleased/5012.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 5012 +affected: + - src/CodeIndex/Database/FtsBulkLoadTriggerGuard.cs + - src/CodeIndex/Cli/LastFailureEventStore.cs + - USER_GUIDE.md +--- + +## English + +- **Full indexing now preserves the first actionable failure when FTS trigger cleanup also fails (#5012)** — a retry from `FtsBulkLoadTriggerGuard.Dispose` no longer replaces an exception raised by the normal completion path, and newly persisted `last-failure.json` events fall back to a canonical bounded exception chain when a platform stack frame cannot be represented safely. + +## 日本語 + +- **FTS trigger の cleanup も失敗した場合に full index が最初の実行可能な failure を保持するようになりました (#5012)** — `FtsBulkLoadTriggerGuard.Dispose` の再試行が通常の completion path で発生した例外を上書きせず、platform 固有の stack frame を安全に表現できない場合も、新しく保存する `last-failure.json` event は canonical な上限付き exception chain へ fallback します。 diff --git a/src/CodeIndex/Cli/LastFailureEventStore.cs b/src/CodeIndex/Cli/LastFailureEventStore.cs index c5374d625..0e4c3a36c 100644 --- a/src/CodeIndex/Cli/LastFailureEventStore.cs +++ b/src/CodeIndex/Cli/LastFailureEventStore.cs @@ -49,6 +49,8 @@ internal static bool TryPersist( occurredAtUtc, runId ?? CreateRunId(), workspacePathForTesting ?? resolvedPaths.WorkspacePath); + var exceptionCategory = SanitizeField(DiagnosticRedactor.ClassifyException(exception)); + var exceptionType = SanitizeField(exception.GetType().FullName ?? exception.GetType().Name); var failure = new LastFailureEvent( SchemaVersion, occurredAtUtc.ToUniversalTime().ToString("O", CultureInfo.InvariantCulture), @@ -57,10 +59,10 @@ internal static bool TryPersist( DiagnosticSanitizer.ForPath(Environment.ProcessPath), ResolveCommandCategory(args), exitCode, - SanitizeField(DiagnosticRedactor.ClassifyException(exception)), - SanitizeField(exception.GetType().FullName ?? exception.GetType().Name), - SanitizeField(DiagnosticRedactor.ClassifyException(exception)), - SanitizeDiagnostics(GlobalToolLog.FormatExceptionChain(exception, includeStacks: true)), + exceptionCategory, + exceptionType, + exceptionCategory, + BuildPersistedDiagnostics(exception, exceptionCategory, exceptionType), PathsRedacted: true, LiteralArgumentsIncluded: false, WorkspaceId: provenance.WorkspaceId, @@ -416,6 +418,41 @@ private static string ResolveBinaryPath() private static string SanitizeField(string? value) => DiagnosticSanitizer.ForMessage(value, MaxFieldChars); + private static string BuildPersistedDiagnostics( + Exception exception, + string exceptionCategory, + string exceptionType) + { + var diagnostics = SanitizeDiagnostics( + GlobalToolLog.FormatExceptionChain(exception, includeStacks: true)); + if (TryNormalizeStoredDiagnostics( + diagnostics, + exceptionCategory, + exceptionType, + out var normalized)) + { + return normalized; + } + + // A platform or runtime may emit a stack frame that cannot survive the bounded canonical + // representation. Preserve the exception chain without stacks instead of persisting an + // event that report would immediately reject as invalid. + // platform / runtime 固有の stack frame が上限付き canonical 表現に収まらない場合は、 + // report が直後に invalid として拒否する event ではなく stack なしの例外 chain を保存する。 + var chainOnlyDiagnostics = SanitizeDiagnostics( + GlobalToolLog.FormatExceptionChain(exception, includeStacks: false)); + if (TryNormalizeStoredDiagnostics( + chainOnlyDiagnostics, + exceptionCategory, + exceptionType, + out normalized)) + { + return normalized; + } + + return $"exception[0] type={exceptionType} message=\"{exceptionCategory}\""; + } + private static bool TryNormalizeStoredCommandCategory(string category, out string normalized) { normalized = string.Empty; diff --git a/src/CodeIndex/Database/FtsBulkLoadTriggerGuard.cs b/src/CodeIndex/Database/FtsBulkLoadTriggerGuard.cs index a167163da..1c855cd05 100644 --- a/src/CodeIndex/Database/FtsBulkLoadTriggerGuard.cs +++ b/src/CodeIndex/Database/FtsBulkLoadTriggerGuard.cs @@ -7,6 +7,7 @@ internal sealed class FtsBulkLoadTriggerGuard : IDisposable private readonly Func? _shouldRebuildOnAbandon; private readonly long _durableCommitGenerationAtStart; + private bool _preserveCompleteFailure; private DbWriter? _writer; private FtsBulkLoadTriggerGuard(DbWriter writer, Func? shouldRebuildOnAbandon) @@ -133,6 +134,15 @@ public void Complete( _writer = null; throw; } + catch + { + // Dispose still retries recovery, but a second cleanup failure must not replace the + // actionable exception from the normal completion path. + // Dispose でも recovery は再試行するが、2 回目の cleanup failure で通常の + // completion path からの実行可能な例外を上書きしてはならない。 + _preserveCompleteFailure = true; + throw; + } } public void Dispose() @@ -176,7 +186,8 @@ public void Dispose() { // The original cleanup failure is the actionable error. } - throw; + if (!_preserveCompleteFailure) + throw; } finally { diff --git a/tests/CodeIndex.Tests/DatabaseTests.cs b/tests/CodeIndex.Tests/DatabaseTests.cs index d1bf1eabd..88afde0df 100644 --- a/tests/CodeIndex.Tests/DatabaseTests.cs +++ b/tests/CodeIndex.Tests/DatabaseTests.cs @@ -2508,35 +2508,62 @@ public void FtsBulkLoadTriggerGuard_DisposeCleanupFailureDowngradesMarkerAndReco } [Fact] - public void FtsBulkLoadTriggerGuard_CompleteFailureKeepsDisposeRecovery() + public void FtsBulkLoadTriggerGuard_CompleteFailureKeepsDisposeRecovery_Issue5012() { var fileId = UpsertTestFile("src/failed-complete-bulk-fts.cs", checksum: "failed-complete-bulk-fts"); + var previousHook = DbWriter.FtsMaintenanceBeforeExecuteForTesting; + var primaryException = new InvalidOperationException("simulated optimize precheck failure"); + var cleanupException = new InvalidOperationException("simulated dispose trigger restoration failure"); + var restoreCount = 0; var ftsMutated = false; - using (var guard = FtsBulkLoadTriggerGuard.Start(_writer, enabled: true, () => ftsMutated)) + try { - Assert.NotNull(guard); - _writer.InsertChunks( - [ - new ChunkRecord + DbWriter.FtsMaintenanceBeforeExecuteForTesting = phase => + { + previousHook?.Invoke(phase); + if (phase == DbWriter.FtsRestoreTriggersMaintenancePhase + && Interlocked.Increment(ref restoreCount) == 2) { - FileId = fileId, - ChunkIndex = 0, - StartLine = 1, - EndLine = 1, - Content = "failedcompletebulktoken", - }, - ]); - ftsMutated = true; + throw cleanupException; + } + }; - Assert.Throws(() => guard!.Complete( - rebuild: true, - beforeOptimize: () => throw new InvalidOperationException("simulated optimize precheck failure"))); - } + using (var guard = FtsBulkLoadTriggerGuard.Start(_writer, enabled: true, () => ftsMutated)) + { + Assert.NotNull(guard); + _writer.InsertChunks( + [ + new ChunkRecord + { + FileId = fileId, + ChunkIndex = 0, + StartLine = 1, + EndLine = 1, + Content = "failedcompletebulktoken", + }, + ]); + ftsMutated = true; - Assert.Equal(3L, CountFtsSyncTriggers()); - Assert.Null(ReadMeta(DbWriter.FtsBulkLoadInProgressMetaKey)); - Assert.Equal(1L, ExecuteScalarLong("SELECT COUNT(*) FROM fts_chunks WHERE fts_chunks MATCH 'failedcompletebulktoken'")); + var thrown = Assert.Throws(() => guard!.Complete( + rebuild: true, + beforeOptimize: () => throw primaryException)); + Assert.Same(primaryException, thrown); + } + + Assert.Equal(2, restoreCount); + Assert.Equal("true", ReadMeta(DbWriter.FtsBulkLoadInProgressMetaKey)); + Assert.Equal(3L, CountFtsSyncTriggers()); + Assert.Equal(1L, ExecuteScalarLong( + "SELECT COUNT(*) FROM fts_chunks WHERE fts_chunks MATCH 'failedcompletebulktoken'")); + + Assert.True(_writer.RecoverInterruptedFtsBulkLoadIfNeeded()); + Assert.Null(ReadMeta(DbWriter.FtsBulkLoadInProgressMetaKey)); + } + finally + { + DbWriter.FtsMaintenanceBeforeExecuteForTesting = previousHook; + } } [Fact] diff --git a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs index 1223cfd1c..2e11f4a2c 100644 --- a/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs +++ b/tests/CodeIndex.Tests/ReportCommandRunnerTests.cs @@ -647,6 +647,59 @@ public void PersistedIndexFailure_CarriesExplicitProjectWorkspaceWithExternalDat } } + [Fact] + public void PersistedFailure_WithUnrepresentableStackUsesCanonicalChain_Issue5012() + { + var workDir = CreateWorkDir(); + var projectPath = Path.Combine(workDir, "project"); + var dbPath = Path.Combine(projectPath, ".cdidx", "codeindex.db"); + var logDir = CreateLogDir(workDir); + var timestamp = new DateTimeOffset(2026, 8, 1, 12, 34, 56, TimeSpan.Zero); + const string version = "1.40.3-test"; + try + { + Directory.CreateDirectory(projectPath); + using var env = EnvironmentVariableScope.Capture("CDIDX_GLOBAL_TOOL_LOG_DIR"); + env.Set("CDIDX_GLOBAL_TOOL_LOG_DIR", logDir); + + Assert.True(LastFailureEventStore.TryPersist( + ["index", projectPath], + version, + CommandExitCodes.UnhandledException, + new UnrepresentableStackException(), + timestamp, + dbPathForTesting: dbPath, + workspacePathForTesting: projectPath)); + + var reportProvenance = LastFailureEventStore.CreateReportProvenance( + dbPath, + version, + timestamp + TimeSpan.FromMinutes(1), + LastFailureEventStore.CreateRunId(), + projectPath); + Assert.True(LastFailureEventStore.TryBuildReportPayload( + reportProvenance, + out var payload, + out var evidence)); + + using var failure = JsonDocument.Parse(payload); + var diagnostics = failure.RootElement.GetProperty("diagnostics").GetString(); + Assert.StartsWith("exception[0] type=", diagnostics, StringComparison.Ordinal); + Assert.DoesNotContain("stack:", diagnostics, StringComparison.Ordinal); + Assert.Equal("included", evidence.Disposition); + Assert.Equal("matched", evidence.Reason); + } + finally + { + TestProjectHelper.DeleteDirectory(workDir); + } + } + + private sealed class UnrepresentableStackException : InvalidOperationException + { + public override string? StackTrace => " native frame without managed method syntax"; + } + [Fact] public void CreateReportProvenance_FoldsCaseOnCaseInsensitiveFilesystem_Issue4828() {