@indigoai-us/hq-cloud 6.14.26 → 6.14.28
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/sync-runner-company.d.ts.map +1 -1
- package/dist/bin/sync-runner-company.js +16 -5
- package/dist/bin/sync-runner-company.js.map +1 -1
- package/dist/bin/sync-runner-watch-loop.d.ts.map +1 -1
- package/dist/bin/sync-runner-watch-loop.js +13 -3
- package/dist/bin/sync-runner-watch-loop.js.map +1 -1
- package/dist/bin/sync-runner.test.js +11 -1
- package/dist/bin/sync-runner.test.js.map +1 -1
- package/dist/personal-vault.d.ts +21 -4
- package/dist/personal-vault.d.ts.map +1 -1
- package/dist/personal-vault.js +63 -4
- package/dist/personal-vault.js.map +1 -1
- package/dist/personal-vault.test.d.ts +3 -2
- package/dist/personal-vault.test.d.ts.map +1 -1
- package/dist/personal-vault.test.js +36 -3
- package/dist/personal-vault.test.js.map +1 -1
- package/dist/watcher.d.ts +98 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +235 -43
- package/dist/watcher.js.map +1 -1
- package/dist/watcher.test.js +199 -1
- package/dist/watcher.test.js.map +1 -1
- package/package.json +2 -2
- package/src/bin/sync-runner-company.ts +19 -5
- package/src/bin/sync-runner-watch-loop.ts +15 -5
- package/src/bin/sync-runner.test.ts +17 -1
- package/src/personal-vault.test.ts +41 -2
- package/src/personal-vault.ts +63 -4
- package/src/watcher.test.ts +248 -0
- package/src/watcher.ts +322 -42
package/src/watcher.ts
CHANGED
|
@@ -31,6 +31,28 @@ import {
|
|
|
31
31
|
|
|
32
32
|
const DEBOUNCE_MS = 2000;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A single Linux chokidar watcher must leave room for editors, dev servers,
|
|
36
|
+
* and other HQ runners on the same uid. This is deliberately far below the
|
|
37
|
+
* common 250k kernel maximum. The cap is on distinct directories admitted over
|
|
38
|
+
* this watcher's lifetime, which also prevents a high-churn tree from slowly
|
|
39
|
+
* accumulating an unbounded set of kernel watches.
|
|
40
|
+
*/
|
|
41
|
+
export const DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS = 20_000;
|
|
42
|
+
export const TREE_WATCHER_MAX_WATCHED_PATHS_ENV = "HQ_SYNC_MAX_WATCHED_PATHS";
|
|
43
|
+
|
|
44
|
+
function resolveMaxWatchedPaths(override: number | undefined): number {
|
|
45
|
+
const configured = override ?? Number(process.env[TREE_WATCHER_MAX_WATCHED_PATHS_ENV]);
|
|
46
|
+
if (override === undefined && process.env[TREE_WATCHER_MAX_WATCHED_PATHS_ENV] === undefined) {
|
|
47
|
+
return DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS;
|
|
48
|
+
}
|
|
49
|
+
if (Number.isFinite(configured) && configured > 0) return Math.trunc(configured);
|
|
50
|
+
console.warn(
|
|
51
|
+
`TreeWatcher ignoring invalid ${TREE_WATCHER_MAX_WATCHED_PATHS_ENV}; using ${DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS}`,
|
|
52
|
+
);
|
|
53
|
+
return DEFAULT_TREE_WATCHER_MAX_WATCHED_PATHS;
|
|
54
|
+
}
|
|
55
|
+
|
|
34
56
|
/**
|
|
35
57
|
* Injectable clock seam (US-001).
|
|
36
58
|
*
|
|
@@ -224,44 +246,146 @@ export class WatchPushDriver {
|
|
|
224
246
|
/** Decision for a single path: emit a change for it, or ignore it. */
|
|
225
247
|
export type WatchPathFilter = (absolutePath: string, isDir?: boolean) => boolean;
|
|
226
248
|
|
|
249
|
+
export interface TreeWatcherWatchBudgetExceeded {
|
|
250
|
+
maxWatchedPaths: number;
|
|
251
|
+
watchedPaths: number;
|
|
252
|
+
/** Absolute directory whose admission would exceed the cap. */
|
|
253
|
+
offendingPath: string;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Tracks directories admitted to chokidar. Chokidar asks its ignored callback
|
|
258
|
+
* twice: before stat (the descent gate) and after stat (where we can safely
|
|
259
|
+
* account only for directories). This class is intentionally lifetime-bounded:
|
|
260
|
+
* a directory that is deleted and recreated cannot turn one long-running
|
|
261
|
+
* runner into an unbounded consumer.
|
|
262
|
+
*/
|
|
263
|
+
export class ChokidarWatchBudget {
|
|
264
|
+
private readonly admitted = new Set<string>();
|
|
265
|
+
private exhausted = false;
|
|
266
|
+
|
|
267
|
+
constructor(
|
|
268
|
+
private readonly maxWatchedPaths: number,
|
|
269
|
+
private readonly onExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
|
|
270
|
+
) {}
|
|
271
|
+
|
|
272
|
+
admit(absolutePath: string): boolean {
|
|
273
|
+
const resolved = path.resolve(absolutePath);
|
|
274
|
+
if (this.admitted.has(resolved)) return true;
|
|
275
|
+
if (this.exhausted || this.admitted.size >= this.maxWatchedPaths) {
|
|
276
|
+
if (!this.exhausted) {
|
|
277
|
+
this.exhausted = true;
|
|
278
|
+
this.onExceeded({
|
|
279
|
+
maxWatchedPaths: this.maxWatchedPaths,
|
|
280
|
+
watchedPaths: this.admitted.size,
|
|
281
|
+
offendingPath: resolved,
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return false;
|
|
285
|
+
}
|
|
286
|
+
this.admitted.add(resolved);
|
|
287
|
+
return true;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
count(): number {
|
|
291
|
+
return this.admitted.size;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
227
295
|
/**
|
|
228
|
-
* Translate
|
|
296
|
+
* Translate the emit filter into chokidar's descent predicate.
|
|
229
297
|
*
|
|
230
|
-
*
|
|
231
|
-
*
|
|
232
|
-
*
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
* `
|
|
236
|
-
*
|
|
237
|
-
* hint we don't know if the candidate is that ancestor dir or an in-scope
|
|
238
|
-
* file, so we must NOT prune when EITHER reading would keep it. The real
|
|
239
|
-
* isDir-accurate verdict is reapplied at event time (TreeWatcher.handleEvent
|
|
240
|
-
* re-checks with the known kind), so this only widens descent, never emit.
|
|
298
|
+
* Chokidar first calls this with no stats, before it has chosen whether to
|
|
299
|
+
* recurse. That call is a DIRECTORY question, not an event-emission question:
|
|
300
|
+
* consulting `shouldEmit(path, false)` here lets directory-only ignores such
|
|
301
|
+
* as `node_modules/` masquerade as files and opens their whole subtree. The
|
|
302
|
+
* directory branch of createIgnoreFilter already contains the exact
|
|
303
|
+
* `.hqinclude` ancestor matcher, so it is both safe and sufficient to consult
|
|
304
|
+
* `shouldEmit(path, true)` at this gate.
|
|
241
305
|
*/
|
|
242
|
-
function toChokidarIgnored(
|
|
306
|
+
export function toChokidarIgnored(
|
|
243
307
|
shouldEmit: WatchPathFilter,
|
|
244
308
|
hqRoot: string,
|
|
309
|
+
budget?: ChokidarWatchBudget,
|
|
245
310
|
): (filePath: string, stats?: fs.Stats) => boolean {
|
|
246
311
|
const root = path.resolve(hqRoot);
|
|
247
312
|
return (filePath: string, stats?: fs.Stats): boolean => {
|
|
248
313
|
// The watched root itself must NEVER be ignored, or chokidar tears down
|
|
249
314
|
// the whole watch. (shouldEmit returns false for it — it's not a valid
|
|
250
315
|
// emit target — so we special-case it here.)
|
|
251
|
-
if (path.resolve(filePath) === root)
|
|
316
|
+
if (path.resolve(filePath) === root) {
|
|
317
|
+
return stats?.isDirectory() && budget !== undefined
|
|
318
|
+
? !budget.admit(filePath)
|
|
319
|
+
: false;
|
|
320
|
+
}
|
|
252
321
|
const isDir = stats?.isDirectory();
|
|
253
322
|
if (isDir === undefined) {
|
|
254
|
-
//
|
|
255
|
-
//
|
|
256
|
-
|
|
323
|
+
// This is chokidar's descent decision. The `isDir=true` branch includes
|
|
324
|
+
// only precise .hqinclude ancestors, rather than widening descent for
|
|
325
|
+
// every path that could happen to be an allowed file.
|
|
326
|
+
return !shouldEmit(filePath, true);
|
|
327
|
+
}
|
|
328
|
+
if (isDir) {
|
|
329
|
+
return (
|
|
330
|
+
!shouldEmit(filePath, true) ||
|
|
331
|
+
(budget !== undefined && !budget.admit(filePath))
|
|
332
|
+
);
|
|
257
333
|
}
|
|
258
334
|
return !shouldEmit(filePath, isDir);
|
|
259
335
|
};
|
|
260
336
|
}
|
|
261
337
|
|
|
262
338
|
/** A started watch backend. `close()` releases its OS handle(s); idempotent. */
|
|
263
|
-
interface WatchBackend {
|
|
339
|
+
export interface WatchBackend {
|
|
264
340
|
close(): void;
|
|
341
|
+
/** Native recursive watches need rename-kind state; chokidar does not. */
|
|
342
|
+
needsKnownKinds: boolean;
|
|
343
|
+
/** Directories/OS handles admitted by this backend so degradation is auditable. */
|
|
344
|
+
watchedPathCount(): number;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
export interface TreeWatcherDegradation {
|
|
348
|
+
reason: "watch_budget_exhausted" | "inotify_enospc";
|
|
349
|
+
maxWatchedPaths: number;
|
|
350
|
+
watchedPaths: number;
|
|
351
|
+
/** Full local path is deliberately log-only; telemetry receives its top level. */
|
|
352
|
+
offendingPath?: string;
|
|
353
|
+
offendingTopLevel?: string;
|
|
354
|
+
errorCode?: string;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
export interface TreeWatchBackendOptions {
|
|
358
|
+
hqRoot: string;
|
|
359
|
+
shouldEmit: WatchPathFilter;
|
|
360
|
+
onEvent: (absolutePath: string, kind: BackendChangeKind) => void;
|
|
361
|
+
onError: (err: unknown) => void;
|
|
362
|
+
maxWatchedPaths: number;
|
|
363
|
+
onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export type TreeWatchBackendFactory = (
|
|
367
|
+
opts: TreeWatchBackendOptions,
|
|
368
|
+
) => WatchBackend;
|
|
369
|
+
|
|
370
|
+
function isInotifyLimitError(err: unknown): boolean {
|
|
371
|
+
return (
|
|
372
|
+
typeof err === "object" &&
|
|
373
|
+
err !== null &&
|
|
374
|
+
"code" in err &&
|
|
375
|
+
(err as { code?: unknown }).code === "ENOSPC"
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
function errorPath(err: unknown): string | undefined {
|
|
380
|
+
if (
|
|
381
|
+
typeof err === "object" &&
|
|
382
|
+
err !== null &&
|
|
383
|
+
"path" in err &&
|
|
384
|
+
typeof (err as { path?: unknown }).path === "string"
|
|
385
|
+
) {
|
|
386
|
+
return (err as { path: string }).path;
|
|
387
|
+
}
|
|
388
|
+
return undefined;
|
|
265
389
|
}
|
|
266
390
|
|
|
267
391
|
export type TreeChangeKind =
|
|
@@ -306,18 +430,36 @@ function startChokidarTreeWatch(
|
|
|
306
430
|
shouldEmit: WatchPathFilter,
|
|
307
431
|
onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
|
|
308
432
|
onError: (err: unknown) => void,
|
|
433
|
+
maxWatchedPaths: number,
|
|
434
|
+
onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
|
|
309
435
|
): WatchBackend {
|
|
310
|
-
const
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
436
|
+
const budget = new ChokidarWatchBudget(
|
|
437
|
+
maxWatchedPaths,
|
|
438
|
+
onWatchBudgetExceeded,
|
|
439
|
+
);
|
|
440
|
+
let cw: ReturnType<typeof watch>;
|
|
441
|
+
try {
|
|
442
|
+
cw = watch(hqRoot, {
|
|
443
|
+
// On Linux chokidar adds one fs.watch/inotify watch per descended dir.
|
|
444
|
+
// The predicate therefore enforces both scope and the hard per-runner cap.
|
|
445
|
+
ignored: toChokidarIgnored(shouldEmit, hqRoot, budget),
|
|
446
|
+
persistent: true,
|
|
447
|
+
ignoreInitial: true,
|
|
448
|
+
awaitWriteFinish: {
|
|
449
|
+
stabilityThreshold: 500,
|
|
450
|
+
pollInterval: 100,
|
|
451
|
+
},
|
|
452
|
+
});
|
|
453
|
+
} catch (err) {
|
|
454
|
+
// Some chokidar/fs.watch versions surface ENOSPC synchronously. Route it
|
|
455
|
+
// through the same degradation path as an asynchronous backend error.
|
|
456
|
+
onError(err);
|
|
457
|
+
return {
|
|
458
|
+
close: () => {},
|
|
459
|
+
needsKnownKinds: false,
|
|
460
|
+
watchedPathCount: () => budget.count(),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
321
463
|
cw.on("add", (p) => onEvent(p, "add"))
|
|
322
464
|
.on("change", (p) => onEvent(p, "change"))
|
|
323
465
|
.on("unlink", (p) => onEvent(p, "unlink"))
|
|
@@ -328,6 +470,8 @@ function startChokidarTreeWatch(
|
|
|
328
470
|
close: () => {
|
|
329
471
|
void cw.close();
|
|
330
472
|
},
|
|
473
|
+
needsKnownKinds: false,
|
|
474
|
+
watchedPathCount: () => budget.count(),
|
|
331
475
|
};
|
|
332
476
|
}
|
|
333
477
|
|
|
@@ -356,6 +500,8 @@ function startTreeWatch(
|
|
|
356
500
|
shouldEmit: WatchPathFilter,
|
|
357
501
|
onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
|
|
358
502
|
onError: (err: unknown) => void,
|
|
503
|
+
maxWatchedPaths: number,
|
|
504
|
+
onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
|
|
359
505
|
): WatchBackend {
|
|
360
506
|
if (supportsRecursiveWatch()) {
|
|
361
507
|
try {
|
|
@@ -391,6 +537,8 @@ function startTreeWatch(
|
|
|
391
537
|
shouldEmit,
|
|
392
538
|
onEvent,
|
|
393
539
|
onError,
|
|
540
|
+
maxWatchedPaths,
|
|
541
|
+
onWatchBudgetExceeded,
|
|
394
542
|
);
|
|
395
543
|
} catch (fallbackErr) {
|
|
396
544
|
onError(fallbackErr);
|
|
@@ -407,6 +555,8 @@ function startTreeWatch(
|
|
|
407
555
|
fallback?.close();
|
|
408
556
|
fallback = null;
|
|
409
557
|
},
|
|
558
|
+
needsKnownKinds: true,
|
|
559
|
+
watchedPathCount: () => fallback?.watchedPathCount() ?? 1,
|
|
410
560
|
};
|
|
411
561
|
} catch (err) {
|
|
412
562
|
// Recursive watch unexpectedly unavailable — fall back to chokidar
|
|
@@ -415,7 +565,14 @@ function startTreeWatch(
|
|
|
415
565
|
}
|
|
416
566
|
}
|
|
417
567
|
|
|
418
|
-
return startChokidarTreeWatch(
|
|
568
|
+
return startChokidarTreeWatch(
|
|
569
|
+
hqRoot,
|
|
570
|
+
shouldEmit,
|
|
571
|
+
onEvent,
|
|
572
|
+
onError,
|
|
573
|
+
maxWatchedPaths,
|
|
574
|
+
onWatchBudgetExceeded,
|
|
575
|
+
);
|
|
419
576
|
}
|
|
420
577
|
|
|
421
578
|
/**
|
|
@@ -562,8 +719,25 @@ export interface TreeWatcherOptions {
|
|
|
562
719
|
maxPendingPaths?: number;
|
|
563
720
|
/** Approximate maximum path-string bytes retained in one debounce window. */
|
|
564
721
|
maxPendingBytes?: number;
|
|
722
|
+
/**
|
|
723
|
+
* Hard cap on distinct directories admitted to the chokidar backend for the
|
|
724
|
+
* lifetime of this instance. Linux uses one inotify watch per such directory.
|
|
725
|
+
* Overrides `HQ_SYNC_MAX_WATCHED_PATHS` for callers that construct a watcher.
|
|
726
|
+
*/
|
|
727
|
+
maxWatchedPaths?: number;
|
|
565
728
|
/** Backlog overflow signal. Defaults to a console warning. */
|
|
566
729
|
onBacklogOverflow?: (info: TreeWatcherBacklogOverflow) => void;
|
|
730
|
+
/**
|
|
731
|
+
* Structured degradation/metric seam. It fires exactly once when the watch
|
|
732
|
+
* cap or the kernel's inotify limit disables event watching; cadence polling
|
|
733
|
+
* remains the correctness path.
|
|
734
|
+
*/
|
|
735
|
+
onDegraded?: (info: TreeWatcherDegradation) => void;
|
|
736
|
+
/** Optional cloud telemetry sink for the same degradation signal. */
|
|
737
|
+
telemetryClient?: CloudTelemetryClient | null;
|
|
738
|
+
telemetryClaims?: TelemetryClaims | null;
|
|
739
|
+
/** Test seam for a watch backend; production uses native/chokidar selection. */
|
|
740
|
+
backendFactory?: TreeWatchBackendFactory;
|
|
567
741
|
captureLocalDeleteSnapshots?: (
|
|
568
742
|
relativePath: string,
|
|
569
743
|
kind: "unlink" | "unlinkDir",
|
|
@@ -622,7 +796,12 @@ export class TreeWatcher {
|
|
|
622
796
|
private readonly shouldEmit: WatchPathFilter;
|
|
623
797
|
private readonly maxPendingPaths: number;
|
|
624
798
|
private readonly maxPendingBytes: number;
|
|
799
|
+
private readonly maxWatchedPaths: number;
|
|
625
800
|
private readonly onBacklogOverflow: (info: TreeWatcherBacklogOverflow) => void;
|
|
801
|
+
private readonly onDegraded: (info: TreeWatcherDegradation) => void;
|
|
802
|
+
private readonly telemetryClient: CloudTelemetryClient | null | undefined;
|
|
803
|
+
private readonly telemetryClaims: TelemetryClaims | null | undefined;
|
|
804
|
+
private readonly backendFactory?: TreeWatchBackendFactory;
|
|
626
805
|
private readonly captureLocalDeleteSnapshots?: TreeWatcherOptions["captureLocalDeleteSnapshots"];
|
|
627
806
|
private backend: WatchBackend | null = null;
|
|
628
807
|
private timer: unknown = null;
|
|
@@ -636,6 +815,7 @@ export class TreeWatcher {
|
|
|
636
815
|
private overflowLogged = false;
|
|
637
816
|
private droppedPaths = 0;
|
|
638
817
|
private droppedBytes = 0;
|
|
818
|
+
private degraded = false;
|
|
639
819
|
private disposed = false;
|
|
640
820
|
|
|
641
821
|
constructor(opts: TreeWatcherOptions) {
|
|
@@ -652,6 +832,7 @@ export class TreeWatcher {
|
|
|
652
832
|
1,
|
|
653
833
|
opts.maxPendingBytes ?? DEFAULT_TREE_WATCHER_MAX_PENDING_BYTES,
|
|
654
834
|
);
|
|
835
|
+
this.maxWatchedPaths = resolveMaxWatchedPaths(opts.maxWatchedPaths);
|
|
655
836
|
this.onBacklogOverflow =
|
|
656
837
|
opts.onBacklogOverflow ??
|
|
657
838
|
((info) => {
|
|
@@ -659,6 +840,16 @@ export class TreeWatcher {
|
|
|
659
840
|
`TreeWatcher backlog cap exceeded; dropping ${info.droppedPaths} path(s) until the next resync`,
|
|
660
841
|
);
|
|
661
842
|
});
|
|
843
|
+
this.onDegraded =
|
|
844
|
+
opts.onDegraded ??
|
|
845
|
+
((info) => {
|
|
846
|
+
console.error(
|
|
847
|
+
JSON.stringify({ event: "watcher.degraded", ...info }),
|
|
848
|
+
);
|
|
849
|
+
});
|
|
850
|
+
this.telemetryClient = opts.telemetryClient;
|
|
851
|
+
this.telemetryClaims = opts.telemetryClaims;
|
|
852
|
+
this.backendFactory = opts.backendFactory;
|
|
662
853
|
this.captureLocalDeleteSnapshots = opts.captureLocalDeleteSnapshots;
|
|
663
854
|
}
|
|
664
855
|
|
|
@@ -684,17 +875,98 @@ export class TreeWatcher {
|
|
|
684
875
|
* for why per-path watching is avoided (kqueue fd exhaustion → EMFILE).
|
|
685
876
|
*/
|
|
686
877
|
start(): void {
|
|
687
|
-
if (this.disposed || this.backend) return;
|
|
688
|
-
|
|
689
|
-
this.hqRoot,
|
|
690
|
-
this.shouldEmit,
|
|
691
|
-
(absolutePath, kind) => this.handleEvent(absolutePath, kind),
|
|
692
|
-
(err) =>
|
|
693
|
-
|
|
694
|
-
|
|
878
|
+
if (this.disposed || this.degraded || this.backend) return;
|
|
879
|
+
const backendOpts: TreeWatchBackendOptions = {
|
|
880
|
+
hqRoot: this.hqRoot,
|
|
881
|
+
shouldEmit: this.shouldEmit,
|
|
882
|
+
onEvent: (absolutePath, kind) => this.handleEvent(absolutePath, kind),
|
|
883
|
+
onError: (err) => this.handleBackendError(err),
|
|
884
|
+
maxWatchedPaths: this.maxWatchedPaths,
|
|
885
|
+
onWatchBudgetExceeded: (info) => this.degrade({
|
|
886
|
+
reason: "watch_budget_exhausted",
|
|
887
|
+
maxWatchedPaths: info.maxWatchedPaths,
|
|
888
|
+
watchedPaths: info.watchedPaths,
|
|
889
|
+
offendingPath: info.offendingPath,
|
|
890
|
+
offendingTopLevel: this.topLevelForTelemetry(info.offendingPath),
|
|
891
|
+
}),
|
|
892
|
+
};
|
|
893
|
+
const backend = this.backendFactory
|
|
894
|
+
? this.backendFactory(backendOpts)
|
|
895
|
+
: startTreeWatch(
|
|
896
|
+
backendOpts.hqRoot,
|
|
897
|
+
backendOpts.shouldEmit,
|
|
898
|
+
backendOpts.onEvent,
|
|
899
|
+
backendOpts.onError,
|
|
900
|
+
backendOpts.maxWatchedPaths,
|
|
901
|
+
backendOpts.onWatchBudgetExceeded,
|
|
902
|
+
);
|
|
903
|
+
// A test backend (or a future eager backend) can discover exhaustion while
|
|
904
|
+
// it is being constructed. Never retain that just-created handle after the
|
|
905
|
+
// degradation path has deliberately disabled event watching.
|
|
906
|
+
if (this.degraded) {
|
|
907
|
+
backend.close();
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
this.backend = backend;
|
|
911
|
+
if (backend.needsKnownKinds) this.seedKnownKinds();
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
private handleBackendError(err: unknown): void {
|
|
915
|
+
if (isInotifyLimitError(err)) {
|
|
916
|
+
const offendingPath = errorPath(err);
|
|
917
|
+
this.degrade({
|
|
918
|
+
reason: "inotify_enospc",
|
|
919
|
+
maxWatchedPaths: this.maxWatchedPaths,
|
|
920
|
+
watchedPaths: this.backend?.watchedPathCount() ?? 0,
|
|
921
|
+
offendingPath,
|
|
922
|
+
offendingTopLevel: offendingPath
|
|
923
|
+
? this.topLevelForTelemetry(offendingPath)
|
|
924
|
+
: undefined,
|
|
925
|
+
errorCode: "ENOSPC",
|
|
926
|
+
});
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
console.error("TreeWatcher error:", err);
|
|
930
|
+
this.signalBackendResync();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
private topLevelForTelemetry(absolutePath: string): string | undefined {
|
|
934
|
+
const rel = path.relative(this.hqRoot, absolutePath).split(path.sep);
|
|
935
|
+
const topLevel = rel[0];
|
|
936
|
+
return topLevel && topLevel !== ".." ? topLevel : undefined;
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
private degrade(info: TreeWatcherDegradation): void {
|
|
940
|
+
if (this.disposed || this.degraded) return;
|
|
941
|
+
this.degraded = true;
|
|
942
|
+
// Closing synchronously prevents further directory admissions while
|
|
943
|
+
// chokidar's initial walk is still in flight.
|
|
944
|
+
this.backend?.close();
|
|
945
|
+
this.backend = null;
|
|
946
|
+
try {
|
|
947
|
+
this.onDegraded(info);
|
|
948
|
+
} catch (err) {
|
|
949
|
+
console.error("TreeWatcher degradation reporter error:", err);
|
|
950
|
+
}
|
|
951
|
+
void emitCloudTelemetry(
|
|
952
|
+
this.telemetryClient,
|
|
953
|
+
{
|
|
954
|
+
eventName: "watcher_degraded",
|
|
955
|
+
source: "watcher",
|
|
956
|
+
properties: {
|
|
957
|
+
reason: info.reason,
|
|
958
|
+
maxWatchedPaths: info.maxWatchedPaths,
|
|
959
|
+
watchedPaths: info.watchedPaths,
|
|
960
|
+
...(info.offendingTopLevel
|
|
961
|
+
? { offendingTopLevel: info.offendingTopLevel }
|
|
962
|
+
: {}),
|
|
963
|
+
},
|
|
695
964
|
},
|
|
965
|
+
{ claims: this.telemetryClaims },
|
|
696
966
|
);
|
|
697
|
-
|
|
967
|
+
// The existing overflow/resync batch causes the runner to use its scoped
|
|
968
|
+
// drain while the cadence poll continues covering everything else.
|
|
969
|
+
this.signalBackendResync();
|
|
698
970
|
}
|
|
699
971
|
|
|
700
972
|
private signalBackendResync(): void {
|
|
@@ -767,7 +1039,12 @@ export class TreeWatcher {
|
|
|
767
1039
|
this.knownKinds.delete(abs);
|
|
768
1040
|
} else {
|
|
769
1041
|
this.pendingChanges.set(abs, { kind });
|
|
770
|
-
|
|
1042
|
+
// Only directories are needed to disambiguate a native recursive
|
|
1043
|
+
// rename-after-delete. Unknown paths already correctly default to an
|
|
1044
|
+
// unlink file, so retaining every file ever touched made this map grow
|
|
1045
|
+
// without bound in a long-lived runner.
|
|
1046
|
+
if (isDir) this.knownKinds.set(abs, "directory");
|
|
1047
|
+
else this.knownKinds.delete(abs);
|
|
771
1048
|
}
|
|
772
1049
|
this.arm();
|
|
773
1050
|
}
|
|
@@ -786,8 +1063,6 @@ export class TreeWatcher {
|
|
|
786
1063
|
if (!this.shouldEmit(absolutePath, true)) continue;
|
|
787
1064
|
this.knownKinds.set(path.resolve(absolutePath), "directory");
|
|
788
1065
|
visit(absolutePath);
|
|
789
|
-
} else if (this.shouldEmit(absolutePath, false)) {
|
|
790
|
-
this.knownKinds.set(path.resolve(absolutePath), "file");
|
|
791
1066
|
}
|
|
792
1067
|
}
|
|
793
1068
|
};
|
|
@@ -872,6 +1147,11 @@ export class TreeWatcher {
|
|
|
872
1147
|
return this.timer === null ? 0 : 1;
|
|
873
1148
|
}
|
|
874
1149
|
|
|
1150
|
+
/** Number of native-rename directory hints retained for deletion handling. */
|
|
1151
|
+
knownDirectoryCount(): number {
|
|
1152
|
+
return this.knownKinds.size;
|
|
1153
|
+
}
|
|
1154
|
+
|
|
875
1155
|
/**
|
|
876
1156
|
* Stop watching: close the watch backend (releasing its OS handle) and
|
|
877
1157
|
* cancel any pending debounce timer. Idempotent. The instance can be
|