From fd94d5dc72b10abf670fe356d6842ecaf30a07d7 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 17:02:53 +0900 Subject: [PATCH 1/4] Fix watch startup duplicate baseline scans (#4858) --- TESTING_GUIDE.md | 2 + USER_GUIDE.md | 8 +- changelog.d/unreleased/4858.fixed.md | 26 ++ src/CodeIndex/Cli/ConsoleUi.Help.cs | 2 +- src/CodeIndex/Cli/IndexCommandRunner.cs | 23 +- src/CodeIndex/Cli/IndexWatchJsonContracts.cs | 4 + .../Cli/IndexWatchRunner.Batching.cs | 36 +- .../Cli/IndexWatchRunner.Reporting.cs | 88 +++- .../Cli/IndexWatchRunner.Watchers.cs | 98 +++++ src/CodeIndex/Cli/IndexWatchRunner.cs | 254 ++++++++---- src/CodeIndex/Cli/JsonOutputContracts.cs | 2 + .../IndexWatchRunnerIssue4169Tests.cs | 8 + .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 376 +++++++++++++++++- 13 files changed, 811 insertions(+), 116 deletions(-) create mode 100644 changelog.d/unreleased/4858.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index d9a55d59c..ac37cbd19 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -385,6 +385,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keep scan, `status --check`, and watch on one `.cdidx` membership policy. Ignore/unignore changes and pattern-config add/edit/delete events must reach a debounced full scan, while pattern/plugin inputs and ordinary `.cdidx` sidecars remain outside indexed source rows. Pattern extension edits must purge the old extension and index the new one in the same process. The registry refresh test also removes a loaded workspace plugin and verifies that its collectible context is unloaded while a host-registered fallback survives. Test the classification boundary directly instead of waiting for platform-specific `FileSystemWatcher` delivery. - `IndexWatchRunnerTests.RunCore_StartupHandoff_ReconcilesMutationAndDrainsBeforeReady_Issue4594` mutates an already indexed symbol through the post-subscription startup hook, explicitly enqueues that event, and verifies both final database content and `rescanned` / `updated` ordering before `watching`. Keep the injected enqueue: relying only on platform watcher timing would make the handoff regression nondeterministic. +- The issue-4858 watch startup fixtures include a top-level command test that counts the real full-scan write phase, plus focused tests that inject `IWatchBackend` implementations and explicit baseline/recovery delegates. They require backend-start retry to finish before exactly one baseline, collapse repeated event-loss callbacks into one recovery generation, stop before the baseline when both starts fail, and cancel only from the ready callback. Keep the machine-readable `backend` / `recovery_reason` assertions and avoid wall-clock sleeps. The guarded macOS case uses the real FSEvents-backed watcher and should remain a platform probe rather than being emulated on Linux or Windows. - `BackgroundTaskObserverTests` relies on `BackgroundTaskObserver`'s fault-only continuation contract: canceled tasks are awaited directly and do not need a post-cancellation fixed sleep to @@ -1290,6 +1291,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" scan、`status --check`、watch が単一の `.cdidx` membership policy を使うことを固定する。ignore / unignore 変更と pattern-config の追加 / 編集 / 削除は debounce 付き full scan に到達し、pattern / plugin 入力と通常の `.cdidx` sidecar は indexed source row から除外されたままでなければならない。pattern extension の編集では同一 process 内で旧 extension を purge して新 extension を index する。registry refresh test は読み込み済み workspace plugin を削除し、host 登録 fallback を維持しながら collectible context が unload されることも検証する。platform 固有の `FileSystemWatcher` 配信を待たず、classification 境界を直接検証する。 - `IndexWatchRunnerTests.RunCore_StartupHandoff_ReconcilesMutationAndDrainsBeforeReady_Issue4594` subscribe 後の startup hook から index 済み symbol を変更して event を明示的に enqueue し、最終 database content と `watching` より前の `rescanned` / `updated` 順序を検証する。platform watcher の timing だけに依存すると handoff 回帰が非決定的になるため、注入した enqueue を維持すること。 +- issue-4858 の watch startup fixture には、実際の full-scan write phase を数える top-level command test と、`IWatchBackend` 実装および明示的な baseline / recovery delegate を注入する focused test がある。backend 起動 retry が baseline 1 回より前に完了すること、複数の event-loss callback が recovery generation 1 回へ集約されること、2 回とも起動に失敗した場合は baseline 前に停止すること、ready callback からのみ cancel することを固定する。機械可読な `backend` / `recovery_reason` assertion を維持し、wall-clock sleep は使わないこと。macOS guard 付き case は実際の FSEvents-backed watcher を使う platform probe とし、Linux / Windows 上で模倣しない。 - `BackgroundTaskObserverTests` は `BackgroundTaskObserver` の fault-only continuation 契約に依存します。canceled task は直接 await し、warning が抑止されたことを示すための cancellation 後の固定 sleep diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 1e1e3651b..a6e67fee6 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1087,7 +1087,7 @@ small repositories, and minutes or longer on very large monorepos with around By default, `cdidx index` stores the database in `/.cdidx/codeindex.db`, even if you run the command from another directory. -`--watch` keeps the process alive after the initial scan and rebuilds the index incrementally as files are created, edited, renamed, or deleted. It uses `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows), debounces bursts of events (`--debounce `, default 500 ms) into a single `--files` update, releases the per-DB index lock between batches so other `cdidx` commands can still query, and falls back to a full incremental rescan if the watcher buffer overflows or a pending path batch reaches its safety cap. Subdirectory watches also monitor ancestor `.gitignore` / `.cdidxignore` files along the repository path. Startup reconciliation must succeed before `watching` is emitted; a failed startup generation exits instead of declaring a stale watcher ready. With `--json` it streams `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout, and update/rescan events include `exit_code`; otherwise it writes `[watch] …` summaries with exit codes to stderr. Stop the loop with Ctrl+C (or SIGTERM); the first Ctrl+C requests cooperative cancellation and a second Ctrl+C remains available for force exit. The final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. +`--watch` starts `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows) before the one required baseline scan, then keeps the process alive and rebuilds the index incrementally as files are created, edited, renamed, or deleted. A recoverable backend-start failure is retried before that baseline, so starting the replacement backend does not repeat a valid scan. Events buffered during the baseline are drained before `watching`; genuine event loss after the backend is active is coalesced into at most one justified full incremental recovery scan per generation. Bursts of ordinary events are debounced (`--debounce `, default 500 ms) into a single `--files` update, the per-DB index lock is released between batches so other `cdidx` commands can still query, and a pending path batch that reaches its safety cap also requests one recovery scan. Subdirectory watches monitor ancestor `.gitignore` / `.cdidxignore` files along the repository path. The baseline and startup reconciliation must succeed before `watching` is emitted; a failed startup generation exits instead of declaring a stale watcher ready. With `--json` it streams `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout; startup and recovery events expose `backend` and machine-readable `recovery_reason`, while update/rescan events include `exit_code`. Human output includes the same backend/recovery context in `[watch] …` summaries. Stop the loop with Ctrl+C (or SIGTERM); the first Ctrl+C requests cooperative cancellation and a second Ctrl+C remains available for force exit. The final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. Indexing scope and ignore handling: @@ -2034,7 +2034,7 @@ same source location. | `--max-symbols-per-file ` | `index` | Skip file content, symbols, and references when one file emits too many symbols. Defaults to `5000`; values above `50000` are rejected. | | `--symbols-only` | `index` | Full-scan only. Build chunks, symbols, and issues while skipping reference extraction and graph finalization for a faster first pass. `search`, `definition`, `symbols`, and `map` are available; reference graph commands remain degraded until a normal `cdidx index ` run. | | `--parallelism ` | `index` | Set full-scan extraction worker count. Defaults to CPU count capped at 8, or `CDIDX_INDEX_PARALLELISM` when set; explicit values are capped at 16. SQLite writes stay single-consumer. | -| `--watch` | `index` | After the initial scan completes, stay running and reindex incrementally as files change (FileSystemWatcher / inotify / FSEvents). Watch subscribes before a startup reconciliation scan, drains the buffered startup generation, and only then emits `watching`, so edits made during the initial-scan handoff are not missed. Changes to `.gitignore`, `.cdidxignore`, `.cdidx/patterns/**`, or `.cdidx/plugins/**` trigger a debounced full-workspace reconciliation; pattern/plugin additions, edits, and removals refresh the in-process extractor registry before that scan. The `.cdidx` namespace itself is excluded from source membership consistently with full scan and `status --check`, including ordinary sidecars. Ctrl-C cancels an active indexing sub-run before the loop emits its stopped event. Sub-run stdout is captured through a runner-scoped writer, so an embedded or concurrent command keeps its own process stdout. Rejects `--commits`, `--changed-between`, `--files`, and `--dry-run` because the loop already drives continuous incremental updates. | +| `--watch` | `index` | Start the filesystem backend before one baseline scan, then stay running and reindex incrementally as files change (FileSystemWatcher / inotify / FSEvents). A recoverable backend-start failure is retried before the baseline; buffered startup events are drained before `watching`, and event loss after activation triggers at most one recovery scan per generation. JSON startup/recovery events expose `backend` and `recovery_reason`. Changes to `.gitignore`, `.cdidxignore`, `.cdidx/patterns/**`, or `.cdidx/plugins/**` trigger a debounced full-workspace reconciliation; pattern/plugin additions, edits, and removals refresh the in-process extractor registry before that scan. The `.cdidx` namespace itself is excluded from source membership consistently with full scan and `status --check`, including ordinary sidecars. Ctrl-C cancels an active indexing sub-run before the loop emits its stopped event. Sub-run stdout is captured through a runner-scoped writer, so an embedded or concurrent command keeps its own process stdout. Rejects `--commits`, `--changed-between`, `--files`, and `--dry-run` because the loop already drives continuous incremental updates. | | `--debounce ` | `index` (watch only) | Coalesce bursts of file events into a single update after `` of quiet (non-negative integer; default: 500). Invalid values emit a warning and are ignored. | | `--watch-pending-path-limit ` | `index` (watch only) | Set the number of distinct changed paths the watch loop will queue before it reports an overflow and falls back to a full rescan. Defaults to `4096`, honors `CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` and `indexing.watchPendingPathLimit`, and rejects values above `262144`. The `watching` and `overflow` JSON events include `watch_pending_path_limit`. | | `--since ` | `search`, `definition`, `symbols`, `files` | Filter to files modified since this ISO 8601 timestamp. Offsetless values (e.g. `2024-01-01T00:00:00`) are treated as UTC so the same flag resolves to the same instant in every timezone; append `Z` or an explicit offset (`+09:00`) to be explicit. | @@ -4287,7 +4287,7 @@ interactive terminal では spinner と progress bar が動き続けます。待 `cdidx index` は、別ディレクトリから実行しても、デフォルトでは `/.cdidx/codeindex.db` にDBを保存します。 -`--watch` を付けると初回スキャン後もプロセスが残り、`FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)でファイルの作成・編集・リネーム・削除を検知して差分更新を繰り返します。`--debounce `(既定 500 ms)の窓内で発生したイベントは 1 つの `--files` 更新にまとめられ、バッチ間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。Watcher バッファがオーバーフローした場合や pending path batch が安全上限に達した場合は、変更を黙って捨てる代わりにフル差分再走査へフォールバックします。subdirectory の watch は repository path 上の ancestor `.gitignore` / `.cdidxignore` も監視します。startup reconciliation が成功するまで `watching` は出力せず、startup generation が失敗した場合は stale なまま ready を宣言せず終了します。`--json` 時は `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流し、update/rescan event には `exit_code` を含めます。そうでなければ exit code 付きの `[watch] …` 要約を stderr に出力します。最初の Ctrl+C(または SIGTERM)は協調的な cancellation を要求し、2 回目の Ctrl+C は強制終了に利用できます。すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 +`--watch` は必要な baseline scan 1 回より先に `FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)を開始し、その後もプロセスを残してファイルの作成・編集・リネーム・削除を差分反映します。回復可能な backend 起動失敗は baseline より前に retry するため、代替 backend の開始だけを理由に有効な scan を繰り返しません。baseline 中に buffer された event は `watching` の前に drain し、backend 有効化後の本当の event loss だけを generation ごとに最大 1 回の根拠付きフル差分 recovery scan へ集約します。通常 event は `--debounce `(既定 500 ms)の窓で 1 つの `--files` 更新にまとめ、batch 間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。pending path batch が安全上限に達した場合も recovery scan を 1 回要求します。subdirectory の watch は repository path 上の ancestor `.gitignore` / `.cdidxignore` も監視します。baseline と startup reconciliation が成功するまで `watching` は出力せず、startup generation が失敗した場合は stale なまま ready を宣言せず終了します。`--json` 時は `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流します。startup / recovery event は `backend` と機械可読な `recovery_reason` を公開し、update/rescan event は `exit_code` を含みます。human 出力も `[watch] …` 要約に同じ backend / recovery context を含めます。最初の Ctrl+C(または SIGTERM)は協調的な cancellation を要求し、2 回目の Ctrl+C は強制終了に利用できます。すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 デフォルト出力: @@ -5194,7 +5194,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--max-file-bytes ` | `index` | この実行で使うファイル単位の索引サイズ上限を上書きする。既定は 4MiB、または `CDIDX_MAX_FILE_BYTES` 設定値。値は raw byte 数、または `50M` のような `K` / `M` / `G` 接尾辞を受け付ける。 | | `--symbols-only` | `index` | フルスキャン専用。参照抽出と graph finalization を省き、chunks、symbols、issues だけを作ることで初回利用を速くする。`search`、`definition`、`symbols`、`map` は使えるが、reference graph 系コマンドは通常の `cdidx index ` を実行するまで degraded のまま。 | | `--parallelism ` | `index` | フルスキャンの抽出 worker 数を指定する。既定は CPU 数を最大 8 に丸めた値、または `CDIDX_INDEX_PARALLELISM` 設定値。明示値は最大 16。SQLite 書き込みは単一 consumer のまま。 | -| `--watch` | `index` | 初回スキャン完了後もプロセスを残し、ファイル変更を検知して差分更新を繰り返す(FileSystemWatcher / inotify / FSEvents)。watch は startup reconciliation scan より先に subscribe し、buffer した startup generation を drain してから `watching` を出力するため、初回 scan からの handoff 中に行われた編集を取りこぼさない。`.gitignore`、`.cdidxignore`、`.cdidx/patterns/**`、`.cdidx/plugins/**` の変更は debounce 後に workspace 全体を reconciliation し、pattern / plugin の追加・編集・削除では scan 前に process 内 extractor registry も refresh する。通常 sidecar を含む `.cdidx` namespace 自体は full scan と `status --check` と同様に source membership から除外する。Ctrl-C は実行中の indexing sub-run をキャンセルしてから stopped event を出力する。sub-run の stdout は runner scope の writer で capture するため、埋め込み先や同時実行 command の process stdout を置き換えない。連続的な差分更新を内蔵しているため `--commits` / `--changed-between` / `--files` / `--dry-run` との併用は拒否する。 | +| `--watch` | `index` | filesystem backend(FileSystemWatcher / inotify / FSEvents)を baseline scan 1 回より先に開始し、その後もプロセスを残してファイル変更を差分反映する。回復可能な backend 起動失敗は baseline 前に retry し、startup 中に buffer した event は `watching` 前に drain する。backend 有効化後の event loss は generation ごとに最大 1 回の recovery scan を起動する。JSON の startup / recovery event は `backend` と `recovery_reason` を公開する。`.gitignore`、`.cdidxignore`、`.cdidx/patterns/**`、`.cdidx/plugins/**` の変更は debounce 後に workspace 全体を reconciliation し、pattern / plugin の追加・編集・削除では scan 前に process 内 extractor registry も refresh する。通常 sidecar を含む `.cdidx` namespace 自体は full scan と `status --check` と同様に source membership から除外する。Ctrl-C は実行中の indexing sub-run をキャンセルしてから stopped event を出力する。sub-run の stdout は runner scope の writer で capture するため、埋め込み先や同時実行 command の process stdout を置き換えない。連続的な差分更新を内蔵しているため `--commits` / `--changed-between` / `--files` / `--dry-run` との併用は拒否する。 | | `--debounce ` | `index`(`--watch` 専用) | 一連のイベントを `` の静止後に 1 つの更新へ集約する(0 以上の整数。既定: 500)。不正な値は警告を出して無視する。 | | `--watch-pending-path-limit ` | `index`(`--watch` 専用) | watch loop が overflow を報告して full rescan へ fallback する前に保持する distinct changed path 数を設定する。既定は `4096` で、`CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` と `indexing.watchPendingPathLimit` も使える。`262144` を超える値は拒否される。`watching` と `overflow` の JSON event には `watch_pending_path_limit` が入る。 | | `--since ` | `search`, `definition`, `symbols`, `files` | 指定タイムスタンプ以降に変更されたファイルのみ(ISO 8601)。オフセットなしの値(例: `2024-01-01T00:00:00`)は UTC として解釈されるため、どのタイムゾーンから呼び出しても同じ UTC 時点になります。明示したい場合は末尾に `Z` または `+09:00` 等のオフセットを付与してください。 | diff --git a/changelog.d/unreleased/4858.fixed.md b/changelog.d/unreleased/4858.fixed.md new file mode 100644 index 000000000..1f54fad02 --- /dev/null +++ b/changelog.d/unreleased/4858.fixed.md @@ -0,0 +1,26 @@ +--- +category: fixed +issues: + - 4858 +affected: + - src/CodeIndex/Cli/IndexCommandRunner.cs + - src/CodeIndex/Cli/IndexWatchRunner.cs + - src/CodeIndex/Cli/IndexWatchRunner.Batching.cs + - src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs + - src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs + - src/CodeIndex/Cli/IndexWatchJsonContracts.cs + - src/CodeIndex/Cli/JsonOutputContracts.cs + - src/CodeIndex/Cli/ConsoleUi.Help.cs + - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs + - tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **macOS watch startup now performs one baseline scan across recoverable backend fallback (#4858)** — the watch backend starts before the required baseline, retries startup failures before that baseline, and performs one recovery scan only when event-stream uncertainty requires it. Watch diagnostics now report the backend and recovery reason. + +## 日本語 + +- **macOS の watch 起動時に、復旧可能なバックエンドフォールバックをまたいでもベースラインスキャンを1回だけ実行するようになりました (#4858)** — 必須のベースラインより先に watch バックエンドを起動し、起動失敗はベースライン前に再試行します。また、イベントストリームの不確実性により必要な場合のみリカバリスキャンを1回実行します。watch 診断にはバックエンドとリカバリ理由も表示されます。 diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 18b5267e0..0a89a2d34 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -182,7 +182,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" --changed-between "); Console.WriteLine(" Update only files changed between two git refs (useful after branch switches)"); Console.WriteLine(" --files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed"); - WriteHelpLine(" --watch After the initial scan, stay running and reindex on file changes (FileSystemWatcher / inotify / FSEvents); rejects --commits / --changed-between / --files / --dry-run"); + WriteHelpLine(" --watch Start the filesystem backend before one baseline scan, then stay running and reindex changes; backend-start retry happens before the baseline, while event loss triggers one recovery rescan; rejects --commits / --changed-between / --files / --dry-run"); Console.WriteLine($" --debounce Watch only: coalesce bursts of file events into one update after of quiet (default: {IndexWatchRunner.DefaultDebounceMs}, max {IndexWatchRunner.MaxDebounceMs})"); WriteHelpLine($" --watch-pending-path-limit Watch only: pending changed-path queue limit before falling back to a full rescan (default: {IndexWatchRunner.DefaultWatchPendingPathLimit}, max: {IndexWatchRunner.MaxWatchPendingPathLimit}; also honors {IndexCommandRunner.WatchPendingPathLimitEnvironmentVariable})"); Console.WriteLine(" --optimize index only: optimize the existing FTS5 table for this project's DB without scanning files"); diff --git a/src/CodeIndex/Cli/IndexCommandRunner.cs b/src/CodeIndex/Cli/IndexCommandRunner.cs index 582d8f5d1..8e7a008fa 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.cs @@ -234,7 +234,7 @@ internal static int Run( return WriteInterruptedResult(options.Json, jsonOptions, filesProcessed: 0, filesTotal: null, mode, progressPersisted); } - var initialExitCode = RunInitialIndex(new IndexRunExecutionContext( + var executionContext = new IndexRunExecutionContext( options, jsonOptions, jsonContext, @@ -249,23 +249,24 @@ internal static int Run( spinnerFrames, databaseExistedBeforeIndex, ignoreCase, - ignoreRuleRoot)); + ignoreRuleRoot); + if (!options.Watch) + return RunInitialIndex(executionContext); - if (!options.Watch || initialExitCode != CommandExitCodes.Success) - return initialExitCode; - - // Release the index lock before entering the watch loop so concurrent - // `cdidx index` invocations between batches can still acquire it. Each - // partial-update batch re-acquires the lock through IndexCommandRunner.Run. - // watch ループ突入前にロックを解放し、バッチ間に別プロセスの `cdidx index` が - // 取得できる状態にする。各バッチ更新はサブ実行で再取得する。 + // Subscribe the watch backend before the one required baseline scan. This closes the + // old initial-scan/subscription gap without paying for an unconditional second scan. + // Recovery scans are reserved for event loss after the backend becomes active. + // 必要な baseline scan 1 回より先に watch backend を subscribe する。これにより従来の + // initial-scan / subscribe 間の gap を閉じ、無条件の 2 回目 scan を避ける。recovery scan は + // backend 有効化後に event loss が発生した場合だけ実行する。 return IndexWatchRunner.Run( options, jsonOptions, Path.GetFullPath(options.ProjectPath!), Path.GetFullPath(dbPath), - indexCancellation.Token); + indexCancellation.Token, + () => RunInitialIndex(executionContext)); } private sealed record IndexRunExecutionContext( diff --git a/src/CodeIndex/Cli/IndexWatchJsonContracts.cs b/src/CodeIndex/Cli/IndexWatchJsonContracts.cs index 8b7a687f3..5e1a2e7a0 100644 --- a/src/CodeIndex/Cli/IndexWatchJsonContracts.cs +++ b/src/CodeIndex/Cli/IndexWatchJsonContracts.cs @@ -11,6 +11,8 @@ internal sealed class IndexWatchStartedJsonResult : IVersionedJsonResult public string? Db { get; init; } public int? DebounceMs { get; init; } public int? WatchPendingPathLimit { get; init; } + public string? Backend { get; init; } + public string? RecoveryReason { get; init; } public IndexWatchContractJsonResult? WatchContract { get; init; } } @@ -26,6 +28,8 @@ internal sealed class IndexWatchContractJsonResult public string RenameEvents { get; init; } = string.Empty; public string OverflowRecovery { get; init; } = string.Empty; public string WatcherErrorRecovery { get; init; } = string.Empty; + public string BaselineScan { get; init; } = string.Empty; + public string BackendStartRecovery { get; init; } = string.Empty; public string Cancellation { get; init; } = string.Empty; public string SubRunOutput { get; init; } = string.Empty; public string McpWatchMode { get; init; } = string.Empty; diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Batching.cs b/src/CodeIndex/Cli/IndexWatchRunner.Batching.cs index 6d68fe616..baf17774e 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Batching.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Batching.cs @@ -26,6 +26,7 @@ internal sealed class FileChangeBatcher private bool _hasLastEventTimestamp; private bool _overflowRequested; private string? _overflowReason; + private string? _recoveryReason; private readonly TimeSpan _debounce; private readonly TimeProvider _timeProvider; private readonly int _maxPendingPaths; @@ -65,7 +66,8 @@ public void Add(string path) if (_pending.Count >= _maxPendingPaths) { RequestFullRescanLocked( - $"pending path limit exceeded ({_maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)} paths)"); + $"pending path limit exceeded ({_maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)} paths)", + "pending_path_limit_exceeded"); return; } @@ -76,24 +78,39 @@ public void Add(string path) } } - public void RequestFullRescan(string? reason = null) + public void RequestFullRescan(string? reason = null, string recoveryReason = "watcher_error") { lock (_gate) { - RequestFullRescanLocked(reason); + RequestFullRescanLocked(reason, recoveryReason); } } public bool TryDrain(out IReadOnlyList batch, out bool fullRescan, out string? overflowReason) - => TryDrainCore(requireDebounce: true, out batch, out fullRescan, out overflowReason); + => TryDrainCore(requireDebounce: true, out batch, out fullRescan, out _, out overflowReason); + + internal bool TryDrain( + out IReadOnlyList batch, + out bool fullRescan, + out string? recoveryReason, + out string? overflowReason) + => TryDrainCore(requireDebounce: true, out batch, out fullRescan, out recoveryReason, out overflowReason); public bool TryDrainImmediately(out IReadOnlyList batch, out bool fullRescan, out string? overflowReason) - => TryDrainCore(requireDebounce: false, out batch, out fullRescan, out overflowReason); + => TryDrainCore(requireDebounce: false, out batch, out fullRescan, out _, out overflowReason); + + internal bool TryDrainImmediately( + out IReadOnlyList batch, + out bool fullRescan, + out string? recoveryReason, + out string? overflowReason) + => TryDrainCore(requireDebounce: false, out batch, out fullRescan, out recoveryReason, out overflowReason); private bool TryDrainCore( bool requireDebounce, out IReadOnlyList batch, out bool fullRescan, + out string? recoveryReason, out string? overflowReason) { lock (_gate) @@ -102,6 +119,7 @@ private bool TryDrainCore( { batch = Array.Empty(); fullRescan = false; + recoveryReason = null; overflowReason = null; return false; } @@ -112,6 +130,7 @@ private bool TryDrainCore( { batch = Array.Empty(); fullRescan = false; + recoveryReason = null; overflowReason = null; return false; } @@ -121,18 +140,23 @@ private bool TryDrainCore( snapshot.Add(path); batch = snapshot; fullRescan = _overflowRequested; + recoveryReason = _recoveryReason; overflowReason = _overflowReason; _pending.Clear(); _overflowRequested = false; + _recoveryReason = null; _overflowReason = null; return true; } } - private void RequestFullRescanLocked(string? reason) + private void RequestFullRescanLocked(string? reason, string recoveryReason) { _pending.Clear(); _overflowRequested = true; + _recoveryReason = string.IsNullOrWhiteSpace(recoveryReason) + ? "watcher_error" + : recoveryReason; if (!string.IsNullOrEmpty(reason)) _overflowReason = IndexWatchRunner.FormatWatchDiagnosticText(reason); RecordEventTimestampLocked(); diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs index 24352fa49..66bda6894 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs @@ -179,8 +179,12 @@ private static void EmitWatchStarted( string resolvedDbPath, TimeSpan debounce, int maxPendingPaths, - bool ignoreCase) + bool ignoreCase, + string? backend, + string? recoveryReason) { + var safeBackend = FormatWatchDiagnosticText(backend) ?? "filesystem_watcher"; + var safeRecoveryReason = FormatWatchDiagnosticText(recoveryReason); if (baseOptions.Json) { Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchStartedJsonResult @@ -191,13 +195,16 @@ private static void EmitWatchStarted( Db = "[redacted]", DebounceMs = (int)debounce.TotalMilliseconds, WatchPendingPathLimit = maxPendingPaths, + Backend = safeBackend, + RecoveryReason = safeRecoveryReason, WatchContract = BuildWatchContract(debounce, maxPendingPaths, ignoreCase), }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchStartedJsonResult)); } else { + var recovery = safeRecoveryReason ?? "none"; CommandErrorWriter.WriteStderr(); - CommandErrorWriter.WriteStderr($"[watch] Watching {projectRoot} for changes (debounce {(int)debounce.TotalMilliseconds} ms, pending path limit {maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)}). Press Ctrl+C to stop."); + CommandErrorWriter.WriteStderr($"[watch] Watching {projectRoot} for changes (backend {safeBackend}, recovery {recovery}, debounce {(int)debounce.TotalMilliseconds} ms, pending path limit {maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)}). Press Ctrl+C to stop."); } } @@ -217,17 +224,84 @@ private static IndexWatchContractJsonResult BuildWatchContract( RenameEvents = "old_and_new_paths", OverflowRecovery = "full_rescan_after_debounce", WatcherErrorRecovery = "full_rescan_after_debounce", + BaselineScan = "single_after_backend_start", + BackendStartRecovery = "retry_before_baseline", Cancellation = "cancel_active_sub_run_then_emit_stopped", SubRunOutput = "json_quiet_sub_runs", McpWatchMode = "unsupported", }; + private static void EmitWatchBackendFallback( + IndexCommandOptions baseOptions, + JsonSerializerOptions jsonOptions, + string? backend, + string recoveryReason, + string? reason) + => EmitWatchBackendStartupEvent( + baseOptions, + jsonOptions, + status: "backend_fallback", + backend: backend, + recoveryReason: recoveryReason, + reason: reason, + humanAction: "retrying before the baseline scan"); + + private static void EmitWatchBackendFailure( + IndexCommandOptions baseOptions, + JsonSerializerOptions jsonOptions, + string? backend, + string recoveryReason, + string? reason) + => EmitWatchBackendStartupEvent( + baseOptions, + jsonOptions, + status: "failed", + backend: backend, + recoveryReason: recoveryReason, + reason: reason, + humanAction: "watch startup stopped before the baseline scan"); + + private static void EmitWatchBackendStartupEvent( + IndexCommandOptions baseOptions, + JsonSerializerOptions jsonOptions, + string status, + string? backend, + string recoveryReason, + string? reason, + string humanAction) + { + var safeBackend = FormatWatchDiagnosticText(backend) ?? "filesystem_watcher"; + var safeRecoveryReason = FormatWatchDiagnosticText(recoveryReason) ?? "backend_start_failed"; + var safeReason = FormatWatchDiagnosticText(reason); + if (baseOptions.Json) + { + Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult + { + Status = status, + Phase = "startup", + Backend = safeBackend, + RecoveryReason = safeRecoveryReason, + Reason = safeReason, + }, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult)); + return; + } + + var detail = string.IsNullOrEmpty(safeReason) ? string.Empty : $"; {safeReason}"; + CommandErrorWriter.WriteStderr( + $"[watch] Backend {safeBackend} startup failed (recovery {safeRecoveryReason}{detail}); {humanAction}."); + } + private static void EmitWatchOverflow( IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions, string? reason, - string resolvedDbPath) + string resolvedDbPath, + string phase, + string? backend, + string? recoveryReason) { + var safeBackend = FormatWatchDiagnosticText(backend) ?? "filesystem_watcher"; + var safeRecoveryReason = FormatWatchDiagnosticText(recoveryReason) ?? "watcher_error"; var safeReason = FormatWatchDiagnosticText(reason); if (baseOptions.Json) { @@ -235,7 +309,9 @@ private static void EmitWatchOverflow( { Status = "overflow", Reason = safeReason, - Phase = "incremental", + Phase = phase, + Backend = safeBackend, + RecoveryReason = safeRecoveryReason, OverflowReason = safeReason, WatchPendingPathLimit = baseOptions.WatchPendingPathLimit, RecoveryCommand = BuildOverflowRecoveryCommand(baseOptions, resolvedDbPath, redactPaths: true), @@ -244,7 +320,9 @@ private static void EmitWatchOverflow( else { var detail = string.IsNullOrEmpty(safeReason) ? string.Empty : $" ({safeReason})"; - CommandErrorWriter.WriteStderr($"[watch] Watcher buffer overflowed{detail}; falling back to full rescan."); + CommandErrorWriter.WriteStderr( + $"[watch] Watcher backend {safeBackend} requires a full rescan " + + $"(recovery {safeRecoveryReason}){detail}."); } } diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index 0eda6ee88..831161533 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -11,6 +11,31 @@ namespace CodeIndex.Cli; internal static partial class IndexWatchRunner { + internal interface IWatchBackend : IDisposable + { + string Name { get; } + + void Start(Action enqueue, Action reportError); + } + + internal static Func? WatchBackendFactoryForTesting { get; set; } + + private static IWatchBackend CreateWatchBackend( + string projectRoot, + string ignoreRuleRoot, + bool ignoreCase) + => WatchBackendFactoryForTesting?.Invoke(projectRoot, ignoreRuleRoot, ignoreCase) + ?? new FileSystemWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase); + + private static string ResolveWatchBackendName() + => OperatingSystem.IsMacOS() + ? "fsevents" + : OperatingSystem.IsLinux() + ? "inotify" + : OperatingSystem.IsWindows() + ? "read_directory_changes_w" + : "filesystem_watcher"; + private static List CreateAncestorIgnoreWatchers( string projectRoot, string ignoreRuleRoot, @@ -252,4 +277,77 @@ private static string TrimDirectorySeparators(string value) : trimmed; } + private sealed class FileSystemWatchBackend : IWatchBackend + { + private readonly string _projectRoot; + private readonly string _ignoreRuleRoot; + private readonly bool _ignoreCase; + private FileSystemWatcher? _watcher; + private List? _ancestorIgnoreWatchers; + + internal FileSystemWatchBackend( + string projectRoot, + string ignoreRuleRoot, + bool ignoreCase) + { + _projectRoot = projectRoot; + _ignoreRuleRoot = ignoreRuleRoot; + _ignoreCase = ignoreCase; + } + + public string Name => ResolveWatchBackendName(); + + public void Start(Action enqueue, Action reportError) + { + ObjectDisposedException.ThrowIf(_watcher != null, this); + + var watcher = new FileSystemWatcher(_projectRoot) + { + IncludeSubdirectories = true, + InternalBufferSize = InternalBufferSize, + NotifyFilter = NotifyFilters.FileName + | NotifyFilters.DirectoryName + | NotifyFilters.LastWrite + | NotifyFilters.Size, + }; + _watcher = watcher; + + watcher.Created += (_, e) => enqueue(e.FullPath); + watcher.Changed += (_, e) => enqueue(e.FullPath); + watcher.Deleted += (_, e) => enqueue(e.FullPath); + watcher.Renamed += (_, e) => + { + enqueue(e.OldFullPath); + enqueue(e.FullPath); + }; + watcher.Error += (_, e) => reportError(e.GetException()); + + watcher.EnableRaisingEvents = true; + _ancestorIgnoreWatchers = CreateAncestorIgnoreWatchers( + _projectRoot, + _ignoreRuleRoot, + _ignoreCase, + enqueue); + } + + public void Dispose() + { + if (_ancestorIgnoreWatchers != null) + { + foreach (var ancestorWatcher in _ancestorIgnoreWatchers) + { + try { ancestorWatcher.EnableRaisingEvents = false; } catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) { } + ancestorWatcher.Dispose(); + } + _ancestorIgnoreWatchers = null; + } + + if (_watcher != null) + { + try { _watcher.EnableRaisingEvents = false; } catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) { } + _watcher.Dispose(); + _watcher = null; + } + } + } } diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index abc64713b..8a66e0793 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -79,11 +79,12 @@ public static int Run( JsonSerializerOptions jsonOptions, string projectRoot, string resolvedDbPath, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? baselineScan = null) { try { - return RunCore(baseOptions, jsonOptions, projectRoot, resolvedDbPath, cancellationToken); + return RunCore(baseOptions, jsonOptions, projectRoot, resolvedDbPath, cancellationToken, baselineScan); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { @@ -96,10 +97,17 @@ internal static int RunCore( JsonSerializerOptions jsonOptions, string projectRoot, string resolvedDbPath, - CancellationToken cancellationToken) + CancellationToken cancellationToken, + Func? baselineScan = null) // Preserve the existing synchronous CLI/test entry point while the watch loop itself // runs through RunCoreAsync so cancellation does not block on Task.Delay. - => RunCoreAsync(baseOptions, jsonOptions, projectRoot, resolvedDbPath, cancellationToken) + => RunCoreAsync( + baseOptions, + jsonOptions, + projectRoot, + resolvedDbPath, + cancellationToken, + baselineScan: baselineScan) .GetAwaiter() .GetResult(); @@ -109,7 +117,9 @@ internal static async Task RunCoreAsync( string projectRoot, string resolvedDbPath, CancellationToken cancellationToken, - Action>? startupHandoff = null) + Action>? startupHandoff = null, + Func? baselineScan = null, + Func? recoveryScan = null) { var debounce = TimeSpan.FromMilliseconds(baseOptions.WatchDebounceMs ?? DefaultDebounceMs); var maxPendingPaths = baseOptions.WatchPendingPathLimit; @@ -127,20 +137,14 @@ internal static async Task RunCoreAsync( internalIndexDatabasePath: resolvedDbPath); var watchExitCode = CommandExitCodes.Success; - FileSystemWatcher? watcher = null; - List? ancestorIgnoreWatchers = null; + var baselineStateGate = new object(); + var baselineState = 0; // 0 = not started, 1 = running, 2 = complete + Exception? startupBackendError = null; + IWatchBackend? backend = null; + string? backendName = null; + string? startupRecoveryReason = null; try { - watcher = new FileSystemWatcher(projectRoot) - { - IncludeSubdirectories = true, - InternalBufferSize = InternalBufferSize, - NotifyFilter = NotifyFilters.FileName - | NotifyFilters.DirectoryName - | NotifyFilters.LastWrite - | NotifyFilters.Size, - }; - void Enqueue(string fullPath) { if (string.IsNullOrEmpty(fullPath)) @@ -174,66 +178,125 @@ void Enqueue(string fullPath) batcher.Add(fullPath); } - watcher.Created += (_, e) => Enqueue(e.FullPath); - watcher.Changed += (_, e) => Enqueue(e.FullPath); - watcher.Deleted += (_, e) => Enqueue(e.FullPath); - watcher.Renamed += (_, e) => - { - Enqueue(e.OldFullPath); - Enqueue(e.FullPath); - }; - watcher.Error += (_, e) => + void ReportBackendError(Exception? exception) { - // Buffer overflows on Linux/inotify and macOS/FSEvents drop individual paths; - // a full rescan is the only safe recovery. Surface the reason for users who - // may need to raise fs.inotify.max_user_watches. - // バッファ溢れ時は個別パスが失われるためフルスキャンへ。inotify 上限引き上げの - // 必要性が判断できるよう理由も保持する。 - batcher.RequestFullRescan(e.GetException()?.Message); - }; - - watcher.EnableRaisingEvents = true; - ancestorIgnoreWatchers = CreateAncestorIgnoreWatchers( - projectRoot, - ignoreRuleRoot, - ignoreCase, - Enqueue); - startupHandoff?.Invoke(Enqueue); - - // The initial command scan completed before this watcher was subscribed. Re-scan - // after subscription, then drain every event buffered during that reconciliation - // before publishing the ready event. A callback that reaches Add after - // the ready transition is an ordinary live update, not a startup handoff event. - // 初回 command scan は watcher の subscribe より先に完了している。subscribe 後に - // 再 scan し、その間に buffer された event をすべて drain してから ready event を - // 公開する。ready 遷移後に Add へ到達した callback は通常の live update。 - if (File.Exists(DbPathResolver.NormalizeDbPath(resolvedDbPath))) - { - RecordSubRunExitCode( - ref watchExitCode, - RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken, phase: "startup")); + lock (baselineStateGate) + { + if (baselineState == 0) + { + startupBackendError ??= exception + ?? new IOException("watch backend reported an unspecified startup error"); + return; + } + } + + // Once the baseline starts, an overflow/error creates an uncertainty window. + // Collapse any number of callbacks into one justified recovery generation. + // baseline 開始後の overflow/error は不確実区間を作る。複数 callback が来ても + // justified recovery generation 1 回へ集約する。 + var recoveryReason = exception is InternalBufferOverflowException + ? "event_stream_overflow" + : "backend_error"; + batcher.RequestFullRescan(exception?.Message, recoveryReason); } - // Close the startup generation by taking one atomic snapshot. Events arriving after - // this boundary remain queued as ordinary live updates, so a continuously changing - // workspace cannot prevent the watcher from ever becoming ready. - // startup generation は atomic snapshot で閉じる。この境界後の event は通常の - // live update として queue に残し、変更が連続する workspace でも ready を妨げない。 - if (!cancellationToken.IsCancellationRequested - && batcher.TryDrainImmediately(out var startupBatch, out var startupFullRescan, out var startupOverflowReason)) + for (var attempt = 0; attempt < 2 && !cancellationToken.IsCancellationRequested; attempt++) { - if (startupFullRescan) + startupBackendError = null; + backend = CreateWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase); + backendName = backend.Name; + try { - EmitWatchOverflow(baseOptions, jsonOptions, startupOverflowReason, resolvedDbPath); - RecordSubRunExitCode( - ref watchExitCode, - RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken, phase: "startup")); + backend.Start(Enqueue, ReportBackendError); } - else if (startupBatch.Count > 0) + catch (Exception ex) when (IsRecoverableWatchBackendStartException(ex)) { - RecordSubRunExitCode( - ref watchExitCode, - RunPartialUpdate(baseOptions, jsonOptions, startupBatch, resolvedDbPath, cancellationToken, phase: "startup")); + startupBackendError = ex; + } + + Exception? startFailure; + lock (baselineStateGate) + { + startFailure = startupBackendError; + if (startFailure == null) + baselineState = 1; + } + + if (startFailure == null) + break; + + startupRecoveryReason = "backend_start_failed"; + var detail = CommandErrorWriter.FormatSanitizedExceptionMessage(startFailure); + backend.Dispose(); + backend = null; + + if (attempt == 0) + { + EmitWatchBackendFallback( + baseOptions, + jsonOptions, + backendName, + startupRecoveryReason, + detail); + continue; + } + + EmitWatchBackendFailure( + baseOptions, + jsonOptions, + backendName, + startupRecoveryReason, + detail); + watchExitCode = CommandExitCodes.RuntimeError; + } + + if (backend != null && !cancellationToken.IsCancellationRequested) + { + startupHandoff?.Invoke(Enqueue); + + var baselineExitCode = baselineScan != null + ? baselineScan() + : File.Exists(DbPathResolver.NormalizeDbPath(resolvedDbPath)) + ? RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken, phase: "startup") + : CommandExitCodes.Success; + RecordSubRunExitCode(ref watchExitCode, baselineExitCode); + lock (baselineStateGate) + baselineState = 2; + + // Close the startup generation by taking one atomic snapshot. Events arriving + // after this boundary remain queued as ordinary live updates, so a continuously + // changing workspace cannot prevent the watcher from ever becoming ready. + // startup generation は atomic snapshot で閉じる。この境界後の event は通常の + // live update として queue に残し、変更が連続する workspace でも ready を妨げない。 + if (watchExitCode == CommandExitCodes.Success + && !cancellationToken.IsCancellationRequested + && batcher.TryDrainImmediately( + out var startupBatch, + out var startupFullRescan, + out var startupRecoveryScanReason, + out var startupOverflowReason)) + { + if (startupFullRescan) + { + EmitWatchOverflow( + baseOptions, + jsonOptions, + startupOverflowReason, + resolvedDbPath, + phase: "startup", + backend: backendName, + recoveryReason: startupRecoveryScanReason); + RecordSubRunExitCode( + ref watchExitCode, + recoveryScan?.Invoke("startup") + ?? RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken, phase: "startup")); + } + else if (startupBatch.Count > 0) + { + RecordSubRunExitCode( + ref watchExitCode, + RunPartialUpdate(baseOptions, jsonOptions, startupBatch, resolvedDbPath, cancellationToken, phase: "startup")); + } } } @@ -241,7 +304,16 @@ void Enqueue(string fullPath) && watchExitCode == CommandExitCodes.Success; if (ready) { - EmitWatchStarted(baseOptions, jsonOptions, projectRoot, resolvedDbPath, debounce, maxPendingPaths, ignoreCase); + EmitWatchStarted( + baseOptions, + jsonOptions, + projectRoot, + resolvedDbPath, + debounce, + maxPendingPaths, + ignoreCase, + backendName, + startupRecoveryReason); WatchReadyForTesting?.Invoke(Enqueue); } @@ -256,13 +328,27 @@ void Enqueue(string fullPath) break; } - if (!batcher.TryDrain(out var batch, out var fullRescan, out var overflowReason)) + if (!batcher.TryDrain( + out var batch, + out var fullRescan, + out var recoveryReason, + out var overflowReason)) continue; if (fullRescan) { - EmitWatchOverflow(baseOptions, jsonOptions, overflowReason, resolvedDbPath); - RecordSubRunExitCode(ref watchExitCode, RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken)); + EmitWatchOverflow( + baseOptions, + jsonOptions, + overflowReason, + resolvedDbPath, + phase: "incremental", + backend: backendName, + recoveryReason: recoveryReason); + RecordSubRunExitCode( + ref watchExitCode, + recoveryScan?.Invoke("incremental") + ?? RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken)); continue; } @@ -274,23 +360,17 @@ void Enqueue(string fullPath) } finally { - if (ancestorIgnoreWatchers != null) - { - foreach (var ancestorWatcher in ancestorIgnoreWatchers) - { - try { ancestorWatcher.EnableRaisingEvents = false; } catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) { } - ancestorWatcher.Dispose(); - } - } - if (watcher != null) - { - try { watcher.EnableRaisingEvents = false; } catch (Exception ex) when (ex is IOException or InvalidOperationException or ObjectDisposedException) { } - watcher.Dispose(); - } + backend?.Dispose(); } EmitWatchStopped(baseOptions, jsonOptions); return watchExitCode; } + private static bool IsRecoverableWatchBackendStartException(Exception exception) + => exception is IOException + or UnauthorizedAccessException + or InvalidOperationException + or ArgumentException + or NotSupportedException; } diff --git a/src/CodeIndex/Cli/JsonOutputContracts.cs b/src/CodeIndex/Cli/JsonOutputContracts.cs index 70ab39551..8630853da 100644 --- a/src/CodeIndex/Cli/JsonOutputContracts.cs +++ b/src/CodeIndex/Cli/JsonOutputContracts.cs @@ -753,6 +753,8 @@ internal sealed class IndexWatchEventJsonResult : IVersionedJsonResult public int? Errors { get; init; } public string? SubRunParseStatus { get; init; } public string? SubRunParseReason { get; init; } + public string? Backend { get; init; } + public string? RecoveryReason { get; init; } public string? OverflowReason { get; init; } public IndexWatchRecoveryCommandJsonResult? RecoveryCommand { get; init; } public string? Reason { get; init; } diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs index 427d10b45..9f1097c52 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs @@ -68,6 +68,8 @@ public void BuildWatchContract_ReportsStableWatchSemantics_Issue4169() Assert.Equal("old_and_new_paths", contract.RenameEvents); Assert.Equal("full_rescan_after_debounce", contract.OverflowRecovery); Assert.Equal("full_rescan_after_debounce", contract.WatcherErrorRecovery); + Assert.Equal("single_after_backend_start", contract.BaselineScan); + Assert.Equal("retry_before_baseline", contract.BackendStartRecovery); Assert.Equal("cancel_active_sub_run_then_emit_stopped", contract.Cancellation); Assert.Equal("json_quiet_sub_runs", contract.SubRunOutput); Assert.Equal("unsupported", contract.McpWatchMode); @@ -88,6 +90,8 @@ public void IndexWatchStartedJsonResult_SerializesWatchContract_Issue4169() Db = "[redacted]", DebounceMs = 50, WatchPendingPathLimit = 123, + Backend = "fsevents", + RecoveryReason = "backend_start_failed", WatchContract = IndexWatchRunner.BuildWatchContractForTesting( TimeSpan.FromMilliseconds(50), maxPendingPaths: 123, @@ -106,6 +110,8 @@ public void IndexWatchStartedJsonResult_SerializesWatchContract_Issue4169() Assert.Equal("[redacted]", watchStarted.RootElement.GetProperty("db").GetString()); Assert.Equal(50, watchStarted.RootElement.GetProperty("debounce_ms").GetInt32()); Assert.Equal(123, watchStarted.RootElement.GetProperty("watch_pending_path_limit").GetInt32()); + Assert.Equal("fsevents", watchStarted.RootElement.GetProperty("backend").GetString()); + Assert.Equal("backend_start_failed", watchStarted.RootElement.GetProperty("recovery_reason").GetString()); var contract = watchStarted.RootElement.GetProperty("watch_contract"); Assert.Equal("quiet_window", contract.GetProperty("debounce").GetString()); @@ -118,6 +124,8 @@ public void IndexWatchStartedJsonResult_SerializesWatchContract_Issue4169() Assert.Equal("old_and_new_paths", contract.GetProperty("rename_events").GetString()); Assert.Equal("full_rescan_after_debounce", contract.GetProperty("overflow_recovery").GetString()); Assert.Equal("full_rescan_after_debounce", contract.GetProperty("watcher_error_recovery").GetString()); + Assert.Equal("single_after_backend_start", contract.GetProperty("baseline_scan").GetString()); + Assert.Equal("retry_before_baseline", contract.GetProperty("backend_start_recovery").GetString()); Assert.Equal("cancel_active_sub_run_then_emit_stopped", contract.GetProperty("cancellation").GetString()); Assert.Equal("json_quiet_sub_runs", contract.GetProperty("sub_run_output").GetString()); Assert.Equal("unsupported", contract.GetProperty("mcp_watch_mode").GetString()); diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index ef85483a0..0a5a92121 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -914,7 +914,20 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() SymlinkPolicy = FileIndexer.SymlinkPolicy.All, SymbolKindFilter = SymbolKindFilter.Create(["class", "function"], ["test.method"], parseError: null), }; - var method = typeof(IndexWatchRunner).GetMethod("EmitWatchOverflow", BindingFlags.NonPublic | BindingFlags.Static); + var method = typeof(IndexWatchRunner).GetMethod( + "EmitWatchOverflow", + BindingFlags.NonPublic | BindingFlags.Static, + binder: null, + [ + typeof(IndexCommandOptions), + typeof(JsonSerializerOptions), + typeof(string), + typeof(string), + typeof(string), + typeof(string), + typeof(string), + ], + modifiers: null); Assert.NotNull(method); const string resolvedDbPath = "/custom-data/codeindex.db"; @@ -926,7 +939,17 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() Console.SetOut(stdout); try { - method.Invoke(null, [options, _jsonOptions, "buffer full", resolvedDbPath]); + method.Invoke( + null, + [ + options, + _jsonOptions, + "buffer full", + resolvedDbPath, + "incremental", + "fsevents", + "event_stream_overflow", + ]); } finally { @@ -939,6 +962,8 @@ public void EmitWatchOverflow_Json_EmitsStructuredRecoveryCommand() Assert.Equal(JsonOutputContract.ApiVersion, doc.RootElement.GetProperty("api_version").GetString()); Assert.Equal("overflow", doc.RootElement.GetProperty("status").GetString()); Assert.Equal("incremental", doc.RootElement.GetProperty("phase").GetString()); + Assert.Equal("fsevents", doc.RootElement.GetProperty("backend").GetString()); + Assert.Equal("event_stream_overflow", doc.RootElement.GetProperty("recovery_reason").GetString()); Assert.Equal("buffer full", doc.RootElement.GetProperty("overflow_reason").GetString()); Assert.Equal(1234, doc.RootElement.GetProperty("watch_pending_path_limit").GetInt32()); var recovery = doc.RootElement.GetProperty("recovery_command"); @@ -1215,6 +1240,249 @@ public async Task WaitForWatchSignalAsync_DistinguishesEarlyExitAndObservationTi pendingRun.TrySetResult(CommandExitCodes.Success); } + [Fact] + public async Task IndexCommandRun_WatchSuccessfulStartup_PerformsExactlyOneBaseline_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var ready = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var baselineScans = 0; + Task? runTask = null; + try + { + File.WriteAllText(Path.Combine(projectRoot, "baseline.py"), "print('baseline')\n"); + IndexCommandRunner.FullScanWritePhaseStartedForTesting = () => Interlocked.Increment(ref baselineScans); + IndexWatchRunner.WatchReadyForTesting = _ => + { + ready.TrySetResult(); + cts.Cancel(); + }; + + runTask = StartTopLevelWatch( + [projectRoot, "--watch", "--quiet", "--db", dbPath], + cts); + + await WaitForWatchSignalAsync(ready.Task, runTask, "single-baseline watch readiness"); + var exitCode = await runTask.WaitAsync(TopLevelWatchTimeout); + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, baselineScans); + } + finally + { + cts.Cancel(); + IndexCommandRunner.FullScanWritePhaseStartedForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + await WaitForTopLevelWatchCleanupAsync(runTask); + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunCore_BackendStartFallback_ReusesSingleBaseline_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var firstBackend = new FakeWatchBackend( + "fsevents", + new IOException("simulated EventStream start failure")); + var fallbackBackend = new FakeWatchBackend("fsevents"); + var backends = new Queue([firstBackend, fallbackBackend]); + var baselineScans = 0; + var recoveryScans = 0; + try + { + var options = CreateIssue4858WatchOptions(projectRoot, dbPath); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backends.Dequeue(); + IndexWatchRunner.WatchReadyForTesting = _ => cts.Cancel(); + + var capturedOut = RunWatchCoreAndCapture( + options, + projectRoot, + dbPath, + cts, + baselineScan: () => + { + baselineScans++; + return CommandExitCodes.Success; + }, + recoveryScan: _ => + { + recoveryScans++; + return CommandExitCodes.Success; + }, + out var exitCode); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, baselineScans); + Assert.Equal(0, recoveryScans); + Assert.Equal(1, firstBackend.StartCount); + Assert.Equal(1, fallbackBackend.StartCount); + Assert.True(firstBackend.Disposed); + Assert.True(fallbackBackend.Disposed); + var fallbackEvent = FindWatchEvent(capturedOut, "backend_fallback"); + Assert.Equal("startup", fallbackEvent.GetProperty("phase").GetString()); + Assert.Equal("fsevents", fallbackEvent.GetProperty("backend").GetString()); + Assert.Equal("backend_start_failed", fallbackEvent.GetProperty("recovery_reason").GetString()); + var watchingEvent = FindWatchEvent(capturedOut, "watching"); + Assert.Equal("initial_scan", watchingEvent.GetProperty("phase").GetString()); + Assert.Equal("fsevents", watchingEvent.GetProperty("backend").GetString()); + Assert.Equal("backend_start_failed", watchingEvent.GetProperty("recovery_reason").GetString()); + } + finally + { + IndexWatchRunner.WatchBackendFactoryForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunCore_EventLossDuringBaseline_PerformsOneJustifiedRecovery_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var backend = new FakeWatchBackend("fsevents"); + var baselineScans = 0; + var recoveryScans = 0; + try + { + var options = CreateIssue4858WatchOptions(projectRoot, dbPath); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backend; + IndexWatchRunner.WatchReadyForTesting = _ => cts.Cancel(); + + var capturedOut = RunWatchCoreAndCapture( + options, + projectRoot, + dbPath, + cts, + baselineScan: () => + { + baselineScans++; + backend.ReportError(new InternalBufferOverflowException("simulated event loss")); + backend.ReportError(new InternalBufferOverflowException("duplicate simulated event loss")); + return CommandExitCodes.Success; + }, + recoveryScan: phase => + { + Assert.Equal("startup", phase); + recoveryScans++; + return CommandExitCodes.Success; + }, + out var exitCode); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, baselineScans); + Assert.Equal(1, recoveryScans); + var overflowEvent = FindWatchEvent(capturedOut, "overflow"); + Assert.Equal("startup", overflowEvent.GetProperty("phase").GetString()); + Assert.Equal("fsevents", overflowEvent.GetProperty("backend").GetString()); + Assert.Equal("event_stream_overflow", overflowEvent.GetProperty("recovery_reason").GetString()); + } + finally + { + IndexWatchRunner.WatchBackendFactoryForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunCore_BackendStartFailureAfterFallback_StopsBeforeBaseline_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var backends = new Queue( + [ + new FakeWatchBackend("fsevents", new IOException("first simulated start failure")), + new FakeWatchBackend("fsevents", new IOException("second simulated start failure")), + ]); + var baselineScans = 0; + try + { + var options = CreateIssue4858WatchOptions(projectRoot, dbPath); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backends.Dequeue(); + + var capturedOut = RunWatchCoreAndCapture( + options, + projectRoot, + dbPath, + cts, + baselineScan: () => + { + baselineScans++; + return CommandExitCodes.Success; + }, + recoveryScan: _ => CommandExitCodes.Success, + out var exitCode); + + Assert.Equal(CommandExitCodes.RuntimeError, exitCode); + Assert.Equal(0, baselineScans); + var failureEvent = FindWatchEvent(capturedOut, "failed"); + Assert.Equal("startup", failureEvent.GetProperty("phase").GetString()); + Assert.Equal("fsevents", failureEvent.GetProperty("backend").GetString()); + Assert.Equal("backend_start_failed", failureEvent.GetProperty("recovery_reason").GetString()); + Assert.DoesNotContain("\"status\":\"watching\"", capturedOut, StringComparison.Ordinal); + Assert.Contains("\"status\":\"stopped\"", capturedOut, StringComparison.Ordinal); + } + finally + { + IndexWatchRunner.WatchBackendFactoryForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunCore_MacOsBackend_StartsBeforeSingleBaseline_Issue4858() + { + if (!OperatingSystem.IsMacOS()) + return; + + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var baselineScans = 0; + var recoveryScans = 0; + try + { + var options = CreateIssue4858WatchOptions(projectRoot, dbPath); + IndexWatchRunner.WatchReadyForTesting = _ => cts.Cancel(); + + var capturedOut = RunWatchCoreAndCapture( + options, + projectRoot, + dbPath, + cts, + baselineScan: () => + { + baselineScans++; + return CommandExitCodes.Success; + }, + recoveryScan: _ => + { + recoveryScans++; + return CommandExitCodes.Success; + }, + out var exitCode); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, baselineScans); + Assert.InRange(recoveryScans, 0, 1); + Assert.Contains("\"status\":\"watching\"", capturedOut, StringComparison.Ordinal); + Assert.Contains("\"backend\":\"fsevents\"", capturedOut, StringComparison.Ordinal); + } + finally + { + IndexWatchRunner.WatchBackendFactoryForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + DeleteDirectory(projectRoot); + } + } + [Fact] public void RunCore_StartupHandoff_ReconcilesMutationAndDrainsBeforeReady_Issue4594() { @@ -1466,6 +1734,8 @@ public void RunCore_EmitsHumanFriendlyStartStop_WhenJsonDisabled() Assert.Equal(CommandExitCodes.Success, exitCode); Assert.Contains("[watch] Watching", capturedErr); + Assert.Contains("backend ", capturedErr); + Assert.Contains("recovery none", capturedErr); Assert.Contains("debounce 50 ms", capturedErr); Assert.Contains("pending path limit 123", capturedErr); Assert.Contains("[watch] Stopped.", capturedErr); @@ -1680,6 +1950,71 @@ private static bool HasIndexedSymbol(string dbPath, string symbolName) private static string CreateTempProject() => TestProjectHelper.CreateTempProject("cdidx_watch_runner"); + private static IndexCommandOptions CreateIssue4858WatchOptions(string projectRoot, string dbPath) + => new() + { + ProjectPath = projectRoot, + DbPath = dbPath, + Json = true, + Quiet = true, + Watch = true, + WatchDebounceMs = 0, + }; + + private string RunWatchCoreAndCapture( + IndexCommandOptions options, + string projectRoot, + string dbPath, + CancellationTokenSource cts, + Func baselineScan, + Func recoveryScan, + out int exitCode) + { + lock (TestConsoleLock.Gate) + { + var originalOut = Console.Out; + using var stdout = new StringWriter(); + Task? loopTask = null; + Console.SetOut(stdout); + try + { + loopTask = IndexWatchRunner.RunCoreAsync( + options, + _jsonOptions, + projectRoot, + dbPath, + cts.Token, + baselineScan: baselineScan, + recoveryScan: recoveryScan); +#pragma warning disable xUnit1031 // Console redirection lock requires synchronous bounded drain. + exitCode = loopTask.WaitAsync(TimeSpan.FromSeconds(10)).GetAwaiter().GetResult(); +#pragma warning restore xUnit1031 + return stdout.ToString(); + } + finally + { + CancelAndDrainWatchLoop(cts, loopTask); + Console.SetOut(originalOut); + } + } + } + + private static JsonElement FindWatchEvent(string output, string status) + { + foreach (var line in output.Split(['\r', '\n'], StringSplitOptions.RemoveEmptyEntries)) + { + using var document = JsonDocument.Parse(line); + if (document.RootElement.TryGetProperty("status", out var statusElement) + && string.Equals(statusElement.GetString(), status, StringComparison.Ordinal)) + { + return document.RootElement.Clone(); + } + } + + Assert.Fail($"Watch event '{status}' was not found. Output: {output}"); + return default; + } + private static void DeleteDirectory(string path) => TestProjectHelper.DeleteDirectory(path); @@ -1745,4 +2080,41 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } } + + private sealed class FakeWatchBackend : IndexWatchRunner.IWatchBackend + { + private readonly Exception? _startException; + private Action? _reportError; + + internal FakeWatchBackend(string name, Exception? startException = null) + { + Name = name; + _startException = startException; + } + + public string Name { get; } + + internal int StartCount { get; private set; } + + internal bool Disposed { get; private set; } + + public void Start(Action enqueue, Action reportError) + { + StartCount++; + _reportError = reportError; + if (_startException != null) + throw _startException; + } + + internal void ReportError(Exception exception) + { + Assert.NotNull(_reportError); + _reportError(exception); + } + + public void Dispose() + { + Disposed = true; + } + } } From 5abc000a60f678d6f631f701155a419103486e35 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 17:36:42 +0900 Subject: [PATCH 2/4] Address watch backend review findings (#4858) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 8 +- changelog.d/unreleased/4858.fixed.md | 4 +- src/CodeIndex/Cli/ConsoleUi.Help.cs | 2 +- .../Cli/IndexWatchRunner.Reporting.cs | 9 +- .../Cli/IndexWatchRunner.Watchers.cs | 234 +++++++++++++++++- src/CodeIndex/Cli/IndexWatchRunner.cs | 212 +++++++++++++--- .../IndexWatchRunnerIssue4169Tests.cs | 4 +- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 152 +++++++++++- 9 files changed, 566 insertions(+), 63 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index ac37cbd19..3634a2164 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -385,7 +385,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keep scan, `status --check`, and watch on one `.cdidx` membership policy. Ignore/unignore changes and pattern-config add/edit/delete events must reach a debounced full scan, while pattern/plugin inputs and ordinary `.cdidx` sidecars remain outside indexed source rows. Pattern extension edits must purge the old extension and index the new one in the same process. The registry refresh test also removes a loaded workspace plugin and verifies that its collectible context is unloaded while a host-registered fallback survives. Test the classification boundary directly instead of waiting for platform-specific `FileSystemWatcher` delivery. - `IndexWatchRunnerTests.RunCore_StartupHandoff_ReconcilesMutationAndDrainsBeforeReady_Issue4594` mutates an already indexed symbol through the post-subscription startup hook, explicitly enqueues that event, and verifies both final database content and `rescanned` / `updated` ordering before `watching`. Keep the injected enqueue: relying only on platform watcher timing would make the handoff regression nondeterministic. -- The issue-4858 watch startup fixtures include a top-level command test that counts the real full-scan write phase, plus focused tests that inject `IWatchBackend` implementations and explicit baseline/recovery delegates. They require backend-start retry to finish before exactly one baseline, collapse repeated event-loss callbacks into one recovery generation, stop before the baseline when both starts fail, and cancel only from the ready callback. Keep the machine-readable `backend` / `recovery_reason` assertions and avoid wall-clock sleeps. The guarded macOS case uses the real FSEvents-backed watcher and should remain a platform probe rather than being emulated on Linux or Windows. +- The issue-4858 watch startup fixtures include a top-level command test that counts the real full-scan write phase, plus focused tests that inject `IWatchBackend` implementations and explicit baseline/recovery delegates. They require asynchronous backend-start failure to switch from FSEvents to polling around exactly one baseline, ignore late callbacks from the disposed generation, collapse repeated event-loss callbacks into one recovery generation, stop before the baseline when both starts fail, and cancel only from the ready callback. Keep the machine-readable `backend` / `recovery_reason` assertions and avoid wall-clock sleeps. The guarded macOS case uses the real backend selection and accepts either FSEvents or its operational polling fallback. - `BackgroundTaskObserverTests` relies on `BackgroundTaskObserver`'s fault-only continuation contract: canceled tasks are awaited directly and do not need a post-cancellation fixed sleep to @@ -1291,7 +1291,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" scan、`status --check`、watch が単一の `.cdidx` membership policy を使うことを固定する。ignore / unignore 変更と pattern-config の追加 / 編集 / 削除は debounce 付き full scan に到達し、pattern / plugin 入力と通常の `.cdidx` sidecar は indexed source row から除外されたままでなければならない。pattern extension の編集では同一 process 内で旧 extension を purge して新 extension を index する。registry refresh test は読み込み済み workspace plugin を削除し、host 登録 fallback を維持しながら collectible context が unload されることも検証する。platform 固有の `FileSystemWatcher` 配信を待たず、classification 境界を直接検証する。 - `IndexWatchRunnerTests.RunCore_StartupHandoff_ReconcilesMutationAndDrainsBeforeReady_Issue4594` subscribe 後の startup hook から index 済み symbol を変更して event を明示的に enqueue し、最終 database content と `watching` より前の `rescanned` / `updated` 順序を検証する。platform watcher の timing だけに依存すると handoff 回帰が非決定的になるため、注入した enqueue を維持すること。 -- issue-4858 の watch startup fixture には、実際の full-scan write phase を数える top-level command test と、`IWatchBackend` 実装および明示的な baseline / recovery delegate を注入する focused test がある。backend 起動 retry が baseline 1 回より前に完了すること、複数の event-loss callback が recovery generation 1 回へ集約されること、2 回とも起動に失敗した場合は baseline 前に停止すること、ready callback からのみ cancel することを固定する。機械可読な `backend` / `recovery_reason` assertion を維持し、wall-clock sleep は使わないこと。macOS guard 付き case は実際の FSEvents-backed watcher を使う platform probe とし、Linux / Windows 上で模倣しない。 +- issue-4858 の watch startup fixture には、実際の full-scan write phase を数える top-level command test と、`IWatchBackend` 実装および明示的な baseline / recovery delegate を注入する focused test がある。非同期 backend 起動失敗が baseline 1 回の前後で FSEvents から polling へ切り替わること、dispose 済み generation から遅れて届く callback を無視すること、複数の event-loss callback が recovery generation 1 回へ集約されること、2 回とも起動に失敗した場合は baseline 前に停止すること、ready callback からのみ cancel することを固定する。機械可読な `backend` / `recovery_reason` assertion を維持し、wall-clock sleep は使わないこと。macOS guard 付き case は実際の backend 選択を使い、FSEvents と実運用 polling fallback のどちらも許容する。 - `BackgroundTaskObserverTests` は `BackgroundTaskObserver` の fault-only continuation 契約に依存します。canceled task は直接 await し、warning が抑止されたことを示すための cancellation 後の固定 sleep diff --git a/USER_GUIDE.md b/USER_GUIDE.md index a6e67fee6..49f332566 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1087,7 +1087,7 @@ small repositories, and minutes or longer on very large monorepos with around By default, `cdidx index` stores the database in `/.cdidx/codeindex.db`, even if you run the command from another directory. -`--watch` starts `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows) before the one required baseline scan, then keeps the process alive and rebuilds the index incrementally as files are created, edited, renamed, or deleted. A recoverable backend-start failure is retried before that baseline, so starting the replacement backend does not repeat a valid scan. Events buffered during the baseline are drained before `watching`; genuine event loss after the backend is active is coalesced into at most one justified full incremental recovery scan per generation. Bursts of ordinary events are debounced (`--debounce `, default 500 ms) into a single `--files` update, the per-DB index lock is released between batches so other `cdidx` commands can still query, and a pending path batch that reaches its safety cap also requests one recovery scan. Subdirectory watches monitor ancestor `.gitignore` / `.cdidxignore` files along the repository path. The baseline and startup reconciliation must succeed before `watching` is emitted; a failed startup generation exits instead of declaring a stale watcher ready. With `--json` it streams `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout; startup and recovery events expose `backend` and machine-readable `recovery_reason`, while update/rescan events include `exit_code`. Human output includes the same backend/recovery context in `[watch] …` summaries. Stop the loop with Ctrl+C (or SIGTERM); the first Ctrl+C requests cooperative cancellation and a second Ctrl+C remains available for force exit. The final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. +`--watch` starts `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows) before the one required baseline scan, then keeps the process alive and rebuilds the index incrementally as files are created, edited, renamed, or deleted. A recoverable macOS EventStream startup failure switches to a polling backend without repeating a valid baseline. If the failure arrives while the baseline is running, the valid baseline is retained and one recovery scan reconciles the backend handoff; stale callbacks from the replaced backend are ignored. Events buffered during the baseline are drained before `watching`; genuine event loss after the backend is active is coalesced into at most one justified full incremental recovery scan per generation. Bursts of ordinary events are debounced (`--debounce `, default 500 ms) into a single `--files` update, the per-DB index lock is released between batches so other `cdidx` commands can still query, and a pending path batch that reaches its safety cap also requests one recovery scan. Subdirectory watches monitor ancestor `.gitignore` / `.cdidxignore` files along the repository path. The baseline and startup reconciliation must succeed before `watching` is emitted; a failed startup generation exits instead of declaring a stale watcher ready. With `--json` it streams `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout; startup and recovery events expose `backend` (`fsevents` or `polling` on macOS) and machine-readable `recovery_reason`, while update/rescan events include `exit_code`. Human output includes the same backend/recovery context in `[watch] …` summaries. Stop the loop with Ctrl+C (or SIGTERM); the first Ctrl+C requests cooperative cancellation and a second Ctrl+C remains available for force exit. The final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. Indexing scope and ignore handling: @@ -2034,7 +2034,7 @@ same source location. | `--max-symbols-per-file ` | `index` | Skip file content, symbols, and references when one file emits too many symbols. Defaults to `5000`; values above `50000` are rejected. | | `--symbols-only` | `index` | Full-scan only. Build chunks, symbols, and issues while skipping reference extraction and graph finalization for a faster first pass. `search`, `definition`, `symbols`, and `map` are available; reference graph commands remain degraded until a normal `cdidx index ` run. | | `--parallelism ` | `index` | Set full-scan extraction worker count. Defaults to CPU count capped at 8, or `CDIDX_INDEX_PARALLELISM` when set; explicit values are capped at 16. SQLite writes stay single-consumer. | -| `--watch` | `index` | Start the filesystem backend before one baseline scan, then stay running and reindex incrementally as files change (FileSystemWatcher / inotify / FSEvents). A recoverable backend-start failure is retried before the baseline; buffered startup events are drained before `watching`, and event loss after activation triggers at most one recovery scan per generation. JSON startup/recovery events expose `backend` and `recovery_reason`. Changes to `.gitignore`, `.cdidxignore`, `.cdidx/patterns/**`, or `.cdidx/plugins/**` trigger a debounced full-workspace reconciliation; pattern/plugin additions, edits, and removals refresh the in-process extractor registry before that scan. The `.cdidx` namespace itself is excluded from source membership consistently with full scan and `status --check`, including ordinary sidecars. Ctrl-C cancels an active indexing sub-run before the loop emits its stopped event. Sub-run stdout is captured through a runner-scoped writer, so an embedded or concurrent command keeps its own process stdout. Rejects `--commits`, `--changed-between`, `--files`, and `--dry-run` because the loop already drives continuous incremental updates. | +| `--watch` | `index` | Start the filesystem backend before one baseline scan, then stay running and reindex incrementally as files change (FileSystemWatcher / inotify / FSEvents). A recoverable macOS EventStream startup failure switches to polling without repeating the baseline; a failure during the baseline retains it and requests one recovery scan for the handoff. Buffered startup events are drained before `watching`, stale backend callbacks are ignored, and event loss after activation triggers at most one recovery scan per generation. JSON startup/recovery events expose `backend` and `recovery_reason`. Changes to `.gitignore`, `.cdidxignore`, `.cdidx/patterns/**`, or `.cdidx/plugins/**` trigger a debounced full-workspace reconciliation; pattern/plugin additions, edits, and removals refresh the in-process extractor registry before that scan. The `.cdidx` namespace itself is excluded from source membership consistently with full scan and `status --check`, including ordinary sidecars. Ctrl-C cancels an active indexing sub-run before the loop emits its stopped event. Sub-run stdout is captured through a runner-scoped writer, so an embedded or concurrent command keeps its own process stdout. Rejects `--commits`, `--changed-between`, `--files`, and `--dry-run` because the loop already drives continuous incremental updates. | | `--debounce ` | `index` (watch only) | Coalesce bursts of file events into a single update after `` of quiet (non-negative integer; default: 500). Invalid values emit a warning and are ignored. | | `--watch-pending-path-limit ` | `index` (watch only) | Set the number of distinct changed paths the watch loop will queue before it reports an overflow and falls back to a full rescan. Defaults to `4096`, honors `CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` and `indexing.watchPendingPathLimit`, and rejects values above `262144`. The `watching` and `overflow` JSON events include `watch_pending_path_limit`. | | `--since ` | `search`, `definition`, `symbols`, `files` | Filter to files modified since this ISO 8601 timestamp. Offsetless values (e.g. `2024-01-01T00:00:00`) are treated as UTC so the same flag resolves to the same instant in every timezone; append `Z` or an explicit offset (`+09:00`) to be explicit. | @@ -4287,7 +4287,7 @@ interactive terminal では spinner と progress bar が動き続けます。待 `cdidx index` は、別ディレクトリから実行しても、デフォルトでは `/.cdidx/codeindex.db` にDBを保存します。 -`--watch` は必要な baseline scan 1 回より先に `FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)を開始し、その後もプロセスを残してファイルの作成・編集・リネーム・削除を差分反映します。回復可能な backend 起動失敗は baseline より前に retry するため、代替 backend の開始だけを理由に有効な scan を繰り返しません。baseline 中に buffer された event は `watching` の前に drain し、backend 有効化後の本当の event loss だけを generation ごとに最大 1 回の根拠付きフル差分 recovery scan へ集約します。通常 event は `--debounce `(既定 500 ms)の窓で 1 つの `--files` 更新にまとめ、batch 間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。pending path batch が安全上限に達した場合も recovery scan を 1 回要求します。subdirectory の watch は repository path 上の ancestor `.gitignore` / `.cdidxignore` も監視します。baseline と startup reconciliation が成功するまで `watching` は出力せず、startup generation が失敗した場合は stale なまま ready を宣言せず終了します。`--json` 時は `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流します。startup / recovery event は `backend` と機械可読な `recovery_reason` を公開し、update/rescan event は `exit_code` を含みます。human 出力も `[watch] …` 要約に同じ backend / recovery context を含めます。最初の Ctrl+C(または SIGTERM)は協調的な cancellation を要求し、2 回目の Ctrl+C は強制終了に利用できます。すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 +`--watch` は必要な baseline scan 1 回より先に `FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)を開始し、その後もプロセスを残してファイルの作成・編集・リネーム・削除を差分反映します。回復可能な macOS EventStream 起動失敗は polling backend へ切り替え、有効な baseline を繰り返しません。失敗通知が baseline 実行中に届いた場合もその baseline を保持し、backend handoff のための recovery scan を1回だけ実行します。置換済み backend から遅れて届いた callback は無視します。baseline 中に buffer された event は `watching` の前に drain し、backend 有効化後の本当の event loss だけを generation ごとに最大 1 回の根拠付きフル差分 recovery scan へ集約します。通常 event は `--debounce `(既定 500 ms)の窓で 1 つの `--files` 更新にまとめ、batch 間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。pending path batch が安全上限に達した場合も recovery scan を 1 回要求します。subdirectory の watch は repository path 上の ancestor `.gitignore` / `.cdidxignore` も監視します。baseline と startup reconciliation が成功するまで `watching` は出力せず、startup generation が失敗した場合は stale なまま ready を宣言せず終了します。`--json` 時は `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流します。startup / recovery event は `backend`(macOS では `fsevents` または `polling`)と機械可読な `recovery_reason` を公開し、update/rescan event は `exit_code` を含みます。human 出力も `[watch] …` 要約に同じ backend / recovery context を含めます。最初の Ctrl+C(または SIGTERM)は協調的な cancellation を要求し、2 回目の Ctrl+C は強制終了に利用できます。すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 デフォルト出力: @@ -5194,7 +5194,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--max-file-bytes ` | `index` | この実行で使うファイル単位の索引サイズ上限を上書きする。既定は 4MiB、または `CDIDX_MAX_FILE_BYTES` 設定値。値は raw byte 数、または `50M` のような `K` / `M` / `G` 接尾辞を受け付ける。 | | `--symbols-only` | `index` | フルスキャン専用。参照抽出と graph finalization を省き、chunks、symbols、issues だけを作ることで初回利用を速くする。`search`、`definition`、`symbols`、`map` は使えるが、reference graph 系コマンドは通常の `cdidx index ` を実行するまで degraded のまま。 | | `--parallelism ` | `index` | フルスキャンの抽出 worker 数を指定する。既定は CPU 数を最大 8 に丸めた値、または `CDIDX_INDEX_PARALLELISM` 設定値。明示値は最大 16。SQLite 書き込みは単一 consumer のまま。 | -| `--watch` | `index` | filesystem backend(FileSystemWatcher / inotify / FSEvents)を baseline scan 1 回より先に開始し、その後もプロセスを残してファイル変更を差分反映する。回復可能な backend 起動失敗は baseline 前に retry し、startup 中に buffer した event は `watching` 前に drain する。backend 有効化後の event loss は generation ごとに最大 1 回の recovery scan を起動する。JSON の startup / recovery event は `backend` と `recovery_reason` を公開する。`.gitignore`、`.cdidxignore`、`.cdidx/patterns/**`、`.cdidx/plugins/**` の変更は debounce 後に workspace 全体を reconciliation し、pattern / plugin の追加・編集・削除では scan 前に process 内 extractor registry も refresh する。通常 sidecar を含む `.cdidx` namespace 自体は full scan と `status --check` と同様に source membership から除外する。Ctrl-C は実行中の indexing sub-run をキャンセルしてから stopped event を出力する。sub-run の stdout は runner scope の writer で capture するため、埋め込み先や同時実行 command の process stdout を置き換えない。連続的な差分更新を内蔵しているため `--commits` / `--changed-between` / `--files` / `--dry-run` との併用は拒否する。 | +| `--watch` | `index` | filesystem backend(FileSystemWatcher / inotify / FSEvents)を baseline scan 1 回より先に開始し、その後もプロセスを残してファイル変更を差分反映する。回復可能な macOS EventStream 起動失敗は baseline を繰り返さず polling へ切り替え、baseline 中の失敗なら handoff recovery scan を1回だけ実行する。startup 中に buffer した event は `watching` 前に drain し、置換済み backend の callback は無視する。backend 有効化後の event loss は generation ごとに最大 1 回の recovery scan を起動する。JSON の startup / recovery event は `backend` と `recovery_reason` を公開する。`.gitignore`、`.cdidxignore`、`.cdidx/patterns/**`、`.cdidx/plugins/**` の変更は debounce 後に workspace 全体を reconciliation し、pattern / plugin の追加・編集・削除では scan 前に process 内 extractor registry も refresh する。通常 sidecar を含む `.cdidx` namespace 自体は full scan と `status --check` と同様に source membership から除外する。Ctrl-C は実行中の indexing sub-run をキャンセルしてから stopped event を出力する。sub-run の stdout は runner scope の writer で capture するため、埋め込み先や同時実行 command の process stdout を置き換えない。連続的な差分更新を内蔵しているため `--commits` / `--changed-between` / `--files` / `--dry-run` との併用は拒否する。 | | `--debounce ` | `index`(`--watch` 専用) | 一連のイベントを `` の静止後に 1 つの更新へ集約する(0 以上の整数。既定: 500)。不正な値は警告を出して無視する。 | | `--watch-pending-path-limit ` | `index`(`--watch` 専用) | watch loop が overflow を報告して full rescan へ fallback する前に保持する distinct changed path 数を設定する。既定は `4096` で、`CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` と `indexing.watchPendingPathLimit` も使える。`262144` を超える値は拒否される。`watching` と `overflow` の JSON event には `watch_pending_path_limit` が入る。 | | `--since ` | `search`, `definition`, `symbols`, `files` | 指定タイムスタンプ以降に変更されたファイルのみ(ISO 8601)。オフセットなしの値(例: `2024-01-01T00:00:00`)は UTC として解釈されるため、どのタイムゾーンから呼び出しても同じ UTC 時点になります。明示したい場合は末尾に `Z` または `+09:00` 等のオフセットを付与してください。 | diff --git a/changelog.d/unreleased/4858.fixed.md b/changelog.d/unreleased/4858.fixed.md index 1f54fad02..ad34cdcd4 100644 --- a/changelog.d/unreleased/4858.fixed.md +++ b/changelog.d/unreleased/4858.fixed.md @@ -19,8 +19,8 @@ affected: ## English -- **macOS watch startup now performs one baseline scan across recoverable backend fallback (#4858)** — the watch backend starts before the required baseline, retries startup failures before that baseline, and performs one recovery scan only when event-stream uncertainty requires it. Watch diagnostics now report the backend and recovery reason. +- **macOS watch startup now performs one baseline scan across recoverable backend fallback (#4858)** — the watch backend starts before the required baseline and falls back from FSEvents to polling without repeating a valid baseline. A failure during that baseline retains it and performs one recovery scan for the handoff, while stale callbacks from the replaced backend are ignored. Watch diagnostics now report the backend and recovery reason. ## 日本語 -- **macOS の watch 起動時に、復旧可能なバックエンドフォールバックをまたいでもベースラインスキャンを1回だけ実行するようになりました (#4858)** — 必須のベースラインより先に watch バックエンドを起動し、起動失敗はベースライン前に再試行します。また、イベントストリームの不確実性により必要な場合のみリカバリスキャンを1回実行します。watch 診断にはバックエンドとリカバリ理由も表示されます。 +- **macOS の watch 起動時に、復旧可能なバックエンドフォールバックをまたいでもベースラインスキャンを1回だけ実行するようになりました (#4858)** — 必須のベースラインより先に watch バックエンドを起動し、有効なベースラインを繰り返さず FSEvents から polling へフォールバックします。ベースライン中の失敗ではその結果を保持して handoff 用のリカバリスキャンを1回実行し、置換済みバックエンドから遅れて届く callback は無視します。watch 診断にはバックエンドとリカバリ理由も表示されます。 diff --git a/src/CodeIndex/Cli/ConsoleUi.Help.cs b/src/CodeIndex/Cli/ConsoleUi.Help.cs index 0a89a2d34..0fd3b6773 100644 --- a/src/CodeIndex/Cli/ConsoleUi.Help.cs +++ b/src/CodeIndex/Cli/ConsoleUi.Help.cs @@ -182,7 +182,7 @@ private static void PrintFlagReference(Action WriteHelpLine) Console.WriteLine(" --changed-between "); Console.WriteLine(" Update only files changed between two git refs (useful after branch switches)"); Console.WriteLine(" --files [path ...] Update only the specified files; old rename/delete paths are not purged unless also listed"); - WriteHelpLine(" --watch Start the filesystem backend before one baseline scan, then stay running and reindex changes; backend-start retry happens before the baseline, while event loss triggers one recovery rescan; rejects --commits / --changed-between / --files / --dry-run"); + WriteHelpLine(" --watch Start the filesystem backend before one baseline scan, then stay running and reindex changes; macOS falls back from FSEvents to polling without repeating the baseline, while an uncertainty gap triggers one recovery rescan; rejects --commits / --changed-between / --files / --dry-run"); Console.WriteLine($" --debounce Watch only: coalesce bursts of file events into one update after of quiet (default: {IndexWatchRunner.DefaultDebounceMs}, max {IndexWatchRunner.MaxDebounceMs})"); WriteHelpLine($" --watch-pending-path-limit Watch only: pending changed-path queue limit before falling back to a full rescan (default: {IndexWatchRunner.DefaultWatchPendingPathLimit}, max: {IndexWatchRunner.MaxWatchPendingPathLimit}; also honors {IndexCommandRunner.WatchPendingPathLimitEnvironmentVariable})"); Console.WriteLine(" --optimize index only: optimize the existing FTS5 table for this project's DB without scanning files"); diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs index 66bda6894..ae10be419 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs @@ -225,7 +225,7 @@ private static IndexWatchContractJsonResult BuildWatchContract( OverflowRecovery = "full_rescan_after_debounce", WatcherErrorRecovery = "full_rescan_after_debounce", BaselineScan = "single_after_backend_start", - BackendStartRecovery = "retry_before_baseline", + BackendStartRecovery = "fallback_preserve_baseline", Cancellation = "cancel_active_sub_run_then_emit_stopped", SubRunOutput = "json_quiet_sub_runs", McpWatchMode = "unsupported", @@ -236,7 +236,8 @@ private static void EmitWatchBackendFallback( JsonSerializerOptions jsonOptions, string? backend, string recoveryReason, - string? reason) + string? reason, + bool baselineCompleted = false) => EmitWatchBackendStartupEvent( baseOptions, jsonOptions, @@ -244,7 +245,9 @@ private static void EmitWatchBackendFallback( backend: backend, recoveryReason: recoveryReason, reason: reason, - humanAction: "retrying before the baseline scan"); + humanAction: baselineCompleted + ? "switching backend without repeating the baseline; one recovery scan will reconcile the handoff" + : "switching backend before the baseline scan"); private static void EmitWatchBackendFailure( IndexCommandOptions baseOptions, diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index 831161533..6a86cfd5f 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -15,17 +15,25 @@ internal interface IWatchBackend : IDisposable { string Name { get; } - void Start(Action enqueue, Action reportError); + Task StartAsync( + Action enqueue, + Action reportError, + CancellationToken cancellationToken); } internal static Func? WatchBackendFactoryForTesting { get; set; } + private static readonly TimeSpan BackendStartupStabilizationDelay = TimeSpan.FromMilliseconds(250); + private static readonly TimeSpan PollingWatchInterval = TimeSpan.FromSeconds(2); private static IWatchBackend CreateWatchBackend( string projectRoot, string ignoreRuleRoot, - bool ignoreCase) + bool ignoreCase, + int attempt) => WatchBackendFactoryForTesting?.Invoke(projectRoot, ignoreRuleRoot, ignoreCase) - ?? new FileSystemWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase); + ?? (attempt > 0 && OperatingSystem.IsMacOS() + ? new PollingWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase) + : new FileSystemWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase)); private static string ResolveWatchBackendName() => OperatingSystem.IsMacOS() @@ -297,9 +305,13 @@ internal FileSystemWatchBackend( public string Name => ResolveWatchBackendName(); - public void Start(Action enqueue, Action reportError) + public Task StartAsync( + Action enqueue, + Action reportError, + CancellationToken cancellationToken) { ObjectDisposedException.ThrowIf(_watcher != null, this); + cancellationToken.ThrowIfCancellationRequested(); var watcher = new FileSystemWatcher(_projectRoot) { @@ -328,6 +340,16 @@ public void Start(Action enqueue, Action reportError) _ignoreRuleRoot, _ignoreCase, enqueue); + + // FileSystemWatcher can report a fatal EventStream startup error asynchronously + // just after EnableRaisingEvents succeeds. Keep startup provisional long enough for + // that callback to select the fallback before the baseline begins. + // EnableRaisingEvents 成功直後に fatal EventStream error が非同期通知される場合が + // あるため、この安定化区間までは startup を provisional として扱う。 + Task.Delay(BackendStartupStabilizationDelay, CancellationToken.None) + .GetAwaiter() + .GetResult(); + return Task.CompletedTask; } public void Dispose() @@ -350,4 +372,208 @@ public void Dispose() } } } + + private sealed class PollingWatchBackend : IWatchBackend + { + private readonly record struct FileStamp( + long Length, + long LastWriteUtcTicks, + long CreationUtcTicks); + + private readonly string _projectRoot; + private readonly string _ignoreRuleRoot; + private readonly bool _ignoreCase; + private readonly StringComparer _pathComparer; + private CancellationTokenSource? _loopCancellation; + private Task? _loopTask; + private Dictionary? _snapshot; + private Action? _enqueue; + private Action? _reportError; + private bool _started; + + internal PollingWatchBackend( + string projectRoot, + string ignoreRuleRoot, + bool ignoreCase) + { + _projectRoot = Path.GetFullPath(projectRoot); + _ignoreRuleRoot = Path.GetFullPath(ignoreRuleRoot); + _ignoreCase = ignoreCase; + _pathComparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + } + + public string Name => "polling"; + + public Task StartAsync( + Action enqueue, + Action reportError, + CancellationToken cancellationToken) + { + ObjectDisposedException.ThrowIf(_started, this); + cancellationToken.ThrowIfCancellationRequested(); + + _snapshot = CaptureSnapshot(cancellationToken); + _enqueue = enqueue; + _reportError = reportError; + _loopCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + _loopTask = Task.Run( + () => RunLoopAsync(_loopCancellation.Token), + CancellationToken.None); + _started = true; + return Task.CompletedTask; + } + + private async Task RunLoopAsync(CancellationToken cancellationToken) + { + try + { + while (true) + { + await Task.Delay(PollingWatchInterval, cancellationToken).ConfigureAwait(false); + PollOnce(cancellationToken); + } + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) when (CodeIndex.FileSystemTraversalPolicy.IsExpectedTraversalException(ex)) + { + _reportError?.Invoke(ex); + } + } + + private void PollOnce(CancellationToken cancellationToken) + { + Dictionary next; + try + { + next = CaptureSnapshot(cancellationToken); + } + catch (Exception ex) when (CodeIndex.FileSystemTraversalPolicy.IsExpectedTraversalException(ex)) + { + _reportError?.Invoke(ex); + return; + } + + var previous = _snapshot ?? new Dictionary(_pathComparer); + foreach (var (path, stamp) in next) + { + if (!previous.TryGetValue(path, out var priorStamp) || priorStamp != stamp) + _enqueue?.Invoke(path); + } + + foreach (var path in previous.Keys) + { + if (!next.ContainsKey(path)) + _enqueue?.Invoke(path); + } + + _snapshot = next; + } + + private Dictionary CaptureSnapshot(CancellationToken cancellationToken) + { + var snapshot = new Dictionary(_pathComparer); + var pendingDirectories = new Stack(); + pendingDirectories.Push(_projectRoot); + + while (pendingDirectories.Count > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + var directory = pendingDirectories.Pop(); + foreach (var file in CodeIndex.FileSystemTraversalPolicy.EnumerateFiles(directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + AddFileStamp(snapshot, file); + } + + foreach (var childDirectory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(directory)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + if ((File.GetAttributes(childDirectory) & FileAttributes.ReparsePoint) == 0) + pendingDirectories.Push(childDirectory); + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + } + } + } + + foreach (var ignorePath in EnumerateAncestorIgnorePaths()) + AddFileStamp(snapshot, ignorePath); + + return snapshot; + } + + private IEnumerable EnumerateAncestorIgnorePaths() + { + var comparison = _ignoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (string.Equals(_projectRoot, _ignoreRuleRoot, comparison)) + yield break; + + var relativeProjectRoot = Path.GetRelativePath(_ignoreRuleRoot, _projectRoot); + if (Path.IsPathRooted(relativeProjectRoot) + || relativeProjectRoot == ".." + || relativeProjectRoot.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || relativeProjectRoot.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal)) + { + yield break; + } + + var directory = Directory.GetParent(_projectRoot); + while (directory != null) + { + yield return Path.Combine(directory.FullName, ".gitignore"); + yield return Path.Combine(directory.FullName, ".cdidxignore"); + if (string.Equals(directory.FullName, _ignoreRuleRoot, comparison)) + yield break; + directory = directory.Parent; + } + } + + private static void AddFileStamp(Dictionary snapshot, string path) + { + try + { + var info = new FileInfo(path); + if (info.Exists) + { + snapshot[path] = new FileStamp( + info.Length, + info.LastWriteTimeUtc.Ticks, + info.CreationTimeUtc.Ticks); + } + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + } + } + + public void Dispose() + { + var cancellation = _loopCancellation; + _loopCancellation = null; + if (cancellation != null) + { + cancellation.Cancel(); + try + { + _loopTask?.GetAwaiter().GetResult(); + } + catch (OperationCanceledException) + { + } + cancellation.Dispose(); + } + + _loopTask = null; + _snapshot = null; + _enqueue = null; + _reportError = null; + } + } } diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index 8a66e0793..5d0ec36fe 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -139,6 +139,8 @@ internal static async Task RunCoreAsync( var baselineStateGate = new object(); var baselineState = 0; // 0 = not started, 1 = running, 2 = complete + var backendGeneration = 0; + var activeBackendAttempt = -1; Exception? startupBackendError = null; IWatchBackend? backend = null; string? backendName = null; @@ -178,11 +180,15 @@ void Enqueue(string fullPath) batcher.Add(fullPath); } - void ReportBackendError(Exception? exception) + void ReportBackendError(int generation, Exception? exception) { lock (baselineStateGate) { - if (baselineState == 0) + if (generation != backendGeneration) + return; + + if (baselineState == 0 + || (baselineState == 1 && exception is not InternalBufferOverflowException)) { startupBackendError ??= exception ?? new IOException("watch backend reported an unspecified startup error"); @@ -200,35 +206,162 @@ void ReportBackendError(Exception? exception) batcher.RequestFullRescan(exception?.Message, recoveryReason); } - for (var attempt = 0; attempt < 2 && !cancellationToken.IsCancellationRequested; attempt++) + async Task StartBackendAttemptAsync(int attempt) { - startupBackendError = null; - backend = CreateWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase); - backendName = backend.Name; + int generation; + lock (baselineStateGate) + { + generation = ++backendGeneration; + startupBackendError = null; + baselineState = 0; + } + + IWatchBackend? candidate = null; try { - backend.Start(Enqueue, ReportBackendError); + candidate = CreateWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase, attempt); + backend = candidate; + backendName = candidate.Name; + await candidate.StartAsync( + Enqueue, + exception => ReportBackendError(generation, exception), + cancellationToken) + .ConfigureAwait(false); } catch (Exception ex) when (IsRecoverableWatchBackendStartException(ex)) { - startupBackendError = ex; + lock (baselineStateGate) + { + if (generation == backendGeneration) + startupBackendError ??= ex; + } } Exception? startFailure; lock (baselineStateGate) { - startFailure = startupBackendError; + startFailure = generation == backendGeneration + ? startupBackendError + : new IOException("watch backend startup was superseded"); if (startFailure == null) + { baselineState = 1; + } + else if (generation == backendGeneration) + { + backendGeneration++; + } + } + + if (startFailure != null) + { + candidate?.Dispose(); + if (ReferenceEquals(backend, candidate)) + backend = null; + } + + return startFailure; + } + + async Task ReplaceFailedBackendAfterBaselineAsync(Exception failure) + { + var failedBackendName = backendName; + lock (baselineStateGate) + { + backendGeneration++; + startupBackendError = null; + baselineState = 1; + } + + backend?.Dispose(); + backend = null; + startupRecoveryReason = "backend_start_failed"; + var failureDetail = CommandErrorWriter.FormatSanitizedExceptionMessage(failure); + + if (activeBackendAttempt != 0) + { + EmitWatchBackendFailure( + baseOptions, + jsonOptions, + failedBackendName, + startupRecoveryReason, + failureDetail); + watchExitCode = CommandExitCodes.RuntimeError; + return false; + } + + EmitWatchBackendFallback( + baseOptions, + jsonOptions, + failedBackendName, + startupRecoveryReason, + failureDetail, + baselineCompleted: true); + + var replacementFailure = await StartBackendAttemptAsync(1).ConfigureAwait(false); + if (replacementFailure != null) + { + EmitWatchBackendFailure( + baseOptions, + jsonOptions, + backendName, + startupRecoveryReason, + CommandErrorWriter.FormatSanitizedExceptionMessage(replacementFailure)); + watchExitCode = CommandExitCodes.RuntimeError; + return false; } + activeBackendAttempt = 1; + batcher.RequestFullRescan(failure.Message, startupRecoveryReason); + return true; + } + + void DrainStartupGeneration() + { + if (!batcher.TryDrainImmediately( + out var startupBatch, + out var startupFullRescan, + out var startupRecoveryScanReason, + out var startupOverflowReason)) + { + return; + } + + if (startupFullRescan) + { + EmitWatchOverflow( + baseOptions, + jsonOptions, + startupOverflowReason, + resolvedDbPath, + phase: "startup", + backend: backendName, + recoveryReason: startupRecoveryScanReason); + RecordSubRunExitCode( + ref watchExitCode, + recoveryScan?.Invoke("startup") + ?? RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken, phase: "startup")); + } + else if (startupBatch.Count > 0) + { + RecordSubRunExitCode( + ref watchExitCode, + RunPartialUpdate(baseOptions, jsonOptions, startupBatch, resolvedDbPath, cancellationToken, phase: "startup")); + } + } + + for (var attempt = 0; attempt < 2 && !cancellationToken.IsCancellationRequested; attempt++) + { + var startFailure = await StartBackendAttemptAsync(attempt).ConfigureAwait(false); + if (startFailure == null) + { + activeBackendAttempt = attempt; break; + } startupRecoveryReason = "backend_start_failed"; var detail = CommandErrorWriter.FormatSanitizedExceptionMessage(startFailure); - backend.Dispose(); - backend = null; if (attempt == 0) { @@ -260,42 +393,45 @@ void ReportBackendError(Exception? exception) ? RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken, phase: "startup") : CommandExitCodes.Success; RecordSubRunExitCode(ref watchExitCode, baselineExitCode); - lock (baselineStateGate) - baselineState = 2; // Close the startup generation by taking one atomic snapshot. Events arriving // after this boundary remain queued as ordinary live updates, so a continuously // changing workspace cannot prevent the watcher from ever becoming ready. // startup generation は atomic snapshot で閉じる。この境界後の event は通常の // live update として queue に残し、変更が連続する workspace でも ready を妨げない。 - if (watchExitCode == CommandExitCodes.Success - && !cancellationToken.IsCancellationRequested - && batcher.TryDrainImmediately( - out var startupBatch, - out var startupFullRescan, - out var startupRecoveryScanReason, - out var startupOverflowReason)) + while (backend != null + && watchExitCode == CommandExitCodes.Success + && !cancellationToken.IsCancellationRequested) { - if (startupFullRescan) + Exception? startupFailure; + lock (baselineStateGate) + startupFailure = startupBackendError; + + if (startupFailure != null + && !await ReplaceFailedBackendAfterBaselineAsync(startupFailure).ConfigureAwait(false)) { - EmitWatchOverflow( - baseOptions, - jsonOptions, - startupOverflowReason, - resolvedDbPath, - phase: "startup", - backend: backendName, - recoveryReason: startupRecoveryScanReason); - RecordSubRunExitCode( - ref watchExitCode, - recoveryScan?.Invoke("startup") - ?? RunFullRescan(baseOptions, jsonOptions, resolvedDbPath, cancellationToken, phase: "startup")); + break; } - else if (startupBatch.Count > 0) + + DrainStartupGeneration(); + if (watchExitCode != CommandExitCodes.Success + || cancellationToken.IsCancellationRequested) + { + break; + } + + lock (baselineStateGate) { - RecordSubRunExitCode( - ref watchExitCode, - RunPartialUpdate(baseOptions, jsonOptions, startupBatch, resolvedDbPath, cancellationToken, phase: "startup")); + if (startupBackendError == null) + { + // This lock is the startup/ready linearization point. A fatal callback + // before it triggers replacement; one after it is an ordinary live + // backend failure. + // この lock を startup/ready の線形化点とし、それ以前の fatal + // callback は置換、それ以後は live backend failure として扱う。 + baselineState = 2; + break; + } } } } @@ -360,6 +496,8 @@ void ReportBackendError(Exception? exception) } finally { + lock (baselineStateGate) + backendGeneration++; backend?.Dispose(); } diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs index 9f1097c52..d0915782b 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerIssue4169Tests.cs @@ -69,7 +69,7 @@ public void BuildWatchContract_ReportsStableWatchSemantics_Issue4169() Assert.Equal("full_rescan_after_debounce", contract.OverflowRecovery); Assert.Equal("full_rescan_after_debounce", contract.WatcherErrorRecovery); Assert.Equal("single_after_backend_start", contract.BaselineScan); - Assert.Equal("retry_before_baseline", contract.BackendStartRecovery); + Assert.Equal("fallback_preserve_baseline", contract.BackendStartRecovery); Assert.Equal("cancel_active_sub_run_then_emit_stopped", contract.Cancellation); Assert.Equal("json_quiet_sub_runs", contract.SubRunOutput); Assert.Equal("unsupported", contract.McpWatchMode); @@ -125,7 +125,7 @@ public void IndexWatchStartedJsonResult_SerializesWatchContract_Issue4169() Assert.Equal("full_rescan_after_debounce", contract.GetProperty("overflow_recovery").GetString()); Assert.Equal("full_rescan_after_debounce", contract.GetProperty("watcher_error_recovery").GetString()); Assert.Equal("single_after_backend_start", contract.GetProperty("baseline_scan").GetString()); - Assert.Equal("retry_before_baseline", contract.GetProperty("backend_start_recovery").GetString()); + Assert.Equal("fallback_preserve_baseline", contract.GetProperty("backend_start_recovery").GetString()); Assert.Equal("cancel_active_sub_run_then_emit_stopped", contract.GetProperty("cancellation").GetString()); Assert.Equal("json_quiet_sub_runs", contract.GetProperty("sub_run_output").GetString()); Assert.Equal("unsupported", contract.GetProperty("mcp_watch_mode").GetString()); diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 0a5a92121..3978bc0cd 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -1287,7 +1287,10 @@ public void RunCore_BackendStartFallback_ReusesSingleBaseline_Issue4858() var firstBackend = new FakeWatchBackend( "fsevents", new IOException("simulated EventStream start failure")); - var fallbackBackend = new FakeWatchBackend("fsevents"); + var fallbackBackend = new FakeWatchBackend( + "polling", + onStart: () => firstBackend.ReportError( + new IOException("stale error from disposed EventStream backend"))); var backends = new Queue([firstBackend, fallbackBackend]); var baselineScans = 0; var recoveryScans = 0; @@ -1327,7 +1330,7 @@ public void RunCore_BackendStartFallback_ReusesSingleBaseline_Issue4858() Assert.Equal("backend_start_failed", fallbackEvent.GetProperty("recovery_reason").GetString()); var watchingEvent = FindWatchEvent(capturedOut, "watching"); Assert.Equal("initial_scan", watchingEvent.GetProperty("phase").GetString()); - Assert.Equal("fsevents", watchingEvent.GetProperty("backend").GetString()); + Assert.Equal("polling", watchingEvent.GetProperty("backend").GetString()); Assert.Equal("backend_start_failed", watchingEvent.GetProperty("recovery_reason").GetString()); } finally @@ -1338,6 +1341,53 @@ public void RunCore_BackendStartFallback_ReusesSingleBaseline_Issue4858() } } + [Fact] + public void RunCore_AsynchronousStartupError_FallsBackBeforeBaseline_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var firstBackend = new DeferredStartupErrorWatchBackend("fsevents"); + var fallbackBackend = new FakeWatchBackend("polling"); + var backends = new Queue([firstBackend, fallbackBackend]); + var baselineScans = 0; + var recoveryScans = 0; + try + { + var options = CreateIssue4858WatchOptions(projectRoot, dbPath); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backends.Dequeue(); + IndexWatchRunner.WatchReadyForTesting = _ => cts.Cancel(); + + var capturedOut = RunWatchCoreAndCapture( + options, + projectRoot, + dbPath, + cts, + baselineScan: () => + { + baselineScans++; + return CommandExitCodes.Success; + }, + recoveryScan: _ => + { + recoveryScans++; + return CommandExitCodes.Success; + }, + out var exitCode); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, baselineScans); + Assert.Equal(0, recoveryScans); + Assert.Equal("polling", FindWatchEvent(capturedOut, "watching").GetProperty("backend").GetString()); + } + finally + { + IndexWatchRunner.WatchBackendFactoryForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + DeleteDirectory(projectRoot); + } + } + [Fact] public void RunCore_EventLossDuringBaseline_PerformsOneJustifiedRecovery_Issue4858() { @@ -1389,6 +1439,58 @@ public void RunCore_EventLossDuringBaseline_PerformsOneJustifiedRecovery_Issue48 } } + [Fact] + public void RunCore_BackendFailureDuringBaseline_PreservesBaselineAndRecoversOnce_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var firstBackend = new FakeWatchBackend("fsevents"); + var fallbackBackend = new FakeWatchBackend("polling"); + var backends = new Queue([firstBackend, fallbackBackend]); + var baselineScans = 0; + var recoveryScans = 0; + try + { + var options = CreateIssue4858WatchOptions(projectRoot, dbPath); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backends.Dequeue(); + IndexWatchRunner.WatchReadyForTesting = _ => cts.Cancel(); + + var capturedOut = RunWatchCoreAndCapture( + options, + projectRoot, + dbPath, + cts, + baselineScan: () => + { + baselineScans++; + firstBackend.ReportError(new IOException("late EventStream startup failure")); + return CommandExitCodes.Success; + }, + recoveryScan: phase => + { + Assert.Equal("startup", phase); + recoveryScans++; + return CommandExitCodes.Success; + }, + out var exitCode); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, baselineScans); + Assert.Equal(1, recoveryScans); + Assert.Equal("polling", FindWatchEvent(capturedOut, "watching").GetProperty("backend").GetString()); + Assert.Equal( + "backend_start_failed", + FindWatchEvent(capturedOut, "overflow").GetProperty("recovery_reason").GetString()); + } + finally + { + IndexWatchRunner.WatchBackendFactoryForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + DeleteDirectory(projectRoot); + } + } + [Fact] public void RunCore_BackendStartFailureAfterFallback_StopsBeforeBaseline_Issue4858() { @@ -1398,7 +1500,7 @@ public void RunCore_BackendStartFailureAfterFallback_StopsBeforeBaseline_Issue48 var backends = new Queue( [ new FakeWatchBackend("fsevents", new IOException("first simulated start failure")), - new FakeWatchBackend("fsevents", new IOException("second simulated start failure")), + new FakeWatchBackend("polling", new IOException("second simulated start failure")), ]); var baselineScans = 0; try @@ -1423,7 +1525,7 @@ public void RunCore_BackendStartFailureAfterFallback_StopsBeforeBaseline_Issue48 Assert.Equal(0, baselineScans); var failureEvent = FindWatchEvent(capturedOut, "failed"); Assert.Equal("startup", failureEvent.GetProperty("phase").GetString()); - Assert.Equal("fsevents", failureEvent.GetProperty("backend").GetString()); + Assert.Equal("polling", failureEvent.GetProperty("backend").GetString()); Assert.Equal("backend_start_failed", failureEvent.GetProperty("recovery_reason").GetString()); Assert.DoesNotContain("\"status\":\"watching\"", capturedOut, StringComparison.Ordinal); Assert.Contains("\"status\":\"stopped\"", capturedOut, StringComparison.Ordinal); @@ -1473,7 +1575,12 @@ public void RunCore_MacOsBackend_StartsBeforeSingleBaseline_Issue4858() Assert.Equal(1, baselineScans); Assert.InRange(recoveryScans, 0, 1); Assert.Contains("\"status\":\"watching\"", capturedOut, StringComparison.Ordinal); - Assert.Contains("\"backend\":\"fsevents\"", capturedOut, StringComparison.Ordinal); + var watchingEvent = FindWatchEvent(capturedOut, "watching"); + Assert.Contains( + watchingEvent.GetProperty("backend").GetString(), + ["fsevents", "polling"]); + if (watchingEvent.GetProperty("backend").GetString() == "polling") + Assert.Contains("\"status\":\"backend_fallback\"", capturedOut, StringComparison.Ordinal); } finally { @@ -2084,12 +2191,17 @@ protected override void Dispose(bool disposing) private sealed class FakeWatchBackend : IndexWatchRunner.IWatchBackend { private readonly Exception? _startException; + private readonly Action? _onStart; private Action? _reportError; - internal FakeWatchBackend(string name, Exception? startException = null) + internal FakeWatchBackend( + string name, + Exception? startException = null, + Action? onStart = null) { Name = name; _startException = startException; + _onStart = onStart; } public string Name { get; } @@ -2098,12 +2210,17 @@ internal FakeWatchBackend(string name, Exception? startException = null) internal bool Disposed { get; private set; } - public void Start(Action enqueue, Action reportError) + public Task StartAsync( + Action enqueue, + Action reportError, + CancellationToken cancellationToken) { StartCount++; _reportError = reportError; + _onStart?.Invoke(); if (_startException != null) - throw _startException; + return Task.FromException(_startException); + return Task.CompletedTask; } internal void ReportError(Exception exception) @@ -2117,4 +2234,23 @@ public void Dispose() Disposed = true; } } + + private sealed class DeferredStartupErrorWatchBackend(string name) : IndexWatchRunner.IWatchBackend + { + public string Name { get; } = name; + + public async Task StartAsync( + Action enqueue, + Action reportError, + CancellationToken cancellationToken) + { + await Task.Yield(); + cancellationToken.ThrowIfCancellationRequested(); + reportError(new IOException("asynchronous simulated EventStream start failure")); + } + + public void Dispose() + { + } + } } From cbdff0455bb537544163b205d11449fee7991b92 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 18:59:12 +0900 Subject: [PATCH 3/4] Resolve final watch review findings (#4858) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 8 +- changelog.d/unreleased/4858.fixed.md | 4 +- .../Cli/IndexWatchRunner.Reporting.cs | 29 ++- .../Cli/IndexWatchRunner.Watchers.cs | 70 ++++++- src/CodeIndex/Cli/IndexWatchRunner.cs | 103 +++++++++- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 179 ++++++++++++++++++ 7 files changed, 368 insertions(+), 29 deletions(-) diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 3c2604986..d9d6e67a5 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -385,7 +385,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding keep scan, `status --check`, and watch on one `.cdidx` membership policy. Ignore/unignore changes and pattern-config add/edit/delete events must reach a debounced full scan, while pattern/plugin inputs and ordinary `.cdidx` sidecars remain outside indexed source rows. Pattern extension edits must purge the old extension and index the new one in the same process. The registry refresh test also removes a loaded workspace plugin and verifies that its collectible context is unloaded while a host-registered fallback survives. Test the classification boundary directly instead of waiting for platform-specific `FileSystemWatcher` delivery. - `IndexWatchRunnerTests.RunCore_StartupHandoff_ReconcilesMutationAndDrainsBeforeReady_Issue4594` mutates an already indexed symbol through the post-subscription startup hook, explicitly enqueues that event, and verifies both final database content and `rescanned` / `updated` ordering before `watching`. Keep the injected enqueue: relying only on platform watcher timing would make the handoff regression nondeterministic. -- The issue-4858 watch startup fixtures include a top-level command test that counts the real full-scan write phase, plus focused tests that inject `IWatchBackend` implementations and explicit baseline/recovery delegates. They require asynchronous backend-start failure to switch from FSEvents to polling around exactly one baseline, ignore late callbacks from the disposed generation, collapse repeated event-loss callbacks into one recovery generation, stop before the baseline when both starts fail, and cancel only from the ready callback. Keep the machine-readable `backend` / `recovery_reason` assertions and avoid wall-clock sleeps. The guarded macOS case uses the real backend selection and accepts either FSEvents or its operational polling fallback. +- The issue-4858 watch startup fixtures include a top-level command test that counts the real full-scan write phase, plus focused tests that inject `IWatchBackend` implementations and explicit baseline/recovery delegates. They require asynchronous backend-start failure to switch from FSEvents to polling around exactly one baseline, replace FSEvents after a fatal error that arrives after readiness, ignore late callbacks from the disposed generation, collapse repeated event-loss callbacks into one recovery generation, stop before the baseline when both starts fail, and emit `stopped` when polling startup is canceled. The polling snapshot fixture requires indexer-equivalent pruning for ignored and internal trees, and human-output lifecycle tests use a fake backend instead of assuming the host FSEvents service is available. Keep the machine-readable `backend` / `recovery_reason` assertions and avoid wall-clock sleeps. The guarded macOS case uses the real backend selection and accepts either FSEvents or its operational polling fallback. - `BackgroundTaskObserverTests` relies on `BackgroundTaskObserver`'s fault-only continuation contract: canceled tasks are awaited directly and do not need a post-cancellation fixed sleep to @@ -1295,7 +1295,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" scan、`status --check`、watch が単一の `.cdidx` membership policy を使うことを固定する。ignore / unignore 変更と pattern-config の追加 / 編集 / 削除は debounce 付き full scan に到達し、pattern / plugin 入力と通常の `.cdidx` sidecar は indexed source row から除外されたままでなければならない。pattern extension の編集では同一 process 内で旧 extension を purge して新 extension を index する。registry refresh test は読み込み済み workspace plugin を削除し、host 登録 fallback を維持しながら collectible context が unload されることも検証する。platform 固有の `FileSystemWatcher` 配信を待たず、classification 境界を直接検証する。 - `IndexWatchRunnerTests.RunCore_StartupHandoff_ReconcilesMutationAndDrainsBeforeReady_Issue4594` subscribe 後の startup hook から index 済み symbol を変更して event を明示的に enqueue し、最終 database content と `watching` より前の `rescanned` / `updated` 順序を検証する。platform watcher の timing だけに依存すると handoff 回帰が非決定的になるため、注入した enqueue を維持すること。 -- issue-4858 の watch startup fixture には、実際の full-scan write phase を数える top-level command test と、`IWatchBackend` 実装および明示的な baseline / recovery delegate を注入する focused test がある。非同期 backend 起動失敗が baseline 1 回の前後で FSEvents から polling へ切り替わること、dispose 済み generation から遅れて届く callback を無視すること、複数の event-loss callback が recovery generation 1 回へ集約されること、2 回とも起動に失敗した場合は baseline 前に停止すること、ready callback からのみ cancel することを固定する。機械可読な `backend` / `recovery_reason` assertion を維持し、wall-clock sleep は使わないこと。macOS guard 付き case は実際の backend 選択を使い、FSEvents と実運用 polling fallback のどちらも許容する。 +- issue-4858 の watch startup fixture には、実際の full-scan write phase を数える top-level command test と、`IWatchBackend` 実装および明示的な baseline / recovery delegate を注入する focused test がある。非同期 backend 起動失敗が baseline 1 回の前後で FSEvents から polling へ切り替わること、ready 後に届く fatal error でも FSEvents を置換すること、dispose 済み generation から遅れて届く callback を無視すること、複数の event-loss callback が recovery generation 1 回へ集約されること、2 回とも起動に失敗した場合は baseline 前に停止すること、polling startup の cancellation でも `stopped` を出力することを固定する。polling snapshot fixture は ignored / internal tree を indexer と同じ規則で剪定することを要求し、human-output lifecycle test は host の FSEvents availability を仮定せず fake backend を使う。機械可読な `backend` / `recovery_reason` assertion を維持し、wall-clock sleep は使わないこと。macOS guard 付き case は実際の backend 選択を使い、FSEvents と実運用 polling fallback のどちらも許容する。 - `BackgroundTaskObserverTests` は `BackgroundTaskObserver` の fault-only continuation 契約に依存します。canceled task は直接 await し、warning が抑止されたことを示すための cancellation 後の固定 sleep diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 5e37776fd..3d49b75f5 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1087,7 +1087,7 @@ small repositories, and minutes or longer on very large monorepos with around By default, `cdidx index` stores the database in `/.cdidx/codeindex.db`, even if you run the command from another directory. -`--watch` starts `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows) before the one required baseline scan, then keeps the process alive and rebuilds the index incrementally as files are created, edited, renamed, or deleted. A recoverable macOS EventStream startup failure switches to a polling backend without repeating a valid baseline. If the failure arrives while the baseline is running, the valid baseline is retained and one recovery scan reconciles the backend handoff; stale callbacks from the replaced backend are ignored. Events buffered during the baseline are drained before `watching`; genuine event loss after the backend is active is coalesced into at most one justified full incremental recovery scan per generation. Bursts of ordinary events are debounced (`--debounce `, default 500 ms) into a single `--files` update, the per-DB index lock is released between batches so other `cdidx` commands can still query, and a pending path batch that reaches its safety cap also requests one recovery scan. Subdirectory watches monitor ancestor `.gitignore` / `.cdidxignore` files along the repository path. The baseline and startup reconciliation must succeed before `watching` is emitted; a failed startup generation exits instead of declaring a stale watcher ready. With `--json` it streams `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout; startup and recovery events expose `backend` (`fsevents` or `polling` on macOS) and machine-readable `recovery_reason`, while update/rescan events include `exit_code`. Human output includes the same backend/recovery context in `[watch] …` summaries. Stop the loop with Ctrl+C (or SIGTERM); the first Ctrl+C requests cooperative cancellation and a second Ctrl+C remains available for force exit. The final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. +`--watch` starts `FileSystemWatcher` (FSEvents on macOS, inotify on Linux, ReadDirectoryChangesW on Windows) before the one required baseline scan, then keeps the process alive and rebuilds the index incrementally as files are created, edited, renamed, or deleted. A recoverable macOS EventStream startup failure, or a later fatal EventStream error, switches to a polling backend without repeating a valid baseline. If the failure arrives while the baseline is running or after readiness, the valid baseline is retained and one recovery scan reconciles the backend handoff; stale callbacks from the replaced backend are ignored. Polling applies the same ignored-directory and internal-artifact pruning as indexing instead of repeatedly walking `.git`, `.cdidx`, build outputs, dependencies, or ignored trees. Events buffered during the baseline are drained before `watching`; genuine event loss after the backend is active is coalesced into at most one justified full incremental recovery scan per generation. Bursts of ordinary events are debounced (`--debounce `, default 500 ms) into a single `--files` update, the per-DB index lock is released between batches so other `cdidx` commands can still query, and a pending path batch that reaches its safety cap also requests one recovery scan. Subdirectory watches monitor ancestor `.gitignore` / `.cdidxignore` files along the repository path. The baseline and startup reconciliation must succeed before `watching` is emitted; a failed startup generation exits instead of declaring a stale watcher ready. With `--json` it streams `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` lifecycle events to stdout; startup and recovery events expose `backend` (`fsevents` or `polling` on macOS) and machine-readable `recovery_reason`, while update/rescan events include `exit_code`. Human output includes the same backend/recovery context in `[watch] …` summaries. Stop the loop with Ctrl+C (or SIGTERM); cancellation during backend fallback or an active sub-run still emits the terminal `stopped` event, the first Ctrl+C requests cooperative cancellation, and a second Ctrl+C remains available for force exit. The final exit code is `0` when every batch succeeds, or the most recent non-zero sub-run exit code if a watch update/rescan failed before stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates. Indexing scope and ignore handling: @@ -2034,7 +2034,7 @@ same source location. | `--max-symbols-per-file ` | `index` | Skip file content, symbols, and references when one file emits too many symbols. Defaults to `5000`; values above `50000` are rejected. | | `--symbols-only` | `index` | Full-scan only. Build chunks, symbols, and issues while skipping reference extraction and graph finalization for a faster first pass. `search`, `definition`, `symbols`, and `map` are available; reference graph commands remain degraded until a normal `cdidx index ` run. | | `--parallelism ` | `index` | Set full-scan extraction worker count. Defaults to CPU count capped at 8, or `CDIDX_INDEX_PARALLELISM` when set; explicit values are capped at 16. SQLite writes stay single-consumer. | -| `--watch` | `index` | Start the filesystem backend before one baseline scan, then stay running and reindex incrementally as files change (FileSystemWatcher / inotify / FSEvents). A recoverable macOS EventStream startup failure switches to polling without repeating the baseline; a failure during the baseline retains it and requests one recovery scan for the handoff. Buffered startup events are drained before `watching`, stale backend callbacks are ignored, and event loss after activation triggers at most one recovery scan per generation. JSON startup/recovery events expose `backend` and `recovery_reason`. Changes to `.gitignore`, `.cdidxignore`, `.cdidx/patterns/**`, or `.cdidx/plugins/**` trigger a debounced full-workspace reconciliation; pattern/plugin additions, edits, and removals refresh the in-process extractor registry before that scan. The `.cdidx` namespace itself is excluded from source membership consistently with full scan and `status --check`, including ordinary sidecars. Ctrl-C cancels an active indexing sub-run before the loop emits its stopped event. Sub-run stdout is captured through a runner-scoped writer, so an embedded or concurrent command keeps its own process stdout. Rejects `--commits`, `--changed-between`, `--files`, and `--dry-run` because the loop already drives continuous incremental updates. | +| `--watch` | `index` | Start the filesystem backend before one baseline scan, then stay running and reindex incrementally as files change (FileSystemWatcher / inotify / FSEvents). A recoverable macOS EventStream startup or later fatal error switches to polling without repeating the baseline; a failure during or after the baseline requests one recovery scan for the handoff. Polling prunes ignored directories and internal artifacts with the indexer policy. Buffered startup events are drained before `watching`, stale backend callbacks are ignored, and event loss after activation triggers at most one recovery scan per generation. JSON startup/recovery events expose `backend` and `recovery_reason`. Changes to `.gitignore`, `.cdidxignore`, `.cdidx/patterns/**`, or `.cdidx/plugins/**` trigger a debounced full-workspace reconciliation; pattern/plugin additions, edits, and removals refresh the in-process extractor registry before that scan. The `.cdidx` namespace itself is excluded from source membership consistently with full scan and `status --check`, including ordinary sidecars. Ctrl-C cancels backend fallback or an active indexing sub-run before the loop emits its stopped event. Sub-run stdout is captured through a runner-scoped writer, so an embedded or concurrent command keeps its own process stdout. Rejects `--commits`, `--changed-between`, `--files`, and `--dry-run` because the loop already drives continuous incremental updates. | | `--debounce ` | `index` (watch only) | Coalesce bursts of file events into a single update after `` of quiet (non-negative integer; default: 500). Invalid values emit a warning and are ignored. | | `--watch-pending-path-limit ` | `index` (watch only) | Set the number of distinct changed paths the watch loop will queue before it reports an overflow and falls back to a full rescan. Defaults to `4096`, honors `CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` and `indexing.watchPendingPathLimit`, and rejects values above `262144`. The `watching` and `overflow` JSON events include `watch_pending_path_limit`. | | `--since ` | `search`, `definition`, `symbols`, `files` | Filter to files modified since this ISO 8601 timestamp. Offsetless values (e.g. `2024-01-01T00:00:00`) are treated as UTC so the same flag resolves to the same instant in every timezone; append `Z` or an explicit offset (`+09:00`) to be explicit. | @@ -4291,7 +4291,7 @@ interactive terminal では spinner と progress bar が動き続けます。待 `cdidx index` は、別ディレクトリから実行しても、デフォルトでは `/.cdidx/codeindex.db` にDBを保存します。 -`--watch` は必要な baseline scan 1 回より先に `FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)を開始し、その後もプロセスを残してファイルの作成・編集・リネーム・削除を差分反映します。回復可能な macOS EventStream 起動失敗は polling backend へ切り替え、有効な baseline を繰り返しません。失敗通知が baseline 実行中に届いた場合もその baseline を保持し、backend handoff のための recovery scan を1回だけ実行します。置換済み backend から遅れて届いた callback は無視します。baseline 中に buffer された event は `watching` の前に drain し、backend 有効化後の本当の event loss だけを generation ごとに最大 1 回の根拠付きフル差分 recovery scan へ集約します。通常 event は `--debounce `(既定 500 ms)の窓で 1 つの `--files` 更新にまとめ、batch 間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。pending path batch が安全上限に達した場合も recovery scan を 1 回要求します。subdirectory の watch は repository path 上の ancestor `.gitignore` / `.cdidxignore` も監視します。baseline と startup reconciliation が成功するまで `watching` は出力せず、startup generation が失敗した場合は stale なまま ready を宣言せず終了します。`--json` 時は `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流します。startup / recovery event は `backend`(macOS では `fsevents` または `polling`)と機械可読な `recovery_reason` を公開し、update/rescan event は `exit_code` を含みます。human 出力も `[watch] …` 要約に同じ backend / recovery context を含めます。最初の Ctrl+C(または SIGTERM)は協調的な cancellation を要求し、2 回目の Ctrl+C は強制終了に利用できます。すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 +`--watch` は必要な baseline scan 1 回より先に `FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)を開始し、その後もプロセスを残してファイルの作成・編集・リネーム・削除を差分反映します。回復可能な macOS EventStream 起動失敗、または ready 後の致命的な EventStream error は polling backend へ切り替え、有効な baseline を繰り返しません。失敗通知が baseline 実行中または ready 後に届いた場合もその baseline を保持し、backend handoff のための recovery scan を1回だけ実行します。置換済み backend から遅れて届いた callback は無視します。polling は index と同じ ignore-directory / internal-artifact policy で `.git`、`.cdidx`、build output、dependency、ignored tree を剪定し、周期ごとの不要な全ツリー走査を避けます。baseline 中に buffer された event は `watching` の前に drain し、backend 有効化後の本当の event loss だけを generation ごとに最大 1 回の根拠付きフル差分 recovery scan へ集約します。通常 event は `--debounce `(既定 500 ms)の窓で 1 つの `--files` 更新にまとめ、batch 間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。pending path batch が安全上限に達した場合も recovery scan を 1 回要求します。subdirectory の watch は repository path 上の ancestor `.gitignore` / `.cdidxignore` も監視します。baseline と startup reconciliation が成功するまで `watching` は出力せず、startup generation が失敗した場合は stale なまま ready を宣言せず終了します。`--json` 時は `status: "backend_fallback" / "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流します。startup / recovery event は `backend`(macOS では `fsevents` または `polling`)と機械可読な `recovery_reason` を公開し、update/rescan event は `exit_code` を含みます。human 出力も `[watch] …` 要約に同じ backend / recovery context を含めます。backend fallback 中または実行中の sub-run を cancellation した場合も terminal `stopped` event を出力します。最初の Ctrl+C(または SIGTERM)は協調的な cancellation を要求し、2 回目の Ctrl+C は強制終了に利用できます。すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。 デフォルト出力: @@ -5198,7 +5198,7 @@ raw match density を正確に測る、といった理由で全 raw chunk hit | `--max-file-bytes ` | `index` | この実行で使うファイル単位の索引サイズ上限を上書きする。既定は 4MiB、または `CDIDX_MAX_FILE_BYTES` 設定値。値は raw byte 数、または `50M` のような `K` / `M` / `G` 接尾辞を受け付ける。 | | `--symbols-only` | `index` | フルスキャン専用。参照抽出と graph finalization を省き、chunks、symbols、issues だけを作ることで初回利用を速くする。`search`、`definition`、`symbols`、`map` は使えるが、reference graph 系コマンドは通常の `cdidx index ` を実行するまで degraded のまま。 | | `--parallelism ` | `index` | フルスキャンの抽出 worker 数を指定する。既定は CPU 数を最大 8 に丸めた値、または `CDIDX_INDEX_PARALLELISM` 設定値。明示値は最大 16。SQLite 書き込みは単一 consumer のまま。 | -| `--watch` | `index` | filesystem backend(FileSystemWatcher / inotify / FSEvents)を baseline scan 1 回より先に開始し、その後もプロセスを残してファイル変更を差分反映する。回復可能な macOS EventStream 起動失敗は baseline を繰り返さず polling へ切り替え、baseline 中の失敗なら handoff recovery scan を1回だけ実行する。startup 中に buffer した event は `watching` 前に drain し、置換済み backend の callback は無視する。backend 有効化後の event loss は generation ごとに最大 1 回の recovery scan を起動する。JSON の startup / recovery event は `backend` と `recovery_reason` を公開する。`.gitignore`、`.cdidxignore`、`.cdidx/patterns/**`、`.cdidx/plugins/**` の変更は debounce 後に workspace 全体を reconciliation し、pattern / plugin の追加・編集・削除では scan 前に process 内 extractor registry も refresh する。通常 sidecar を含む `.cdidx` namespace 自体は full scan と `status --check` と同様に source membership から除外する。Ctrl-C は実行中の indexing sub-run をキャンセルしてから stopped event を出力する。sub-run の stdout は runner scope の writer で capture するため、埋め込み先や同時実行 command の process stdout を置き換えない。連続的な差分更新を内蔵しているため `--commits` / `--changed-between` / `--files` / `--dry-run` との併用は拒否する。 | +| `--watch` | `index` | filesystem backend(FileSystemWatcher / inotify / FSEvents)を baseline scan 1 回より先に開始し、その後もプロセスを残してファイル変更を差分反映する。回復可能な macOS EventStream 起動失敗または ready 後の致命的 error は baseline を繰り返さず polling へ切り替え、baseline 中または ready 後の失敗なら handoff recovery scan を1回だけ実行する。polling は indexer policy で ignored directory と internal artifact を剪定する。startup 中に buffer した event は `watching` 前に drain し、置換済み backend の callback は無視する。backend 有効化後の event loss は generation ごとに最大 1 回の recovery scan を起動する。JSON の startup / recovery event は `backend` と `recovery_reason` を公開する。`.gitignore`、`.cdidxignore`、`.cdidx/patterns/**`、`.cdidx/plugins/**` の変更は debounce 後に workspace 全体を reconciliation し、pattern / plugin の追加・編集・削除では scan 前に process 内 extractor registry も refresh する。通常 sidecar を含む `.cdidx` namespace 自体は full scan と `status --check` と同様に source membership から除外する。Ctrl-C は backend fallback または実行中の indexing sub-run をキャンセルしてから stopped event を出力する。sub-run の stdout は runner scope の writer で capture するため、埋め込み先や同時実行 command の process stdout を置き換えない。連続的な差分更新を内蔵しているため `--commits` / `--changed-between` / `--files` / `--dry-run` との併用は拒否する。 | | `--debounce ` | `index`(`--watch` 専用) | 一連のイベントを `` の静止後に 1 つの更新へ集約する(0 以上の整数。既定: 500)。不正な値は警告を出して無視する。 | | `--watch-pending-path-limit ` | `index`(`--watch` 専用) | watch loop が overflow を報告して full rescan へ fallback する前に保持する distinct changed path 数を設定する。既定は `4096` で、`CDIDX_INDEX_WATCH_PENDING_PATH_LIMIT` と `indexing.watchPendingPathLimit` も使える。`262144` を超える値は拒否される。`watching` と `overflow` の JSON event には `watch_pending_path_limit` が入る。 | | `--since ` | `search`, `definition`, `symbols`, `files` | 指定タイムスタンプ以降に変更されたファイルのみ(ISO 8601)。オフセットなしの値(例: `2024-01-01T00:00:00`)は UTC として解釈されるため、どのタイムゾーンから呼び出しても同じ UTC 時点になります。明示したい場合は末尾に `Z` または `+09:00` 等のオフセットを付与してください。 | diff --git a/changelog.d/unreleased/4858.fixed.md b/changelog.d/unreleased/4858.fixed.md index ad34cdcd4..711cea3c0 100644 --- a/changelog.d/unreleased/4858.fixed.md +++ b/changelog.d/unreleased/4858.fixed.md @@ -19,8 +19,8 @@ affected: ## English -- **macOS watch startup now performs one baseline scan across recoverable backend fallback (#4858)** — the watch backend starts before the required baseline and falls back from FSEvents to polling without repeating a valid baseline. A failure during that baseline retains it and performs one recovery scan for the handoff, while stale callbacks from the replaced backend are ignored. Watch diagnostics now report the backend and recovery reason. +- **macOS watch startup now performs one baseline scan across recoverable backend fallback (#4858)** — the watch backend starts before the required baseline and falls back from FSEvents to polling without repeating a valid baseline. Failures during the baseline or after readiness replace the failed backend and perform one recovery scan for the handoff, while stale callbacks from the replaced backend are ignored. Polling prunes ignored/internal trees with the indexer policy, cancellation still emits `stopped`, and watch diagnostics report the backend and recovery reason. ## 日本語 -- **macOS の watch 起動時に、復旧可能なバックエンドフォールバックをまたいでもベースラインスキャンを1回だけ実行するようになりました (#4858)** — 必須のベースラインより先に watch バックエンドを起動し、有効なベースラインを繰り返さず FSEvents から polling へフォールバックします。ベースライン中の失敗ではその結果を保持して handoff 用のリカバリスキャンを1回実行し、置換済みバックエンドから遅れて届く callback は無視します。watch 診断にはバックエンドとリカバリ理由も表示されます。 +- **macOS の watch 起動時に、復旧可能なバックエンドフォールバックをまたいでもベースラインスキャンを1回だけ実行するようになりました (#4858)** — 必須のベースラインより先に watch バックエンドを起動し、有効なベースラインを繰り返さず FSEvents から polling へフォールバックします。ベースライン中または ready 後の失敗では failed backend を置換して handoff 用のリカバリスキャンを1回実行し、置換済みバックエンドから遅れて届く callback は無視します。polling は indexer policy で ignored / internal tree を剪定し、cancellation でも `stopped` を出力し、watch 診断にはバックエンドとリカバリ理由も表示します。 diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs index ae10be419..cc9e84e82 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Reporting.cs @@ -237,7 +237,8 @@ private static void EmitWatchBackendFallback( string? backend, string recoveryReason, string? reason, - bool baselineCompleted = false) + bool baselineCompleted = false, + string phase = "startup") => EmitWatchBackendStartupEvent( baseOptions, jsonOptions, @@ -245,16 +246,20 @@ private static void EmitWatchBackendFallback( backend: backend, recoveryReason: recoveryReason, reason: reason, - humanAction: baselineCompleted - ? "switching backend without repeating the baseline; one recovery scan will reconcile the handoff" - : "switching backend before the baseline scan"); + phase: phase, + humanAction: phase == "startup" + ? baselineCompleted + ? "switching backend without repeating the baseline; one recovery scan will reconcile the handoff" + : "switching backend before the baseline scan" + : "switching backend; one recovery scan will reconcile the handoff"); private static void EmitWatchBackendFailure( IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions, string? backend, string recoveryReason, - string? reason) + string? reason, + string phase = "startup") => EmitWatchBackendStartupEvent( baseOptions, jsonOptions, @@ -262,7 +267,10 @@ private static void EmitWatchBackendFailure( backend: backend, recoveryReason: recoveryReason, reason: reason, - humanAction: "watch startup stopped before the baseline scan"); + phase: phase, + humanAction: phase == "startup" + ? "watch startup stopped before the baseline scan" + : "watch stopped because no further backend fallback is available"); private static void EmitWatchBackendStartupEvent( IndexCommandOptions baseOptions, @@ -271,6 +279,7 @@ private static void EmitWatchBackendStartupEvent( string? backend, string recoveryReason, string? reason, + string phase, string humanAction) { var safeBackend = FormatWatchDiagnosticText(backend) ?? "filesystem_watcher"; @@ -281,7 +290,7 @@ private static void EmitWatchBackendStartupEvent( Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult { Status = status, - Phase = "startup", + Phase = phase, Backend = safeBackend, RecoveryReason = safeRecoveryReason, Reason = safeReason, @@ -290,8 +299,12 @@ private static void EmitWatchBackendStartupEvent( } var detail = string.IsNullOrEmpty(safeReason) ? string.Empty : $"; {safeReason}"; + var failureDescription = phase == "startup" + ? "startup failed" + : "reported a fatal error"; CommandErrorWriter.WriteStderr( - $"[watch] Backend {safeBackend} startup failed (recovery {safeRecoveryReason}{detail}); {humanAction}."); + $"[watch] Backend {safeBackend} {failureDescription} " + + $"(recovery {safeRecoveryReason}{detail}); {humanAction}."); } private static void EmitWatchOverflow( diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index 6a86cfd5f..cec514651 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -25,14 +25,38 @@ Task StartAsync( private static readonly TimeSpan BackendStartupStabilizationDelay = TimeSpan.FromMilliseconds(250); private static readonly TimeSpan PollingWatchInterval = TimeSpan.FromSeconds(2); + internal static IReadOnlyCollection CapturePollingSnapshotPathsForTesting( + string projectRoot, + string ignoreRuleRoot, + string resolvedDbPath, + bool ignoreCase, + bool dbPathExplicit, + CancellationToken cancellationToken = default) + { + using var backend = new PollingWatchBackend( + projectRoot, + ignoreRuleRoot, + resolvedDbPath, + ignoreCase, + dbPathExplicit); + return backend.CaptureSnapshotPaths(cancellationToken); + } + private static IWatchBackend CreateWatchBackend( string projectRoot, string ignoreRuleRoot, + string resolvedDbPath, bool ignoreCase, + bool dbPathExplicit, int attempt) => WatchBackendFactoryForTesting?.Invoke(projectRoot, ignoreRuleRoot, ignoreCase) ?? (attempt > 0 && OperatingSystem.IsMacOS() - ? new PollingWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase) + ? new PollingWatchBackend( + projectRoot, + ignoreRuleRoot, + resolvedDbPath, + ignoreCase, + dbPathExplicit) : new FileSystemWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase)); private static string ResolveWatchBackendName() @@ -382,8 +406,11 @@ private readonly record struct FileStamp( private readonly string _projectRoot; private readonly string _ignoreRuleRoot; + private readonly string _resolvedDbPath; private readonly bool _ignoreCase; + private readonly bool _dbPathExplicit; private readonly StringComparer _pathComparer; + private readonly FileIndexer _fileIndexer; private CancellationTokenSource? _loopCancellation; private Task? _loopTask; private Dictionary? _snapshot; @@ -394,12 +421,23 @@ private readonly record struct FileStamp( internal PollingWatchBackend( string projectRoot, string ignoreRuleRoot, - bool ignoreCase) + string resolvedDbPath, + bool ignoreCase, + bool dbPathExplicit) { _projectRoot = Path.GetFullPath(projectRoot); _ignoreRuleRoot = Path.GetFullPath(ignoreRuleRoot); + _resolvedDbPath = Path.GetFullPath(DbPathResolver.NormalizeDbPath(resolvedDbPath)); _ignoreCase = ignoreCase; + _dbPathExplicit = dbPathExplicit; _pathComparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + _fileIndexer = new FileIndexer( + _projectRoot, + ignoreCase, + _ignoreRuleRoot, + maxFileSizeBytes: null, + directoryIgnoreCaseProbe: null, + internalIndexDatabasePath: _resolvedDbPath); } public string Name => "polling"; @@ -471,6 +509,9 @@ private void PollOnce(CancellationToken cancellationToken) _snapshot = next; } + internal IReadOnlyCollection CaptureSnapshotPaths(CancellationToken cancellationToken) + => CaptureSnapshot(cancellationToken).Keys.ToArray(); + private Dictionary CaptureSnapshot(CancellationToken cancellationToken) { var snapshot = new Dictionary(_pathComparer); @@ -484,7 +525,8 @@ private Dictionary CaptureSnapshot(CancellationToken cancella foreach (var file in CodeIndex.FileSystemTraversalPolicy.EnumerateFiles(directory)) { cancellationToken.ThrowIfCancellationRequested(); - AddFileStamp(snapshot, file); + if (ShouldTrackFile(file)) + AddFileStamp(snapshot, file); } foreach (var childDirectory in CodeIndex.FileSystemTraversalPolicy.EnumerateDirectories(directory)) @@ -492,8 +534,11 @@ private Dictionary CaptureSnapshot(CancellationToken cancella cancellationToken.ThrowIfCancellationRequested(); try { - if ((File.GetAttributes(childDirectory) & FileAttributes.ReparsePoint) == 0) + if ((File.GetAttributes(childDirectory) & FileAttributes.ReparsePoint) == 0 + && !_fileIndexer.ShouldSkipPath(childDirectory, isDirectory: true)) + { pendingDirectories.Push(childDirectory); + } } catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) { @@ -507,6 +552,23 @@ private Dictionary CaptureSnapshot(CancellationToken cancella return snapshot; } + private bool ShouldTrackFile(string path) + { + if (FileIndexer.ClassifyIndexInputInvalidation(_projectRoot, path) + != FileIndexer.IndexInputInvalidationKind.None) + { + return true; + } + + return !ShouldIgnoreWatchInternalPath( + _projectRoot, + _resolvedDbPath, + path, + _ignoreCase, + _dbPathExplicit) + && !_fileIndexer.ShouldSkipPath(path); + } + private IEnumerable EnumerateAncestorIgnorePaths() { var comparison = _ignoreCase diff --git a/src/CodeIndex/Cli/IndexWatchRunner.cs b/src/CodeIndex/Cli/IndexWatchRunner.cs index 5d0ec36fe..af2b4ed28 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.cs @@ -142,6 +142,7 @@ internal static async Task RunCoreAsync( var backendGeneration = 0; var activeBackendAttempt = -1; Exception? startupBackendError = null; + Exception? liveBackendError = null; IWatchBackend? backend = null; string? backendName = null; string? startupRecoveryReason = null; @@ -182,6 +183,8 @@ void Enqueue(string fullPath) void ReportBackendError(int generation, Exception? exception) { + var failure = exception + ?? new IOException("watch backend reported an unspecified error"); lock (baselineStateGate) { if (generation != backendGeneration) @@ -190,8 +193,14 @@ void ReportBackendError(int generation, Exception? exception) if (baselineState == 0 || (baselineState == 1 && exception is not InternalBufferOverflowException)) { - startupBackendError ??= exception - ?? new IOException("watch backend reported an unspecified startup error"); + startupBackendError ??= failure; + return; + } + + if (baselineState == 2 + && exception is not InternalBufferOverflowException) + { + liveBackendError ??= failure; return; } } @@ -213,13 +222,20 @@ void ReportBackendError(int generation, Exception? exception) { generation = ++backendGeneration; startupBackendError = null; + liveBackendError = null; baselineState = 0; } IWatchBackend? candidate = null; try { - candidate = CreateWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase, attempt); + candidate = CreateWatchBackend( + projectRoot, + ignoreRuleRoot, + resolvedDbPath, + ignoreCase, + dbPathExplicit, + attempt); backend = candidate; backendName = candidate.Name; await candidate.StartAsync( @@ -263,19 +279,23 @@ await candidate.StartAsync( return startFailure; } - async Task ReplaceFailedBackendAfterBaselineAsync(Exception failure) + async Task ReplaceFailedBackendAfterBaselineAsync( + Exception failure, + string recoveryReason, + string phase) { var failedBackendName = backendName; lock (baselineStateGate) { backendGeneration++; startupBackendError = null; + liveBackendError = null; baselineState = 1; } backend?.Dispose(); backend = null; - startupRecoveryReason = "backend_start_failed"; + startupRecoveryReason = recoveryReason; var failureDetail = CommandErrorWriter.FormatSanitizedExceptionMessage(failure); if (activeBackendAttempt != 0) @@ -285,7 +305,8 @@ async Task ReplaceFailedBackendAfterBaselineAsync(Exception failure) jsonOptions, failedBackendName, startupRecoveryReason, - failureDetail); + failureDetail, + phase); watchExitCode = CommandExitCodes.RuntimeError; return false; } @@ -296,7 +317,8 @@ async Task ReplaceFailedBackendAfterBaselineAsync(Exception failure) failedBackendName, startupRecoveryReason, failureDetail, - baselineCompleted: true); + baselineCompleted: true, + phase: phase); var replacementFailure = await StartBackendAttemptAsync(1).ConfigureAwait(false); if (replacementFailure != null) @@ -306,12 +328,42 @@ async Task ReplaceFailedBackendAfterBaselineAsync(Exception failure) jsonOptions, backendName, startupRecoveryReason, - CommandErrorWriter.FormatSanitizedExceptionMessage(replacementFailure)); + CommandErrorWriter.FormatSanitizedExceptionMessage(replacementFailure), + phase); watchExitCode = CommandExitCodes.RuntimeError; return false; } activeBackendAttempt = 1; + Exception? activationFailure; + lock (baselineStateGate) + { + activationFailure = startupBackendError; + if (activationFailure == null) + { + baselineState = 2; + } + else + { + backendGeneration++; + } + } + + if (activationFailure != null) + { + backend?.Dispose(); + backend = null; + EmitWatchBackendFailure( + baseOptions, + jsonOptions, + backendName, + startupRecoveryReason, + CommandErrorWriter.FormatSanitizedExceptionMessage(activationFailure), + phase); + watchExitCode = CommandExitCodes.RuntimeError; + return false; + } + batcher.RequestFullRescan(failure.Message, startupRecoveryReason); return true; } @@ -408,7 +460,11 @@ void DrainStartupGeneration() startupFailure = startupBackendError; if (startupFailure != null - && !await ReplaceFailedBackendAfterBaselineAsync(startupFailure).ConfigureAwait(false)) + && !await ReplaceFailedBackendAfterBaselineAsync( + startupFailure, + recoveryReason: "backend_start_failed", + phase: "startup") + .ConfigureAwait(false)) { break; } @@ -455,6 +511,28 @@ void DrainStartupGeneration() while (ready && !cancellationToken.IsCancellationRequested) { + Exception? liveFailure; + lock (baselineStateGate) + { + liveFailure = liveBackendError; + liveBackendError = null; + } + + if (liveFailure != null) + { + if (!await ReplaceFailedBackendAfterBaselineAsync( + liveFailure, + recoveryReason: "backend_error", + phase: "incremental") + .ConfigureAwait(false)) + { + ready = false; + break; + } + + continue; + } + try { await Task.Delay(PollIntervalMs, cancellationToken).ConfigureAwait(false); @@ -494,6 +572,13 @@ void DrainStartupGeneration() RecordSubRunExitCode(ref watchExitCode, RunPartialUpdate(baseOptions, jsonOptions, batch, resolvedDbPath, cancellationToken)); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Startup cancellation (including a polling snapshot interrupted during fallback) + // follows the same terminal-event contract as cancellation in the live loop. + // fallback 中の polling snapshot を含む startup cancellation でも、live loop と + // 同じ terminal-event 契約に従って stopped を出力する。 + } finally { lock (baselineStateGate) diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 3978bc0cd..616c0b13b 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -1491,6 +1491,155 @@ public void RunCore_BackendFailureDuringBaseline_PreservesBaselineAndRecoversOnc } } + [Fact] + public void RunCore_LateFatalBackendError_ReplacesBackendAndRecoversOnce_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var firstBackend = new FakeWatchBackend("fsevents"); + var fallbackBackend = new FakeWatchBackend("polling"); + var backends = new Queue([firstBackend, fallbackBackend]); + var baselineScans = 0; + var recoveryScans = 0; + try + { + var options = CreateIssue4858WatchOptions(projectRoot, dbPath); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backends.Dequeue(); + IndexWatchRunner.WatchReadyForTesting = _ => + firstBackend.ReportError(new IOException("late fatal EventStream failure")); + + var capturedOut = RunWatchCoreAndCapture( + options, + projectRoot, + dbPath, + cts, + baselineScan: () => + { + baselineScans++; + return CommandExitCodes.Success; + }, + recoveryScan: phase => + { + Assert.Equal("incremental", phase); + recoveryScans++; + cts.Cancel(); + return CommandExitCodes.Success; + }, + out var exitCode); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(1, baselineScans); + Assert.Equal(1, recoveryScans); + Assert.True(firstBackend.Disposed); + Assert.True(fallbackBackend.Disposed); + var fallbackEvent = FindWatchEvent(capturedOut, "backend_fallback"); + Assert.Equal("incremental", fallbackEvent.GetProperty("phase").GetString()); + Assert.Equal("fsevents", fallbackEvent.GetProperty("backend").GetString()); + Assert.Equal("backend_error", fallbackEvent.GetProperty("recovery_reason").GetString()); + var overflowEvent = FindWatchEvent(capturedOut, "overflow"); + Assert.Equal("incremental", overflowEvent.GetProperty("phase").GetString()); + Assert.Equal("polling", overflowEvent.GetProperty("backend").GetString()); + Assert.Equal("backend_error", overflowEvent.GetProperty("recovery_reason").GetString()); + } + finally + { + IndexWatchRunner.WatchBackendFactoryForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void RunCore_PollingFallbackCancellation_EmitsStoppedWithoutBaseline_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + using var cts = new CancellationTokenSource(); + var firstBackend = new FakeWatchBackend( + "fsevents", + new IOException("simulated EventStream start failure")); + var pollingBackend = new CancelingWatchBackend("polling", cts); + var backends = new Queue([firstBackend, pollingBackend]); + var baselineScans = 0; + try + { + var options = CreateIssue4858WatchOptions(projectRoot, dbPath); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backends.Dequeue(); + + var capturedOut = RunWatchCoreAndCapture( + options, + projectRoot, + dbPath, + cts, + baselineScan: () => + { + baselineScans++; + return CommandExitCodes.Success; + }, + recoveryScan: _ => CommandExitCodes.Success, + out var exitCode); + + Assert.Equal(CommandExitCodes.Success, exitCode); + Assert.Equal(0, baselineScans); + Assert.True(firstBackend.Disposed); + Assert.True(pollingBackend.Disposed); + Assert.Equal( + "backend_start_failed", + FindWatchEvent(capturedOut, "backend_fallback") + .GetProperty("recovery_reason") + .GetString()); + Assert.Equal("stopped", FindWatchEvent(capturedOut, "stopped").GetProperty("status").GetString()); + Assert.DoesNotContain("\"status\":\"watching\"", capturedOut, StringComparison.Ordinal); + } + finally + { + IndexWatchRunner.WatchBackendFactoryForTesting = null; + IndexWatchRunner.WatchReadyForTesting = null; + DeleteDirectory(projectRoot); + } + } + + [Fact] + public void PollingSnapshot_PrunesIgnoredAndInternalTrees_Issue4858() + { + var projectRoot = CreateTempProject(); + var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + try + { + Directory.CreateDirectory(Path.Combine(projectRoot, ".git")); + Directory.CreateDirectory(Path.Combine(projectRoot, ".cdidx")); + Directory.CreateDirectory(Path.Combine(projectRoot, "ignored", "deep")); + Directory.CreateDirectory(Path.Combine(projectRoot, "bin", "deep")); + File.WriteAllText(Path.Combine(projectRoot, ".gitignore"), "ignored/\nbin/\n"); + File.WriteAllText(Path.Combine(projectRoot, ".git", "config"), "[core]\n"); + File.WriteAllText(dbPath, "not a database"); + File.WriteAllText(Path.Combine(projectRoot, "ignored", "deep", "hidden.cs"), "class Hidden {}\n"); + File.WriteAllText(Path.Combine(projectRoot, "bin", "deep", "generated.cs"), "class Generated {}\n"); + File.WriteAllText(Path.Combine(projectRoot, "visible.cs"), "class Visible {}\n"); + + var relativePaths = IndexWatchRunner.CapturePollingSnapshotPathsForTesting( + projectRoot, + projectRoot, + dbPath, + ignoreCase: false, + dbPathExplicit: true) + .Select(path => Path.GetRelativePath(projectRoot, path).Replace('\\', '/')) + .ToArray(); + + Assert.Contains("visible.cs", relativePaths); + Assert.Contains(".gitignore", relativePaths); + Assert.DoesNotContain(relativePaths, path => path.StartsWith(".git/", StringComparison.Ordinal)); + Assert.DoesNotContain(relativePaths, path => path.StartsWith(".cdidx/", StringComparison.Ordinal)); + Assert.DoesNotContain(relativePaths, path => path.StartsWith("ignored/", StringComparison.Ordinal)); + Assert.DoesNotContain(relativePaths, path => path.StartsWith("bin/", StringComparison.Ordinal)); + } + finally + { + DeleteDirectory(projectRoot); + } + } + [Fact] public void RunCore_BackendStartFailureAfterFallback_StopsBeforeBaseline_Issue4858() { @@ -1737,9 +1886,11 @@ public void RunCore_JsonLifecycleEventsHonorIndentedJsonOptions() { var projectRoot = CreateTempProject(); var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var backend = new FakeWatchBackend("fsevents"); try { File.WriteAllText(Path.Combine(projectRoot, "hello.py"), "print('hi')\n"); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backend; var options = new IndexCommandOptions { ProjectPath = projectRoot, @@ -1787,6 +1938,7 @@ public void RunCore_JsonLifecycleEventsHonorIndentedJsonOptions() } finally { + IndexWatchRunner.WatchBackendFactoryForTesting = null; DeleteDirectory(projectRoot); } } @@ -1796,9 +1948,11 @@ public void RunCore_EmitsHumanFriendlyStartStop_WhenJsonDisabled() { var projectRoot = CreateTempProject(); var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); + var backend = new FakeWatchBackend("fsevents"); try { File.WriteAllText(Path.Combine(projectRoot, "hello.py"), "print('hi')\n"); + IndexWatchRunner.WatchBackendFactoryForTesting = (_, _, _) => backend; var options = new IndexCommandOptions { ProjectPath = projectRoot, @@ -1849,6 +2003,7 @@ public void RunCore_EmitsHumanFriendlyStartStop_WhenJsonDisabled() } finally { + IndexWatchRunner.WatchBackendFactoryForTesting = null; DeleteDirectory(projectRoot); } } @@ -2253,4 +2408,28 @@ public void Dispose() { } } + + private sealed class CancelingWatchBackend( + string name, + CancellationTokenSource cancellation) : IndexWatchRunner.IWatchBackend + { + public string Name { get; } = name; + + internal bool Disposed { get; private set; } + + public Task StartAsync( + Action enqueue, + Action reportError, + CancellationToken cancellationToken) + { + cancellation.Cancel(); + cancellationToken.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + + public void Dispose() + { + Disposed = true; + } + } } From 1ea81ba06d471e795c363c51b1f85377ece0258c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Tue, 28 Jul 2026 20:52:24 +0900 Subject: [PATCH 4/4] Handle ancestor watcher backend errors (#4858) --- src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index cec514651..613cde85b 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -72,7 +72,8 @@ private static List CreateAncestorIgnoreWatchers( string projectRoot, string ignoreRuleRoot, bool ignoreCase, - Action enqueue) + Action enqueue, + Action reportError) { var watchers = new List(); var fullProjectRoot = Path.GetFullPath(projectRoot); @@ -111,6 +112,7 @@ private static List CreateAncestorIgnoreWatchers( enqueue(e.OldFullPath); enqueue(e.FullPath); }; + ancestorWatcher.Error += (_, e) => reportError(e.GetException()); ancestorWatcher.EnableRaisingEvents = true; if (string.Equals(directory.FullName, fullIgnoreRuleRoot, comparison)) @@ -363,7 +365,8 @@ public Task StartAsync( _projectRoot, _ignoreRuleRoot, _ignoreCase, - enqueue); + enqueue, + reportError); // FileSystemWatcher can report a fatal EventStream startup error asynchronously // just after EnableRaisingEvents succeeds. Keep startup provisional long enough for