From 0724eaa1352079fdf81780c4be343d566b87e44c Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 00:50:55 +0900 Subject: [PATCH 1/2] Fix macOS net8 subproject watch reconciliation (#4955) --- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 4 ++ changelog.d/unreleased/4955.fixed.md | 18 +++++++++ .../Cli/IndexWatchRunner.Watchers.cs | 29 ++++++++++++++- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 37 ++++++++++++++++++- 5 files changed, 87 insertions(+), 5 deletions(-) create mode 100644 changelog.d/unreleased/4955.fixed.md diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 76fe2516b..bcab55399 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -395,7 +395,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding blocks at the active update-extraction boundary, cancels through the watch sub-run token, and uses an unrelated stdout sentinel to prove the injected runner writer does not replace process-global stdout. Keep the explicit synchronization point and bounded wait; a fixed delay cannot prove that cancellation happened during active work. - The top-level issue-4591 watch cancellation fixtures use the ready callback to cover both an idle loop and a deterministically enqueued active update. They run the synchronous command runner on a dedicated worker and await asynchronous readiness signals so full-suite thread-pool pressure cannot starve startup. A bounded wait must distinguish an early watch exit or fault from a still-running readiness observation timeout, and cleanup must cancel and drain the worker before deleting its temporary project. The FTS/planner fixtures cancel at the existing maintenance hooks; keep the bulk-FTS assertion that synchronization triggers are restored and the marker is recoverable without an active PID owner. - `IndexWatchRunnerTests.ClassifyWatchPath_ReconcilesInputsAndUsesSharedCdidxMembership_Issue4592`, `IndexCommandRunnerTests.RunStatusCheck_CdidxSidecarIsExcludedFromScanAndWorkspaceMembership_Issue4592`, and the issue-4592 update-mode fixtures - 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. + 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. `RunCore_SubprojectObservesAncestorIgnoreFileChanges_Issue4592` is the narrower integration exception: it must create the real ancestor `.gitignore` after readiness. On macOS, its .NET 8 target relies on the real polling delivery selected before the baseline because that runtime can silently miss ancestor FSEvents. The .NET 9 target retains FSEvents in the backend-selection matrix but explicitly forwards the real path through the readiness callback's production enqueue to keep reconciliation deterministic under concurrent target execution. - `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, 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. @@ -1330,7 +1330,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" update extraction の実行中境界で停止させ、watch sub-run token 経由でキャンセルし、無関係な stdout sentinel によって注入 writer が process-global stdout を置き換えないことを検証する。明示的な同期点と bounded wait を維持すること。固定 delay では active work 中のキャンセルを証明できない。 - issue-4591 の top-level watch cancellation fixture は ready callback を使い、idle loop と決定的に enqueue した active update の両方を検証する。同期 command runner は専用 worker で実行し、非同期 readiness signal を await することで、full-suite の thread-pool 負荷が startup を枯渇させないようにする。bounded wait は watch の早期終了または fault と、watch が実行中のまま readiness を観測できない timeout を区別し、cleanup は一時 project を削除する前に worker を cancel して drain しなければならない。FTS / planner fixture は既存の maintenance hook で cancel する。bulk FTS では同期 trigger が復元され、active PID owner を持たない marker から recovery できることの assertion を維持する。 - `IndexWatchRunnerTests.ClassifyWatchPath_ReconcilesInputsAndUsesSharedCdidxMembership_Issue4592`、`IndexCommandRunnerTests.RunStatusCheck_CdidxSidecarIsExcludedFromScanAndWorkspaceMembership_Issue4592`、issue-4592 の update-mode fixture - 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 境界を直接検証する。 + 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 境界を直接検証する。`RunCore_SubprojectObservesAncestorIgnoreFileChanges_Issue4592` は限定的な integration 例外であり、ready 後に実際の祖先 `.gitignore` を作成する。macOS の .NET 8 target は ancestor FSEvents を黙って見落とす可能性があるため baseline 前に選択した実際の polling 配信に依存させる。.NET 9 target は backend 選択 matrix で FSEvents を維持しつつ、target 並行実行時も reconciliation を決定的にするため、実pathを readiness callback の production enqueue へ明示転送する。 - `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 へ切り替わること、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 のどちらも許容する。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 8c10fd664..518a17ddc 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1126,6 +1126,8 @@ By default, `cdidx index` stores the database in `/.cdidx/codeindex `--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. +On macOS, a subproject watch running on .NET 8 selects the existing polling backend before the baseline because that runtime can silently miss changes to ancestor `.gitignore` / `.cdidxignore` files. Top-level .NET 8 watches and .NET 9 subproject watches continue to start with FSEvents, and Linux / Windows backend selection is unchanged. + Indexing scope and ignore handling: | Area | Behavior | @@ -4394,6 +4396,8 @@ interactive terminal では spinner と progress bar が動き続けます。待 `--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` と併用できません。 +macOS では、.NET 8 の subproject watch は ancestor `.gitignore` / `.cdidxignore` 変更を黙って見落とす可能性があるため、baseline 前から既存の polling backend を選択します。.NET 8 の top-level watch と .NET 9 の subproject watch は引き続き FSEvents で開始し、Linux / Windows の backend 選択は変わりません。 + デフォルト出力: ``` diff --git a/changelog.d/unreleased/4955.fixed.md b/changelog.d/unreleased/4955.fixed.md new file mode 100644 index 000000000..53d2f326e --- /dev/null +++ b/changelog.d/unreleased/4955.fixed.md @@ -0,0 +1,18 @@ +--- +category: fixed +issues: + - 4955 +affected: + - src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs + - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs + - USER_GUIDE.md + - TESTING_GUIDE.md +--- + +## English + +- **macOS subproject watchers now reconcile ancestor ignore-file changes reliably on .NET 8 (#4955)** — subproject watches select the existing polling backend before the baseline on .NET 8, avoiding silently missed ancestor FSEvents while preserving FSEvents on .NET 9 and the existing classification, debounce, and full-scan reconciliation contracts. + +## 日本語 + +- **macOS の .NET 8 subproject watcher が祖先 ignore-file 変更を確実に reconcile するようになりました (#4955)** — .NET 8 の subproject watch は baseline 前から既存の polling backend を選択して、祖先 FSEvents の通知欠落を回避します。.NET 9 の FSEvents と既存の classification、debounce、full-scan reconciliation 契約は維持します。 diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index 613cde85b..093bfcffe 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -50,7 +50,7 @@ private static IWatchBackend CreateWatchBackend( bool dbPathExplicit, int attempt) => WatchBackendFactoryForTesting?.Invoke(projectRoot, ignoreRuleRoot, ignoreCase) - ?? (attempt > 0 && OperatingSystem.IsMacOS() + ?? (ShouldUsePollingWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase, attempt) ? new PollingWatchBackend( projectRoot, ignoreRuleRoot, @@ -59,6 +59,33 @@ private static IWatchBackend CreateWatchBackend( dbPathExplicit) : new FileSystemWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase)); + private static bool ShouldUsePollingWatchBackend( + string projectRoot, + string ignoreRuleRoot, + bool ignoreCase, + int attempt) + { + var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var hasAncestorIgnorePaths = !string.Equals( + Path.GetFullPath(projectRoot), + Path.GetFullPath(ignoreRuleRoot), + comparison); + return ShouldUsePollingWatchBackendForTesting( + OperatingSystem.IsMacOS(), + Environment.Version.Major, + attempt, + hasAncestorIgnorePaths); + } + + internal static bool ShouldUsePollingWatchBackendForTesting( + bool isMacOs, + int runtimeMajorVersion, + int attempt, + bool hasAncestorIgnorePaths) + => isMacOs + && (attempt > 0 + || (runtimeMajorVersion <= 8 && hasAncestorIgnorePaths)); + private static string ResolveWatchBackendName() => OperatingSystem.IsMacOS() ? "fsevents" diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 616c0b13b..510221d9a 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -1640,6 +1640,28 @@ public void PollingSnapshot_PrunesIgnoredAndInternalTrees_Issue4858() } } + [Theory] + [InlineData(true, 8, 0, true, true)] + [InlineData(true, 8, 0, false, false)] + [InlineData(true, 9, 0, true, false)] + [InlineData(true, 9, 1, false, true)] + [InlineData(false, 8, 0, true, false)] + public void ShouldUsePollingWatchBackend_Net8MacSubprojectAvoidsMissedAncestorEvents_Issue4955( + bool isMacOs, + int runtimeMajorVersion, + int attempt, + bool hasAncestorIgnorePaths, + bool expected) + { + Assert.Equal( + expected, + IndexWatchRunner.ShouldUsePollingWatchBackendForTesting( + isMacOs, + runtimeMajorVersion, + attempt, + hasAncestorIgnorePaths)); + } + [Fact] public void RunCore_BackendStartFailureAfterFallback_StopsBeforeBaseline_Issue4858() { @@ -2016,6 +2038,7 @@ public void RunCore_SubprojectObservesAncestorIgnoreFileChanges_Issue4592() var dbPath = Path.Combine(projectRoot, ".cdidx", "codeindex.db"); using var cts = new CancellationTokenSource(); using var ready = new ManualResetEventSlim(); + Action? enqueue = null; Task? loopTask = null; try { @@ -2036,11 +2059,21 @@ public void RunCore_SubprojectObservesAncestorIgnoreFileChanges_Issue4592() Watch = true, WatchDebounceMs = 50, }; - IndexWatchRunner.WatchReadyForTesting = _ => ready.Set(); + IndexWatchRunner.WatchReadyForTesting = callback => + { + enqueue = callback; + ready.Set(); + }; loopTask = IndexWatchRunner.RunCoreAsync(options, _jsonOptions, projectRoot, dbPath, cts.Token); Assert.True(ready.Wait(TimeSpan.FromSeconds(15)), "The subproject watcher did not become ready."); - File.WriteAllText(Path.Combine(repoRoot, ".gitignore"), "subproj/ignored.py\n"); + var ignorePath = Path.Combine(repoRoot, ".gitignore"); + File.WriteAllText(ignorePath, "subproj/ignored.py\n"); + if (OperatingSystem.IsMacOS() && Environment.Version.Major >= 9) + { + Assert.NotNull(enqueue); + enqueue(ignorePath); + } Assert.True( SpinWait.SpinUntil( () => From 099e17c602a1b52dee25da00b1cb7a32cf62e036 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Thu, 30 Jul 2026 01:57:43 +0900 Subject: [PATCH 2/2] Bound net8 ancestor ignore polling (#4955) --- DEVELOPER_GUIDE.md | 4 +- TESTING_GUIDE.md | 4 +- USER_GUIDE.md | 8 +- changelog.d/unreleased/4955.fixed.md | 5 +- .../Cli/IndexWatchRunner.Watchers.cs | 305 ++++++++++++++---- .../CodeIndex.Tests/IndexWatchRunnerTests.cs | 52 ++- 6 files changed, 310 insertions(+), 68 deletions(-) diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 61d39ddb4..ac0e91c3e 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -1672,7 +1672,7 @@ cdidx ./myproject --files src/app.cs # specific files only Watch batches reuse this partial-update runner with the top-level cancellation token. Ctrl-C, SIGTERM, or an embedding-host token remains active after the initial scan, so it interrupts idle watch waits, active extraction, FTS recovery/rebuild/optimization, and SQLite planner maintenance instead of waiting for a sub-run to finish. Watch does not install a second console handler, preserving the top-level first-Ctrl-C cooperative / second-Ctrl-C force-exit contract. A cancelled bulk FTS completion restores synchronization triggers and leaves an owner-independent recovery marker when its transaction did not roll back the marker. Long-lived MCP write contexts register each request token to suppress dispose-time planner maintenance after cancellation. Sub-run JSON is routed through `CommandOutputWriter`'s async-local scope into the watch capture writer; the watch loop never replaces `Console.Out`, so other commands and embedding hosts retain their own stdout. -Source membership is shared through `FileIndexer`: full scan, workspace freshness checks, and watch all exclude the `.cdidx` namespace. Watch classifies ignore files plus `.cdidx/patterns/**` and `.cdidx/plugins/**` before applying that source filter, so those non-source inputs remain debounced reconciliation events while ordinary `.cdidx` sidecars stay excluded. Subdirectory watches add non-recursive, ignore-file-only watchers for each ancestor directory through the repository rule root. The resulting `--files` sub-run recognizes extractor inputs, refreshes the process registry generation, and falls back to a full scan that disables unchanged-file reuse so every retained source row is extracted with the new generation. Refresh unloads file-discovered workspace plugins and patterns before loading the current generation, while retaining extractors explicitly registered by an embedding host, so edits and deletions cannot leave extension membership or persisted rows stale. +Source membership is shared through `FileIndexer`: full scan, workspace freshness checks, and watch all exclude the `.cdidx` namespace. Watch classifies ignore files plus `.cdidx/patterns/**` and `.cdidx/plugins/**` before applying that source filter, so those non-source inputs remain debounced reconciliation events while ordinary `.cdidx` sidecars stay excluded. Subdirectory watches normally add non-recursive, ignore-file-only watchers for each ancestor directory through the repository rule root. On macOS/.NET 8, the project tree remains on FSEvents while those exact ancestor `.gitignore` / `.cdidxignore` paths use bounded polling to avoid the runtime's silent ancestor-event miss; recursive project polling remains the failure-recovery backend. The resulting `--files` sub-run recognizes extractor inputs, refreshes the process registry generation, and falls back to a full scan that disables unchanged-file reuse so every retained source row is extracted with the new generation. Refresh unloads file-discovered workspace plugins and patterns before loading the current generation, while retaining extractors explicitly registered by an embedding host, so edits and deletions cannot leave extension membership or persisted rows stale. The watcher is enabled before its startup reconciliation scan. `FileChangeBatcher.TryDrainImmediately` closes the buffered startup generation without waiting for the normal debounce interval; those paths are applied before the `watching` event, while events arriving after the snapshot remain queued as normal live updates. `watching` is emitted only when every startup reconciliation sub-run succeeds; a failed generation returns its non-zero exit instead of discarding the batch and declaring readiness. This generation boundary prevents both the initial-scan subscription gap and unbounded readiness delay on a continuously changing workspace. @@ -5044,7 +5044,7 @@ cdidx ./myproject --files src/app.cs # 特定ファイルのみ watch batch は top-level の cancellation token を使ってこの部分更新 runner を再利用する。Ctrl-C、SIGTERM、埋め込み host token は初回 scan 後も有効なので、sub-run の完了を待たず、idle watch wait、実行中 extraction、FTS recovery / rebuild / optimization、SQLite planner maintenance を中断できる。watch は第2の console handler を登録せず、top-level の「最初の Ctrl-C は協調的 cancellation、2 回目は強制終了」という契約を維持する。cancel された bulk FTS completion は同期 trigger を復元し、transaction rollback で marker が戻らなかった場合は owner 非依存の recovery marker を残す。長寿命 MCP write context は request ごとの token を登録し、cancel 後の dispose-time planner maintenance を抑止する。sub-run JSON は `CommandOutputWriter` の async-local scope から watch capture writer へ送られ、watch loop は `Console.Out` を置き換えないため、他の command や埋め込み host は自身の stdout を維持できる。 -source membership は `FileIndexer` で共有し、full scan、workspace freshness check、watch のすべてで `.cdidx` namespace を除外する。watch は source filter の適用前に ignore file と `.cdidx/patterns/**` / `.cdidx/plugins/**` を分類するため、これらの非 source 入力は debounce 付き reconciliation event として保持し、通常の `.cdidx` sidecar は除外したままにする。subdirectory watch は repository rule root までの各 ancestor directory に non-recursive かつ ignore-file-only の watcher を追加する。生成された `--files` sub-run は extractor 入力を認識し、process registry の generation を refresh してから unchanged-file reuse を無効にした full scan へ fallback し、保持対象の全 source row を新しい generation で抽出し直す。refresh は埋め込み host が明示的に登録した extractor を維持しつつ、file から発見した以前の workspace plugin / pattern を unload して現在の generation を読み込むため、編集や削除後に extension membership や persisted row が stale のまま残らない。 +source membership は `FileIndexer` で共有し、full scan、workspace freshness check、watch のすべてで `.cdidx` namespace を除外する。watch は source filter の適用前に ignore file と `.cdidx/patterns/**` / `.cdidx/plugins/**` を分類するため、これらの非 source 入力は debounce 付き reconciliation event として保持し、通常の `.cdidx` sidecar は除外したままにする。subdirectory watch は通常、repository rule root までの各 ancestor directory に non-recursive かつ ignore-file-only の watcher を追加する。macOS/.NET 8 では project tree の FSEvents を維持しつつ、runtime が祖先 event を黙って見落とす問題を避けるため、ancestor `.gitignore` / `.cdidxignore` の exact path だけを bounded polling する。project の再帰 polling は failure-recovery backend に限定したままにする。生成された `--files` sub-run は extractor 入力を認識し、process registry の generation を refresh してから unchanged-file reuse を無効にした full scan へ fallback し、保持対象の全 source row を新しい generation で抽出し直す。refresh は埋め込み host が明示的に登録した extractor を維持しつつ、file から発見した以前の workspace plugin / pattern を unload して現在の generation を読み込むため、編集や削除後に extension membership や persisted row が stale のまま残らない。 watcher は startup reconciliation scan より先に有効化する。`FileChangeBatcher.TryDrainImmediately` は通常の debounce interval を待たずに buffer 済み startup generation を閉じ、その path を `watching` event より前に適用する一方、snapshot 後に到着した event は通常の live update として queue に残す。すべての startup reconciliation sub-run が成功した場合だけ `watching` を出力し、失敗した generation は batch を捨てて ready を宣言せず non-zero exit を返す。この generation boundary により、初回 scan と subscribe の間の gap と、変更が連続する workspace で ready が無期限に遅れる問題の両方を防ぐ。 diff --git a/TESTING_GUIDE.md b/TESTING_GUIDE.md index 8cf56ae9f..6b7c3beaf 100644 --- a/TESTING_GUIDE.md +++ b/TESTING_GUIDE.md @@ -396,7 +396,7 @@ Use `docs/test-doc-maintenance-plan.md` before moving oversized suites or adding blocks at the active update-extraction boundary, cancels through the watch sub-run token, and uses an unrelated stdout sentinel to prove the injected runner writer does not replace process-global stdout. Keep the explicit synchronization point and bounded wait; a fixed delay cannot prove that cancellation happened during active work. - The top-level issue-4591 watch cancellation fixtures use the ready callback to cover both an idle loop and a deterministically enqueued active update. They run the synchronous command runner on a dedicated worker and await asynchronous readiness signals so full-suite thread-pool pressure cannot starve startup. A bounded wait must distinguish an early watch exit or fault from a still-running readiness observation timeout, and cleanup must cancel and drain the worker before deleting its temporary project. The FTS/planner fixtures cancel at the existing maintenance hooks; keep the bulk-FTS assertion that synchronization triggers are restored and the marker is recoverable without an active PID owner. - `IndexWatchRunnerTests.ClassifyWatchPath_ReconcilesInputsAndUsesSharedCdidxMembership_Issue4592`, `IndexCommandRunnerTests.RunStatusCheck_CdidxSidecarIsExcludedFromScanAndWorkspaceMembership_Issue4592`, and the issue-4592 update-mode fixtures - 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. `RunCore_SubprojectObservesAncestorIgnoreFileChanges_Issue4592` is the narrower integration exception: it must create the real ancestor `.gitignore` after readiness. On macOS, its .NET 8 target relies on the real polling delivery selected before the baseline because that runtime can silently miss ancestor FSEvents. The .NET 9 target retains FSEvents in the backend-selection matrix but explicitly forwards the real path through the readiness callback's production enqueue to keep reconciliation deterministic under concurrent target execution. + 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. `RunCore_SubprojectObservesAncestorIgnoreFileChanges_Issue4592` is the narrower integration exception: it must create the real ancestor `.gitignore` after readiness. On macOS, its .NET 8 target relies on the bounded polling delivery for the exact ancestor ignore paths while project-tree delivery remains on FSEvents. The .NET 9 target retains FSEvents in the backend-selection matrix but explicitly forwards the real path through the readiness callback's production enqueue because host EventStream delivery is not deterministic under concurrent target execution. Keep the selection matrix and exact-path enumeration assertion so the workaround cannot expand into recursive project polling. - `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, 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. @@ -1332,7 +1332,7 @@ dotnet test --filter "FullyQualifiedName~GitHelperTests" update extraction の実行中境界で停止させ、watch sub-run token 経由でキャンセルし、無関係な stdout sentinel によって注入 writer が process-global stdout を置き換えないことを検証する。明示的な同期点と bounded wait を維持すること。固定 delay では active work 中のキャンセルを証明できない。 - issue-4591 の top-level watch cancellation fixture は ready callback を使い、idle loop と決定的に enqueue した active update の両方を検証する。同期 command runner は専用 worker で実行し、非同期 readiness signal を await することで、full-suite の thread-pool 負荷が startup を枯渇させないようにする。bounded wait は watch の早期終了または fault と、watch が実行中のまま readiness を観測できない timeout を区別し、cleanup は一時 project を削除する前に worker を cancel して drain しなければならない。FTS / planner fixture は既存の maintenance hook で cancel する。bulk FTS では同期 trigger が復元され、active PID owner を持たない marker から recovery できることの assertion を維持する。 - `IndexWatchRunnerTests.ClassifyWatchPath_ReconcilesInputsAndUsesSharedCdidxMembership_Issue4592`、`IndexCommandRunnerTests.RunStatusCheck_CdidxSidecarIsExcludedFromScanAndWorkspaceMembership_Issue4592`、issue-4592 の update-mode fixture - 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 境界を直接検証する。`RunCore_SubprojectObservesAncestorIgnoreFileChanges_Issue4592` は限定的な integration 例外であり、ready 後に実際の祖先 `.gitignore` を作成する。macOS の .NET 8 target は ancestor FSEvents を黙って見落とす可能性があるため baseline 前に選択した実際の polling 配信に依存させる。.NET 9 target は backend 選択 matrix で FSEvents を維持しつつ、target 並行実行時も reconciliation を決定的にするため、実pathを readiness callback の production enqueue へ明示転送する。 + 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 境界を直接検証する。`RunCore_SubprojectObservesAncestorIgnoreFileChanges_Issue4592` は限定的な integration 例外であり、ready 後に実際の祖先 `.gitignore` を作成する。macOS の .NET 8 target は project tree の FSEvents を維持しつつ、祖先 ignore path のみを対象にした bounded polling の実配信に依存させる。.NET 9 target は backend 選択 matrix で FSEvents を維持するが、host EventStream 配信は target 並行実行時に決定的ではないため、実 path を readiness callback の production enqueue へ明示転送する。workaround が project の再帰 polling へ拡大しないよう、選択 matrix と exact-path 列挙 assertion を維持する。 - `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 へ切り替わること、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 のどちらも許容する。 diff --git a/USER_GUIDE.md b/USER_GUIDE.md index 518a17ddc..094ba6ca9 100644 --- a/USER_GUIDE.md +++ b/USER_GUIDE.md @@ -1126,7 +1126,7 @@ By default, `cdidx index` stores the database in `/.cdidx/codeindex `--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. -On macOS, a subproject watch running on .NET 8 selects the existing polling backend before the baseline because that runtime can silently miss changes to ancestor `.gitignore` / `.cdidxignore` files. Top-level .NET 8 watches and .NET 9 subproject watches continue to start with FSEvents, and Linux / Windows backend selection is unchanged. +On macOS, a subproject watch running on .NET 8 keeps FSEvents for the project tree and additionally polls only the exact ancestor `.gitignore` / `.cdidxignore` paths because that runtime can silently miss those ancestor events. Full-project polling remains reserved for backend failure recovery; top-level .NET 8 watches, .NET 9 subproject watches, and Linux / Windows backend selection are unchanged. Indexing scope and ignore handling: @@ -2098,7 +2098,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 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. | +| `--watch` | `index` | Start the filesystem backend before one baseline scan, then stay running and reindex incrementally as files change (FileSystemWatcher / inotify / FSEvents). On macOS/.NET 8, subproject watches keep FSEvents for the project tree and add bounded polling only for exact ancestor `.gitignore` / `.cdidxignore` paths. A recoverable macOS EventStream startup or later fatal error switches to full-project polling without repeating the baseline; a failure during or after the baseline requests one recovery scan for the handoff. Full-project 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. | @@ -4396,7 +4396,7 @@ interactive terminal では spinner と progress bar が動き続けます。待 `--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` と併用できません。 -macOS では、.NET 8 の subproject watch は ancestor `.gitignore` / `.cdidxignore` 変更を黙って見落とす可能性があるため、baseline 前から既存の polling backend を選択します。.NET 8 の top-level watch と .NET 9 の subproject watch は引き続き FSEvents で開始し、Linux / Windows の backend 選択は変わりません。 +macOS では、.NET 8 の subproject watch は project tree の FSEvents を維持しつつ、この runtime が黙って見落とす可能性のある ancestor `.gitignore` / `.cdidxignore` の exact path だけを追加で polling します。project 全体の polling は backend failure recovery に限定したままです。.NET 8 の top-level watch、.NET 9 の subproject watch、Linux / Windows の backend 選択は変わりません。 デフォルト出力: @@ -5325,7 +5325,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 起動失敗または 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` との併用は拒否する。 | +| `--watch` | `index` | filesystem backend(FileSystemWatcher / inotify / FSEvents)を baseline scan 1 回より先に開始し、その後もプロセスを残してファイル変更を差分反映する。macOS/.NET 8 の subproject watch は project tree の FSEvents を維持し、ancestor `.gitignore` / `.cdidxignore` の exact path だけを bounded polling で補完する。回復可能な macOS EventStream 起動失敗または ready 後の致命的 error は baseline を繰り返さず project 全体の polling へ切り替え、baseline 中または ready 後の失敗なら handoff recovery scan を1回だけ実行する。full-project 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/4955.fixed.md b/changelog.d/unreleased/4955.fixed.md index 53d2f326e..22b912967 100644 --- a/changelog.d/unreleased/4955.fixed.md +++ b/changelog.d/unreleased/4955.fixed.md @@ -6,13 +6,14 @@ affected: - src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs - tests/CodeIndex.Tests/IndexWatchRunnerTests.cs - USER_GUIDE.md + - DEVELOPER_GUIDE.md - TESTING_GUIDE.md --- ## English -- **macOS subproject watchers now reconcile ancestor ignore-file changes reliably on .NET 8 (#4955)** — subproject watches select the existing polling backend before the baseline on .NET 8, avoiding silently missed ancestor FSEvents while preserving FSEvents on .NET 9 and the existing classification, debounce, and full-scan reconciliation contracts. +- **macOS subproject watchers now reconcile ancestor ignore-file changes reliably on .NET 8 (#4955)** — subproject watches keep FSEvents for the project tree and add bounded polling only for exact ancestor ignore paths on .NET 8, avoiding silently missed ancestor events without imposing recursive polling while preserving .NET 9 and the existing classification, debounce, and full-scan reconciliation contracts. ## 日本語 -- **macOS の .NET 8 subproject watcher が祖先 ignore-file 変更を確実に reconcile するようになりました (#4955)** — .NET 8 の subproject watch は baseline 前から既存の polling backend を選択して、祖先 FSEvents の通知欠落を回避します。.NET 9 の FSEvents と既存の classification、debounce、full-scan reconciliation 契約は維持します。 +- **macOS の .NET 8 subproject watcher が祖先 ignore-file 変更を確実に reconcile するようになりました (#4955)** — .NET 8 の subproject watch は project tree の FSEvents を維持しつつ、祖先 ignore path のみを bounded polling して、再帰 polling の負荷を加えず通知欠落を回避します。.NET 9 と既存の classification、debounce、full-scan reconciliation 契約は維持します。 diff --git a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs index 093bfcffe..88e2702a5 100644 --- a/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs +++ b/src/CodeIndex/Cli/IndexWatchRunner.Watchers.cs @@ -42,6 +42,12 @@ internal static IReadOnlyCollection CapturePollingSnapshotPathsForTestin return backend.CaptureSnapshotPaths(cancellationToken); } + internal static IReadOnlyCollection CaptureAncestorIgnorePollingPathsForTesting( + string projectRoot, + string ignoreRuleRoot, + bool ignoreCase) + => EnumerateAncestorIgnorePaths(projectRoot, ignoreRuleRoot, ignoreCase).ToArray(); + private static IWatchBackend CreateWatchBackend( string projectRoot, string ignoreRuleRoot, @@ -49,42 +55,64 @@ private static IWatchBackend CreateWatchBackend( bool ignoreCase, bool dbPathExplicit, int attempt) - => WatchBackendFactoryForTesting?.Invoke(projectRoot, ignoreRuleRoot, ignoreCase) - ?? (ShouldUsePollingWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase, attempt) - ? new PollingWatchBackend( - projectRoot, - ignoreRuleRoot, - resolvedDbPath, - ignoreCase, - dbPathExplicit) - : new FileSystemWatchBackend(projectRoot, ignoreRuleRoot, ignoreCase)); - - private static bool ShouldUsePollingWatchBackend( + { + var backendOverride = WatchBackendFactoryForTesting?.Invoke(projectRoot, ignoreRuleRoot, ignoreCase); + if (backendOverride != null) + return backendOverride; + + if (ShouldUseFullPollingWatchBackendForTesting(OperatingSystem.IsMacOS(), attempt)) + { + return new PollingWatchBackend( + projectRoot, + ignoreRuleRoot, + resolvedDbPath, + ignoreCase, + dbPathExplicit); + } + + return new FileSystemWatchBackend( + projectRoot, + ignoreRuleRoot, + ignoreCase, + pollAncestorIgnorePaths: ShouldPollAncestorIgnorePaths( + projectRoot, + ignoreRuleRoot, + ignoreCase, + attempt)); + } + + private static bool ShouldPollAncestorIgnorePaths( string projectRoot, string ignoreRuleRoot, bool ignoreCase, int attempt) { var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - var hasAncestorIgnorePaths = !string.Equals( + var hasAncestorIgnorePaths = !IsSamePath( Path.GetFullPath(projectRoot), Path.GetFullPath(ignoreRuleRoot), comparison); - return ShouldUsePollingWatchBackendForTesting( + return ShouldPollAncestorIgnorePathsForTesting( OperatingSystem.IsMacOS(), Environment.Version.Major, attempt, hasAncestorIgnorePaths); } - internal static bool ShouldUsePollingWatchBackendForTesting( + internal static bool ShouldUseFullPollingWatchBackendForTesting( + bool isMacOs, + int attempt) + => isMacOs && attempt > 0; + + internal static bool ShouldPollAncestorIgnorePathsForTesting( bool isMacOs, int runtimeMajorVersion, int attempt, bool hasAncestorIgnorePaths) => isMacOs - && (attempt > 0 - || (runtimeMajorVersion <= 8 && hasAncestorIgnorePaths)); + && runtimeMajorVersion <= 8 + && attempt == 0 + && hasAncestorIgnorePaths; private static string ResolveWatchBackendName() => OperatingSystem.IsMacOS() @@ -103,10 +131,10 @@ private static List CreateAncestorIgnoreWatchers( Action reportError) { var watchers = new List(); - var fullProjectRoot = Path.GetFullPath(projectRoot); - var fullIgnoreRuleRoot = Path.GetFullPath(ignoreRuleRoot); + var fullProjectRoot = TrimDirectorySeparators(Path.GetFullPath(projectRoot)); + var fullIgnoreRuleRoot = TrimDirectorySeparators(Path.GetFullPath(ignoreRuleRoot)); var comparison = ignoreCase ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; - if (string.Equals(fullProjectRoot, fullIgnoreRuleRoot, comparison)) + if (IsSamePath(fullProjectRoot, fullIgnoreRuleRoot, comparison)) return watchers; var relativeProjectRoot = Path.GetRelativePath(fullIgnoreRuleRoot, fullProjectRoot); @@ -158,6 +186,39 @@ private static List CreateAncestorIgnoreWatchers( return watchers; } + private static IEnumerable EnumerateAncestorIgnorePaths( + string projectRoot, + string ignoreRuleRoot, + bool ignoreCase) + { + var fullProjectRoot = TrimDirectorySeparators(Path.GetFullPath(projectRoot)); + var fullIgnoreRuleRoot = TrimDirectorySeparators(Path.GetFullPath(ignoreRuleRoot)); + var comparison = ignoreCase + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + if (IsSamePath(fullProjectRoot, fullIgnoreRuleRoot, comparison)) + yield break; + + var relativeProjectRoot = Path.GetRelativePath(fullIgnoreRuleRoot, fullProjectRoot); + if (Path.IsPathRooted(relativeProjectRoot) + || relativeProjectRoot == ".." + || relativeProjectRoot.StartsWith($"..{Path.DirectorySeparatorChar}", StringComparison.Ordinal) + || relativeProjectRoot.StartsWith($"..{Path.AltDirectorySeparatorChar}", StringComparison.Ordinal)) + { + yield break; + } + + var directory = Directory.GetParent(fullProjectRoot); + while (directory != null) + { + yield return Path.Combine(directory.FullName, ".gitignore"); + yield return Path.Combine(directory.FullName, ".cdidxignore"); + if (IsSamePath(directory.FullName, fullIgnoreRuleRoot, comparison)) + yield break; + directory = directory.Parent; + } + } + private static int RunPartialUpdate( IndexCommandOptions baseOptions, JsonSerializerOptions jsonOptions, @@ -343,17 +404,21 @@ private sealed class FileSystemWatchBackend : IWatchBackend private readonly string _projectRoot; private readonly string _ignoreRuleRoot; private readonly bool _ignoreCase; + private readonly bool _pollAncestorIgnorePaths; private FileSystemWatcher? _watcher; private List? _ancestorIgnoreWatchers; + private AncestorIgnorePollingWatcher? _ancestorIgnorePollingWatcher; internal FileSystemWatchBackend( string projectRoot, string ignoreRuleRoot, - bool ignoreCase) + bool ignoreCase, + bool pollAncestorIgnorePaths) { _projectRoot = projectRoot; _ignoreRuleRoot = ignoreRuleRoot; _ignoreCase = ignoreCase; + _pollAncestorIgnorePaths = pollAncestorIgnorePaths; } public string Name => ResolveWatchBackendName(); @@ -388,12 +453,23 @@ public Task StartAsync( watcher.Error += (_, e) => reportError(e.GetException()); watcher.EnableRaisingEvents = true; - _ancestorIgnoreWatchers = CreateAncestorIgnoreWatchers( - _projectRoot, - _ignoreRuleRoot, - _ignoreCase, - enqueue, - reportError); + if (_pollAncestorIgnorePaths) + { + _ancestorIgnorePollingWatcher = new AncestorIgnorePollingWatcher( + _projectRoot, + _ignoreRuleRoot, + _ignoreCase); + _ancestorIgnorePollingWatcher.Start(enqueue, reportError, cancellationToken); + } + else + { + _ancestorIgnoreWatchers = CreateAncestorIgnoreWatchers( + _projectRoot, + _ignoreRuleRoot, + _ignoreCase, + enqueue, + reportError); + } // FileSystemWatcher can report a fatal EventStream startup error asynchronously // just after EnableRaisingEvents succeeds. Keep startup provisional long enough for @@ -408,6 +484,9 @@ public Task StartAsync( public void Dispose() { + _ancestorIgnorePollingWatcher?.Dispose(); + _ancestorIgnorePollingWatcher = null; + if (_ancestorIgnoreWatchers != null) { foreach (var ancestorWatcher in _ancestorIgnoreWatchers) @@ -427,6 +506,147 @@ public void Dispose() } } + private sealed class AncestorIgnorePollingWatcher : IDisposable + { + private readonly record struct FileStamp( + bool Exists, + long Length, + long LastWriteUtcTicks, + long CreationUtcTicks); + + private readonly IReadOnlyList _paths; + private readonly StringComparer _pathComparer; + private CancellationTokenSource? _loopCancellation; + private Task? _loopTask; + private Dictionary? _snapshot; + private Action? _enqueue; + private Action? _reportError; + private bool _started; + + internal AncestorIgnorePollingWatcher( + string projectRoot, + string ignoreRuleRoot, + bool ignoreCase) + { + _paths = EnumerateAncestorIgnorePaths(projectRoot, ignoreRuleRoot, ignoreCase).ToArray(); + _pathComparer = ignoreCase ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal; + } + + internal void Start( + 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; + } + + 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); + } + + _snapshot = next; + } + + private Dictionary CaptureSnapshot(CancellationToken cancellationToken) + { + var snapshot = new Dictionary(_pathComparer); + foreach (var path in _paths) + { + cancellationToken.ThrowIfCancellationRequested(); + snapshot[path] = CaptureFileStamp(path); + } + + return snapshot; + } + + private static FileStamp CaptureFileStamp(string path) + { + try + { + var info = new FileInfo(path); + return info.Exists + ? new FileStamp( + Exists: true, + info.Length, + info.LastWriteTimeUtc.Ticks, + info.CreationTimeUtc.Ticks) + : default; + } + catch (Exception ex) when (ex is FileNotFoundException or DirectoryNotFoundException) + { + return default; + } + } + + 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; + } + } + private sealed class PollingWatchBackend : IWatchBackend { private readonly record struct FileStamp( @@ -576,7 +796,10 @@ private Dictionary CaptureSnapshot(CancellationToken cancella } } - foreach (var ignorePath in EnumerateAncestorIgnorePaths()) + foreach (var ignorePath in EnumerateAncestorIgnorePaths( + _projectRoot, + _ignoreRuleRoot, + _ignoreCase)) AddFileStamp(snapshot, ignorePath); return snapshot; @@ -599,34 +822,6 @@ private bool ShouldTrackFile(string path) && !_fileIndexer.ShouldSkipPath(path); } - 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 diff --git a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs index 510221d9a..d311ad6b6 100644 --- a/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs +++ b/tests/CodeIndex.Tests/IndexWatchRunnerTests.cs @@ -1644,9 +1644,9 @@ public void PollingSnapshot_PrunesIgnoredAndInternalTrees_Issue4858() [InlineData(true, 8, 0, true, true)] [InlineData(true, 8, 0, false, false)] [InlineData(true, 9, 0, true, false)] - [InlineData(true, 9, 1, false, true)] + [InlineData(true, 8, 1, true, false)] [InlineData(false, 8, 0, true, false)] - public void ShouldUsePollingWatchBackend_Net8MacSubprojectAvoidsMissedAncestorEvents_Issue4955( + public void ShouldPollAncestorIgnorePaths_Net8MacSubprojectAvoidsMissedEvents_Issue4955( bool isMacOs, int runtimeMajorVersion, int attempt, @@ -1655,13 +1655,59 @@ public void ShouldUsePollingWatchBackend_Net8MacSubprojectAvoidsMissedAncestorEv { Assert.Equal( expected, - IndexWatchRunner.ShouldUsePollingWatchBackendForTesting( + IndexWatchRunner.ShouldPollAncestorIgnorePathsForTesting( isMacOs, runtimeMajorVersion, attempt, hasAncestorIgnorePaths)); } + [Theory] + [InlineData(true, 0, false)] + [InlineData(true, 1, true)] + [InlineData(false, 1, false)] + public void ShouldUseFullPollingWatchBackend_OnlyForMacFallbackAttempts_Issue4955( + bool isMacOs, + int attempt, + bool expected) + { + Assert.Equal( + expected, + IndexWatchRunner.ShouldUseFullPollingWatchBackendForTesting(isMacOs, attempt)); + } + + [Fact] + public void AncestorIgnorePollingPaths_AreBoundedToExactAncestorFiles_Issue4955() + { + var repoRoot = CreateTempProject(); + var projectRoot = Path.Combine(repoRoot, "parent", "subproject"); + try + { + Directory.CreateDirectory(projectRoot); + + var relativePaths = IndexWatchRunner.CaptureAncestorIgnorePollingPathsForTesting( + projectRoot, + repoRoot, + ignoreCase: false) + .Select(path => Path.GetRelativePath(repoRoot, path).Replace('\\', '/')) + .OrderBy(path => path, StringComparer.Ordinal) + .ToArray(); + + Assert.Equal( + [ + ".cdidxignore", + ".gitignore", + "parent/.cdidxignore", + "parent/.gitignore", + ], + relativePaths); + } + finally + { + DeleteDirectory(repoRoot); + } + } + [Fact] public void RunCore_BackendStartFailureAfterFallback_StopsBeforeBaseline_Issue4858() {