Skip to content

Commit eb8dbf5

Browse files
authored
[codex] Fix index watch failure handling (#2956)
* Fix watch pending batch cap (#2903) * Propagate watch sub-run failures (#2902) * Document watch failure events (#2902) * Document watch pending cap fallback (#2903)
1 parent 6b36488 commit eb8dbf5

6 files changed

Lines changed: 253 additions & 26 deletions

File tree

USER_GUIDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -724,7 +724,7 @@ small repositories, and minutes or longer on very large monorepos with around
724724

725725
By default, `cdidx index` stores the database in `<projectPath>/.cdidx/codeindex.db`, even if you run the command from another directory.
726726

727-
`--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 <ms>`, 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. With `--json` it streams `status: "watching" / "updated" / "rescanned" / "overflow" / "stopped"` lifecycle events to stdout; otherwise it writes `[watch] …` summaries to stderr. Stop the loop with Ctrl+C (or SIGTERM); the final exit code is `0` for a clean stop. `--watch` cannot be combined with `--commits`, `--files`, or `--dry-run` — the loop already drives continuous incremental updates.
727+
`--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 <ms>`, 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. 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 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.
728728

729729
Indexing keeps the built-in skip lists (`node_modules`, `bin`, `obj`, lockfiles, etc.) and also honors user `.gitignore` plus optional `.cdidxignore` rules across full scans, `--files`, and `--commits` updates. A project-root `.codeindex/.cdidxignore` is also loaded as a workspace-scoped ignore file, which lets multi-workspace manifests keep local cdidx-only ignore rules out of the repository root. Ignore files are read as UTF-8, so non-ASCII patterns behave the same across platforms. On Windows, paths marked with the Hidden or System attribute are skipped before language detection so broad scans do not enter OS-owned caches such as `System Volume Information` or `$Recycle.Bin`; clear those attributes before indexing project-owned source files because ignore rules only exclude additional paths. When the project is inside Git, ignore matching follows the repository's `core.ignorecase` setting, even when the indexed project path is a subdirectory inside that repo; repo-root and other ancestor `.gitignore` files above that subdirectory still apply, and `--commits` resolves changed paths from the repository root before narrowing them back to the indexed project root. Nested directories that contain their own `.git` directory or gitfile are treated as repository boundaries and skipped by default. Indexed file paths are stored in Unicode NFC form so composed and decomposed path spellings match across platforms. `**` only gets Git-style special handling in the documented path forms rather than as an unrestricted cross-directory wildcard. If an update refresh includes ignore-file changes, cdidx automatically falls back to a full scan so newly ignored files are purged safely. Invalid ignore lines are skipped with a warning instead of aborting the whole run, while unreadable ignore files fail closed for that directory scope so cdidx does not index with incomplete rules.
730730

@@ -2863,7 +2863,7 @@ interactive terminal では spinner と progress bar が動き続けます。待
28632863

28642864
`cdidx index` は、別ディレクトリから実行しても、デフォルトでは `<projectPath>/.cdidx/codeindex.db` にDBを保存します。
28652865

2866-
`--watch` を付けると初回スキャン後もプロセスが残り、`FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)でファイルの作成・編集・リネーム・削除を検知して差分更新を繰り返します。`--debounce <ms>`(既定 500 ms)の窓内で発生したイベントは 1 つの `--files` 更新にまとめられ、バッチ間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。Watcher バッファがオーバーフローした場合は変更を黙って捨てる代わりにフル差分再走査へフォールバックします。`--json` 時は `status: "watching" / "updated" / "rescanned" / "overflow" / "stopped"` のライフサイクルイベントを stdout に流し、そうでなければ `[watch] …` の要約を stderr に出力します。Ctrl+C(または SIGTERM)で正常に停止し、終了コードは正常停止で `0` です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。
2866+
`--watch` を付けると初回スキャン後もプロセスが残り、`FileSystemWatcher`(macOS は FSEvents、Linux は inotify、Windows は ReadDirectoryChangesW)でファイルの作成・編集・リネーム・削除を検知して差分更新を繰り返します。`--debounce <ms>`(既定 500 ms)の窓内で発生したイベントは 1 つの `--files` 更新にまとめられ、バッチ間ではデータベースごとの index lock を解放するため別の `cdidx` コマンドからの問い合わせも可能です。Watcher バッファがオーバーフローした場合や pending path batch が安全上限に達した場合は、変更を黙って捨てる代わりにフル差分再走査へフォールバックします。`--json` 時は `status: "watching" / "updated" / "rescanned" / "overflow" / "failed" / "stopped"` のライフサイクルイベントを stdout に流し、update/rescan event には `exit_code` を含めます。そうでなければ exit code 付きの `[watch] …` 要約を stderr に出力します。Ctrl+C(または SIGTERM)で停止し、すべての batch が成功していれば終了コードは `0`、停止前に watch update/rescan が失敗していれば直近の non-zero sub-run exit code です。`--watch` は連続的な差分更新を内蔵しているため `--commits` / `--files` / `--dry-run` と併用できません。
28672867

28682868
デフォルト出力:
28692869

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 2902
5+
affected:
6+
- USER_GUIDE.md
7+
- src/CodeIndex/Cli/IndexWatchRunner.cs
8+
- src/CodeIndex/Cli/JsonOutputContracts.cs
9+
- tests/CodeIndex.Tests/IndexWatchRunnerTests.cs
10+
---
11+
12+
## English
13+
14+
- **`index --watch` now reports failed sub-runs (#2902)** — watch batch events and human summaries include the sub-run exit code, and failed sub-runs are surfaced as failed watch batches instead of normal updates.
15+
16+
## 日本語
17+
18+
- **`index --watch` が sub-run failure を報告するようになりました (#2902)** — watch batch event と human summary に sub-run exit code を含め、失敗した sub-run を通常更新ではなく failed batch として表示します。
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
---
2+
category: fixed
3+
issues:
4+
- 2903
5+
affected:
6+
- USER_GUIDE.md
7+
- src/CodeIndex/Cli/IndexWatchRunner.cs
8+
- tests/CodeIndex.Tests/IndexWatchRunnerTests.cs
9+
---
10+
11+
## English
12+
13+
- **`index --watch` now caps pending path batches (#2903)** — watch mode collapses very large pending file-event sets into a full rescan request instead of retaining an unbounded list of changed paths.
14+
15+
## 日本語
16+
17+
- **`index --watch` の pending path batch に上限を設けました (#2903)** — watch mode は大量のファイルイベントを個別 path の無制限リストとして保持せず、full rescan 要求へ畳み込むようになりました。

src/CodeIndex/Cli/IndexWatchRunner.cs

Lines changed: 82 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ internal static int RunCore(
5454

5555
var ignoreRuleRoot = GitHelper.TryGetRepositoryRoot(projectRoot) ?? Path.GetFullPath(projectRoot);
5656
var fileIndexer = new FileIndexer(projectRoot, ignoreCase, ignoreRuleRoot);
57+
var watchExitCode = CommandExitCodes.Success;
5758

5859
FileSystemWatcher? watcher = null;
5960
try
@@ -133,14 +134,14 @@ void Enqueue(string fullPath)
133134
if (fullRescan)
134135
{
135136
EmitWatchOverflow(baseOptions, overflowReason);
136-
RunFullRescan(baseOptions, jsonOptions);
137+
RecordSubRunExitCode(ref watchExitCode, RunFullRescan(baseOptions, jsonOptions));
137138
continue;
138139
}
139140

140141
if (batch.Count == 0)
141142
continue;
142143

143-
RunPartialUpdate(baseOptions, jsonOptions, batch);
144+
RecordSubRunExitCode(ref watchExitCode, RunPartialUpdate(baseOptions, jsonOptions, batch));
144145
}
145146
}
146147
finally
@@ -153,10 +154,10 @@ void Enqueue(string fullPath)
153154
}
154155

155156
EmitWatchStopped(baseOptions);
156-
return CommandExitCodes.Success;
157+
return watchExitCode;
157158
}
158159

159-
private static void RunPartialUpdate(
160+
private static int RunPartialUpdate(
160161
IndexCommandOptions baseOptions,
161162
JsonSerializerOptions jsonOptions,
162163
IReadOnlyList<string> changedPaths)
@@ -167,18 +168,24 @@ private static void RunPartialUpdate(
167168
foreach (var path in changedPaths)
168169
args.Add(path);
169170

170-
InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "updated", changedPaths.Count);
171+
return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "updated", changedPaths.Count);
171172
}
172173

173-
private static void RunFullRescan(
174+
private static int RunFullRescan(
174175
IndexCommandOptions baseOptions,
175176
JsonSerializerOptions jsonOptions)
176177
{
177178
var stopwatch = Stopwatch.StartNew();
178179
var args = BuildSubRunArgs(baseOptions);
179180
// No --files: this is a default incremental full scan.
180181
// --files を付けない: 通常のインクリメンタル全件スキャン。
181-
InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null);
182+
return InvokeSubRunAndEmit(baseOptions, jsonOptions, args, stopwatch, "rescanned", batchSize: null);
183+
}
184+
185+
private static void RecordSubRunExitCode(ref int watchExitCode, int subRunExitCode)
186+
{
187+
if (subRunExitCode != CommandExitCodes.Success)
188+
watchExitCode = subRunExitCode;
182189
}
183190

184191
private static List<string> BuildSubRunArgs(IndexCommandOptions baseOptions)
@@ -214,7 +221,7 @@ private static List<string> BuildSubRunArgs(IndexCommandOptions baseOptions)
214221
return args;
215222
}
216223

217-
private static void InvokeSubRunAndEmit(
224+
private static int InvokeSubRunAndEmit(
218225
IndexCommandOptions baseOptions,
219226
JsonSerializerOptions jsonOptions,
220227
List<string> args,
@@ -223,19 +230,24 @@ private static void InvokeSubRunAndEmit(
223230
int? batchSize)
224231
{
225232
string capturedJson;
233+
int subRunExitCode;
226234
var previousOut = Console.Out;
227235
using var captureWriter = new StringWriter();
228236
Console.SetOut(captureWriter);
229237
try
230238
{
231-
IndexCommandRunner.Run(args.ToArray(), jsonOptions);
239+
subRunExitCode = IndexCommandRunner.Run(args.ToArray(), jsonOptions);
232240
}
233241
finally
234242
{
235243
Console.SetOut(previousOut);
236244
}
237245
stopwatch.Stop();
238246
capturedJson = captureWriter.ToString();
247+
var eventStatus = subRunExitCode == CommandExitCodes.Success ? status : "failed";
248+
var failureReason = subRunExitCode == CommandExitCodes.Success
249+
? null
250+
: $"{status} sub-run exited with code {subRunExitCode.ToString(CultureInfo.InvariantCulture)}";
239251

240252
if (baseOptions.Json)
241253
{
@@ -244,9 +256,11 @@ private static void InvokeSubRunAndEmit(
244256
// watch バッチであることを示すヘッダ行を先頭に流し、その後にサブ実行 JSON を出す。
245257
Console.Out.WriteLine(JsonSerializer.Serialize(new IndexWatchEventJsonResult
246258
{
247-
Status = status,
259+
Status = eventStatus,
248260
BatchSize = batchSize,
249261
ElapsedMs = stopwatch.ElapsedMilliseconds,
262+
ExitCode = subRunExitCode,
263+
Reason = failureReason,
250264
}, CliJsonSerializerContextFactory.Create(jsonOptions).IndexWatchEventJsonResult));
251265

252266
var trimmed = capturedJson.TrimEnd('\r', '\n');
@@ -255,22 +269,32 @@ private static void InvokeSubRunAndEmit(
255269
}
256270
else
257271
{
258-
var human = FormatHumanSummary(status, batchSize, stopwatch.ElapsedMilliseconds, capturedJson);
272+
var human = FormatHumanSummary(eventStatus, batchSize, stopwatch.ElapsedMilliseconds, capturedJson, subRunExitCode);
259273
Console.Error.WriteLine(human);
260274
}
275+
276+
return subRunExitCode;
261277
}
262278

263-
private static string FormatHumanSummary(string status, int? batchSize, long elapsedMs, string subRunJson)
279+
private static string FormatHumanSummary(string status, int? batchSize, long elapsedMs, string subRunJson, int exitCode)
264280
{
265-
var prefix = status == "rescanned" ? "[watch] rescanned" : "[watch] updated";
281+
var prefix = status switch
282+
{
283+
"rescanned" => "[watch] rescanned",
284+
"failed" => "[watch] failed",
285+
_ => "[watch] updated",
286+
};
266287
var batchLabel = batchSize is int n
267288
? $" {ConsoleUi.Counted(n, "path", format: "N0")}"
268289
: string.Empty;
269290

270291
// Best-effort parse of the sub-run JSON to surface updated/removed/errors counts.
271292
// The summary is informational; a parse failure must not break the watch loop.
272293
// サブ実行 JSON から件数を best-effort で抽出。失敗してもループは続行する。
273-
string detail = string.Empty;
294+
var details = new List<string>
295+
{
296+
$"exit code {exitCode.ToString(CultureInfo.InvariantCulture)}",
297+
};
274298
try
275299
{
276300
var trimmed = subRunJson.TrimEnd('\r', '\n');
@@ -284,15 +308,17 @@ private static string FormatHumanSummary(string status, int? batchSize, long ela
284308
int updated = summary.TryGetProperty("updated", out var u) && u.TryGetInt32(out var uv) ? uv : 0;
285309
int removed = summary.TryGetProperty("removed", out var r) && r.TryGetInt32(out var rv) ? rv : 0;
286310
int errors = summary.TryGetProperty("errors", out var er) && er.TryGetInt32(out var erv) ? erv : 0;
287-
detail = $" (updated {updated}, removed {removed}, errors {errors})";
311+
details.Add($"updated {updated}");
312+
details.Add($"removed {removed}");
313+
details.Add($"errors {errors}");
288314
}
289315
}
290316
}
291317
catch (JsonException)
292318
{
293-
detail = string.Empty;
294319
}
295320

321+
var detail = details.Count > 0 ? $" ({string.Join(", ", details)})" : string.Empty;
296322
return $"{prefix}{batchLabel}{detail} in {elapsedMs.ToString("N0", System.Globalization.CultureInfo.InvariantCulture)} ms";
297323
}
298324

@@ -373,18 +399,29 @@ private static void EmitWatchStopped(IndexCommandOptions baseOptions)
373399
/// </summary>
374400
internal sealed class FileChangeBatcher
375401
{
402+
internal const int DefaultMaxPendingPaths = 4096;
403+
376404
private readonly object _gate = new();
377405
private readonly HashSet<string> _pending;
378406
private DateTime _lastEventUtc = DateTime.MinValue;
379407
private bool _overflowRequested;
380408
private string? _overflowReason;
381409
private readonly TimeSpan _debounce;
382410
private readonly Func<DateTime> _clock;
411+
private readonly int _maxPendingPaths;
383412

384-
public FileChangeBatcher(TimeSpan debounce, Func<DateTime>? clock = null, bool ignoreCase = true)
413+
public FileChangeBatcher(
414+
TimeSpan debounce,
415+
Func<DateTime>? clock = null,
416+
bool ignoreCase = true,
417+
int maxPendingPaths = DefaultMaxPendingPaths)
385418
{
419+
if (maxPendingPaths <= 0)
420+
throw new ArgumentOutOfRangeException(nameof(maxPendingPaths), "Maximum pending path count must be positive.");
421+
386422
_debounce = debounce;
387423
_clock = clock ?? (() => DateTime.UtcNow);
424+
_maxPendingPaths = maxPendingPaths;
388425
// On case-sensitive filesystems (Linux ext4), `foo.py` and `Foo.py` are distinct files,
389426
// so coalescing them via OrdinalIgnoreCase would drop one rename leg and leave the
390427
// renamed-to file unindexed. The watch loop passes the filesystem's case sensitivity in.
@@ -397,7 +434,24 @@ public void Add(string path)
397434
{
398435
lock (_gate)
399436
{
400-
_pending.Add(path);
437+
if (_overflowRequested)
438+
{
439+
_lastEventUtc = _clock();
440+
return;
441+
}
442+
443+
if (!_pending.Contains(path))
444+
{
445+
if (_pending.Count >= _maxPendingPaths)
446+
{
447+
RequestFullRescanLocked(
448+
$"pending path limit exceeded ({_maxPendingPaths.ToString("N0", CultureInfo.InvariantCulture)} paths)");
449+
return;
450+
}
451+
452+
_pending.Add(path);
453+
}
454+
401455
_lastEventUtc = _clock();
402456
}
403457
}
@@ -406,10 +460,7 @@ public void RequestFullRescan(string? reason = null)
406460
{
407461
lock (_gate)
408462
{
409-
_overflowRequested = true;
410-
if (!string.IsNullOrEmpty(reason))
411-
_overflowReason = reason;
412-
_lastEventUtc = _clock();
463+
RequestFullRescanLocked(reason);
413464
}
414465
}
415466

@@ -445,4 +496,13 @@ public bool TryDrain(out IReadOnlyList<string> batch, out bool fullRescan, out s
445496
return true;
446497
}
447498
}
499+
500+
private void RequestFullRescanLocked(string? reason)
501+
{
502+
_pending.Clear();
503+
_overflowRequested = true;
504+
if (!string.IsNullOrEmpty(reason))
505+
_overflowReason = reason;
506+
_lastEventUtc = _clock();
507+
}
448508
}

src/CodeIndex/Cli/JsonOutputContracts.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,7 @@ internal sealed class IndexWatchEventJsonResult
181181
public int? DebounceMs { get; init; }
182182
public int? BatchSize { get; init; }
183183
public long? ElapsedMs { get; init; }
184+
public int? ExitCode { get; init; }
184185
public string? Reason { get; init; }
185186
}
186187

0 commit comments

Comments
 (0)