@indigoai-us/hq-cloud 6.14.44 → 6.14.45
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/watch-roots.d.ts +72 -0
- package/dist/watch-roots.d.ts.map +1 -0
- package/dist/watch-roots.js +115 -0
- package/dist/watch-roots.js.map +1 -0
- package/dist/watch-roots.test.d.ts +18 -0
- package/dist/watch-roots.test.d.ts.map +1 -0
- package/dist/watch-roots.test.js +236 -0
- package/dist/watch-roots.test.js.map +1 -0
- package/dist/watcher-event-gate.test.d.ts +16 -0
- package/dist/watcher-event-gate.test.d.ts.map +1 -0
- package/dist/watcher-event-gate.test.js +191 -0
- package/dist/watcher-event-gate.test.js.map +1 -0
- package/dist/watcher.d.ts +21 -0
- package/dist/watcher.d.ts.map +1 -1
- package/dist/watcher.js +312 -54
- package/dist/watcher.js.map +1 -1
- package/package.json +1 -1
- package/src/watch-roots.test.ts +278 -0
- package/src/watch-roots.ts +162 -0
- package/src/watcher-event-gate.test.ts +212 -0
- package/src/watcher.ts +363 -65
- package/test/e2e/watcher-scoped-coverage.test.ts +381 -0
package/src/watcher.ts
CHANGED
|
@@ -12,6 +12,11 @@ import { readFile, stat } from "node:fs/promises";
|
|
|
12
12
|
import * as path from "path";
|
|
13
13
|
import { watch } from "chokidar";
|
|
14
14
|
import { createIgnoreFilter } from "./ignore.js";
|
|
15
|
+
import {
|
|
16
|
+
isCoveredByRecursiveRoot,
|
|
17
|
+
planWatchRoots,
|
|
18
|
+
type WatchRootPlan,
|
|
19
|
+
} from "./watch-roots.js";
|
|
15
20
|
import { vaultKeyForLocalPath } from "./local-path-codec.js";
|
|
16
21
|
import { isPersonalVaultExcluded } from "./personal-vault-exclusions.js";
|
|
17
22
|
import {
|
|
@@ -340,6 +345,12 @@ export interface WatchBackend {
|
|
|
340
345
|
close(): void;
|
|
341
346
|
/** Native recursive watches need rename-kind state; chokidar does not. */
|
|
342
347
|
needsKnownKinds: boolean;
|
|
348
|
+
/**
|
|
349
|
+
* True when the backend already populated those known kinds through
|
|
350
|
+
* `onDirectorySeen` while planning its watch roots, so {@link TreeWatcher}
|
|
351
|
+
* must NOT walk the tree a second time to seed them.
|
|
352
|
+
*/
|
|
353
|
+
seededKnownKinds?: boolean;
|
|
343
354
|
/** Directories/OS handles admitted by this backend so degradation is auditable. */
|
|
344
355
|
watchedPathCount(): number;
|
|
345
356
|
}
|
|
@@ -361,6 +372,11 @@ export interface TreeWatchBackendOptions {
|
|
|
361
372
|
onError: (err: unknown) => void;
|
|
362
373
|
maxWatchedPaths: number;
|
|
363
374
|
onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void;
|
|
375
|
+
/**
|
|
376
|
+
* Reports every in-scope directory a backend encounters while planning its
|
|
377
|
+
* watch roots, so the known-kinds index can be built in that same pass.
|
|
378
|
+
*/
|
|
379
|
+
onDirectorySeen?: (absolutePath: string) => void;
|
|
364
380
|
}
|
|
365
381
|
|
|
366
382
|
export type TreeWatchBackendFactory = (
|
|
@@ -476,88 +492,344 @@ function startChokidarTreeWatch(
|
|
|
476
492
|
}
|
|
477
493
|
|
|
478
494
|
/**
|
|
479
|
-
*
|
|
495
|
+
* macOS/Windows backend: recursive `fs.watch` handles placed over the in-scope
|
|
496
|
+
* tree ONLY, as planned by {@link planWatchRoots}.
|
|
480
497
|
*
|
|
481
|
-
*
|
|
482
|
-
*
|
|
483
|
-
*
|
|
484
|
-
*
|
|
485
|
-
*
|
|
486
|
-
*
|
|
487
|
-
*
|
|
488
|
-
* - Linux / other → chokidar (recursive `fs.watch` is unsupported there).
|
|
498
|
+
* The previous shape was a single recursive watch on hqRoot. That is one handle
|
|
499
|
+
* regardless of tree size — but the OS then reports the WHOLE tree, including
|
|
500
|
+
* the buckets sync never uploads. On a real HQ root that is `repos/` (~26k
|
|
501
|
+
* directories) and `workspace/worktrees/` (~62k), where agent builds, installs,
|
|
502
|
+
* and git checkouts churn constantly. Each of those events woke the runner and
|
|
503
|
+
* was dropped only after the emit filter ran, which was enough allocation churn
|
|
504
|
+
* to pin the process above 100% CPU in GC.
|
|
489
505
|
*
|
|
490
|
-
*
|
|
491
|
-
*
|
|
492
|
-
*
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
496
|
-
*
|
|
506
|
+
* So instead: plan the minimum cover of the uploadable tree and watch that.
|
|
507
|
+
* A directory whose entire subtree is in scope takes one recursive watch; a
|
|
508
|
+
* directory containing an excluded child is watched non-recursively and its
|
|
509
|
+
* in-scope children are planned separately. Excluded subtrees get no watch at
|
|
510
|
+
* all, so the OS never reports them.
|
|
511
|
+
*
|
|
512
|
+
* Handle count stays small (tens, not thousands — well clear of the kqueue fd
|
|
513
|
+
* exhaustion that made chokidar 4 unusable here), and the emit filter still
|
|
514
|
+
* runs on every delivered event, so a depth-bounded plan admitting some residue
|
|
515
|
+
* is a performance concern, never a correctness one.
|
|
497
516
|
*/
|
|
498
|
-
|
|
517
|
+
/**
|
|
518
|
+
* How long the temporary whole-tree bootstrap watch is held open after the
|
|
519
|
+
* scoped plan goes live, so events the OS observed during the planning walk
|
|
520
|
+
* still drain instead of being dropped with the handle.
|
|
521
|
+
*/
|
|
522
|
+
const BOOTSTRAP_DRAIN_MS = 2000;
|
|
523
|
+
|
|
524
|
+
export interface ScopedWatchBackend extends WatchBackend {
|
|
525
|
+
/**
|
|
526
|
+
* Planned watches that could not be attached during the initial plan. Any
|
|
527
|
+
* failure here means the scoped plan has a permanent hole, so the caller
|
|
528
|
+
* discards it and falls back to chokidar rather than running with partial
|
|
529
|
+
* coverage.
|
|
530
|
+
*/
|
|
531
|
+
attachFailures: number;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
export function startScopedRecursiveWatch(
|
|
499
535
|
hqRoot: string,
|
|
500
536
|
shouldEmit: WatchPathFilter,
|
|
501
537
|
onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
|
|
502
538
|
onError: (err: unknown) => void,
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
539
|
+
onDirectorySeen?: (absolutePath: string) => void,
|
|
540
|
+
): ScopedWatchBackend {
|
|
541
|
+
const root = path.resolve(hqRoot);
|
|
542
|
+
const watchers = new Map<string, fs.FSWatcher>();
|
|
543
|
+
const recursiveRoots = new Set<string>();
|
|
544
|
+
const recursiveFlag = new Map<string, boolean>();
|
|
545
|
+
let closed = false;
|
|
546
|
+
let attachFailures = 0;
|
|
547
|
+
/** Retires the bootstrap watch — replaced once that watch exists. */
|
|
548
|
+
let closeBootstrap: () => void = () => {};
|
|
549
|
+
|
|
550
|
+
/** lstat that answers "is this a directory right now", never throwing. */
|
|
551
|
+
function isDirectoryNow(absolutePath: string): boolean {
|
|
507
552
|
try {
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
553
|
+
return fs.lstatSync(absolutePath).isDirectory();
|
|
554
|
+
} catch {
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function exists(absolutePath: string): boolean {
|
|
560
|
+
try {
|
|
561
|
+
fs.lstatSync(absolutePath);
|
|
562
|
+
return true;
|
|
563
|
+
} catch {
|
|
564
|
+
return false;
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Release the watch on `dir` AND on everything beneath it.
|
|
570
|
+
*
|
|
571
|
+
* When a directory is removed the OS reports that directory, not each of its
|
|
572
|
+
* descendants — so dropping only the exact path leaks every watch underneath
|
|
573
|
+
* it. Those handles stay open forever, and a leaked recursive root keeps
|
|
574
|
+
* marking its (now absent) subtree as covered, so a later recreation is never
|
|
575
|
+
* re-attached. These runners live for many hours, so the leak accumulates
|
|
576
|
+
* across every directory the tree churns through.
|
|
577
|
+
*/
|
|
578
|
+
function dropWatch(dir: string): void {
|
|
579
|
+
const prefix = dir + path.sep;
|
|
580
|
+
for (const watched of [...watchers.keys()]) {
|
|
581
|
+
if (watched !== dir && !watched.startsWith(prefix)) continue;
|
|
582
|
+
const handle = watchers.get(watched);
|
|
583
|
+
if (handle) {
|
|
584
|
+
try {
|
|
585
|
+
handle.close();
|
|
586
|
+
} catch {
|
|
587
|
+
/* already closed */
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
watchers.delete(watched);
|
|
591
|
+
recursiveRoots.delete(watched);
|
|
592
|
+
recursiveFlag.delete(watched);
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
function addWatch(dir: string, recursive: boolean): void {
|
|
597
|
+
if (closed || watchers.has(dir)) return;
|
|
598
|
+
let handle: fs.FSWatcher;
|
|
599
|
+
try {
|
|
600
|
+
handle = fs.watch(
|
|
601
|
+
dir,
|
|
602
|
+
{ recursive, persistent: true },
|
|
511
603
|
(eventType, filename) => {
|
|
512
|
-
// `filename` is relative to
|
|
513
|
-
// couldn't provide it — nothing actionable then).
|
|
514
|
-
// both the string and (older @types/node) Buffer
|
|
604
|
+
// `filename` is relative to THIS watch's directory (or null/empty if
|
|
605
|
+
// the platform couldn't provide it — nothing actionable then).
|
|
606
|
+
// `String()` coerces both the string and (older @types/node) Buffer
|
|
607
|
+
// shapes.
|
|
515
608
|
if (filename == null) return;
|
|
516
609
|
const rel = String(filename);
|
|
517
610
|
if (rel === "") return;
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
eventType === "change" ? "change" : "rename"
|
|
521
|
-
);
|
|
611
|
+
const abs = path.resolve(dir, rel);
|
|
612
|
+
const kind: BackendChangeKind =
|
|
613
|
+
eventType === "change" ? "change" : "rename";
|
|
614
|
+
if (kind === "rename") reconcileCoverage(abs);
|
|
615
|
+
onEvent(abs, kind);
|
|
522
616
|
},
|
|
523
617
|
);
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
618
|
+
} catch (err) {
|
|
619
|
+
// A planned watch we could not attach. Report it and record the hole so
|
|
620
|
+
// the caller can reject the whole plan instead of running half-blind.
|
|
621
|
+
attachFailures += 1;
|
|
622
|
+
onError(err);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
handle.on("error", (err) => {
|
|
626
|
+
onError(err);
|
|
627
|
+
if (closed) return;
|
|
628
|
+
// Rebuild this root rather than leaving it permanently unwatched. The
|
|
629
|
+
// onError above has already asked TreeWatcher for a reconcile, so a gap
|
|
630
|
+
// between the drop and the re-attach cannot lose data.
|
|
631
|
+
const wasRecursive = recursiveFlag.get(dir) ?? recursive;
|
|
632
|
+
dropWatch(dir);
|
|
633
|
+
if (exists(dir) && shouldEmit(dir, true)) addWatch(dir, wasRecursive);
|
|
634
|
+
});
|
|
635
|
+
watchers.set(dir, handle);
|
|
636
|
+
recursiveFlag.set(dir, recursive);
|
|
637
|
+
if (recursive) recursiveRoots.add(dir);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function applyPlan(plan: WatchRootPlan): void {
|
|
641
|
+
for (const dir of plan.recursive) addWatch(dir, true);
|
|
642
|
+
for (const dir of plan.shallow) addWatch(dir, false);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function planAndAttach(dir: string): void {
|
|
646
|
+
applyPlan(planWatchRoots(dir, shouldEmit, { onDirectory: onDirectorySeen }));
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Keep the live watch set honest about a path that just appeared, moved, or
|
|
651
|
+
* vanished. Three cases, all of which silently break coverage if ignored:
|
|
652
|
+
*
|
|
653
|
+
* 1. A watched directory was deleted (or deleted and recreated, which gives
|
|
654
|
+
* the replacement a new inode). Its stale handle would otherwise make the
|
|
655
|
+
* path look covered forever, so drop it and let the recreate re-plan.
|
|
656
|
+
* 2. An EXCLUDED directory was created under a recursive root — e.g.
|
|
657
|
+
* `workspace/worktrees/` or a fresh `node_modules/` that did not exist at
|
|
658
|
+
* plan time. The root now covers a bucket we must never watch, which is
|
|
659
|
+
* exactly the firehose this backend exists to prevent, so re-plan it.
|
|
660
|
+
* 3. An in-scope directory was created under a SHALLOW watch, so nothing
|
|
661
|
+
* covers its subtree yet. Attach before the event is handed on.
|
|
662
|
+
*/
|
|
663
|
+
function reconcileCoverage(absolutePath: string): void {
|
|
664
|
+
if (closed) return;
|
|
665
|
+
|
|
666
|
+
// (1) stale handle for a path that no longer exists.
|
|
667
|
+
if (watchers.has(absolutePath) && !exists(absolutePath)) {
|
|
668
|
+
dropWatch(absolutePath);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const inScope = shouldEmit(absolutePath, true);
|
|
673
|
+
const covered = isCoveredByRecursiveRoot(absolutePath, recursiveRoots);
|
|
674
|
+
|
|
675
|
+
// (2) newly-created exclusion swallowed by an existing recursive root. The
|
|
676
|
+
// lstat is paid only for excluded paths under a recursive root — rare, and
|
|
677
|
+
// strictly cheaper than the unconditional stat this backend replaced.
|
|
678
|
+
if (!inScope) {
|
|
679
|
+
if (covered && isDirectoryNow(absolutePath)) {
|
|
680
|
+
for (const rootDir of [...recursiveRoots]) {
|
|
681
|
+
if (
|
|
682
|
+
absolutePath === rootDir ||
|
|
683
|
+
absolutePath.startsWith(rootDir + path.sep)
|
|
684
|
+
) {
|
|
685
|
+
dropWatch(rootDir);
|
|
686
|
+
planAndAttach(rootDir);
|
|
687
|
+
}
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
|
|
693
|
+
// (3) in-scope directory with no coverage yet.
|
|
694
|
+
if (covered || watchers.has(absolutePath)) return;
|
|
695
|
+
if (!isDirectoryNow(absolutePath)) return;
|
|
696
|
+
onDirectorySeen?.(absolutePath);
|
|
697
|
+
planAndAttach(absolutePath);
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// Cover the whole tree BEFORE planning. The plan walk below is synchronous
|
|
701
|
+
// and takes seconds on a real HQ root; without this, every change during that
|
|
702
|
+
// window would be invisible to event-driven push and would wait for the next
|
|
703
|
+
// cadence poll. This temporary watch is exactly the old backend's shape, so
|
|
704
|
+
// the bootstrap window is no worse than the previous behavior — and it is
|
|
705
|
+
// dropped the moment the scoped plan is live.
|
|
706
|
+
let bootstrap: fs.FSWatcher | null = null;
|
|
707
|
+
try {
|
|
708
|
+
bootstrap = fs.watch(
|
|
709
|
+
root,
|
|
710
|
+
{ recursive: true, persistent: true },
|
|
711
|
+
(eventType, filename) => {
|
|
712
|
+
if (filename == null) return;
|
|
713
|
+
const rel = String(filename);
|
|
714
|
+
if (rel === "") return;
|
|
715
|
+
onEvent(
|
|
716
|
+
path.resolve(root, rel),
|
|
717
|
+
eventType === "change" ? "change" : "rename",
|
|
718
|
+
);
|
|
719
|
+
},
|
|
720
|
+
);
|
|
721
|
+
bootstrap.on("error", onError);
|
|
722
|
+
} catch (err) {
|
|
723
|
+
// Recursive watch is unavailable at all — the plan below cannot work
|
|
724
|
+
// either, so record the hole and let the caller fall back.
|
|
725
|
+
attachFailures += 1;
|
|
726
|
+
onError(err);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// Unbounded on purpose: this walk replaces TreeWatcher's separate known-kinds
|
|
730
|
+
// seed walk (same tree, same filter, ~3.6s on a real HQ root), so planning
|
|
731
|
+
// the tree exhaustively costs nothing over the status quo and leaves no
|
|
732
|
+
// excluded subtree covered by a recursive root.
|
|
733
|
+
planAndAttach(root);
|
|
734
|
+
|
|
735
|
+
// The scoped plan is live, so narrow from whole-tree to in-scope-only — but
|
|
736
|
+
// NOT synchronously. Events observed during the planning walk can still be
|
|
737
|
+
// sitting in the OS queue undelivered, and closing the handle here drops
|
|
738
|
+
// them, reopening the very gap the bootstrap watch exists to close. Hold it
|
|
739
|
+
// open for one drain window instead; the overlap only costs duplicate events
|
|
740
|
+
// for excluded paths, which the emit filter drops and the debounce coalesces.
|
|
741
|
+
if (bootstrap) {
|
|
742
|
+
const handle = bootstrap;
|
|
743
|
+
bootstrap = null;
|
|
744
|
+
const timer = setTimeout(() => {
|
|
745
|
+
try {
|
|
746
|
+
handle.close();
|
|
747
|
+
} catch {
|
|
748
|
+
/* already closed */
|
|
749
|
+
}
|
|
750
|
+
closeBootstrap = () => {};
|
|
751
|
+
}, BOOTSTRAP_DRAIN_MS);
|
|
752
|
+
// Never hold the process open just to retire a temporary watch.
|
|
753
|
+
(timer as { unref?: () => void }).unref?.();
|
|
754
|
+
closeBootstrap = () => {
|
|
755
|
+
clearTimeout(timer);
|
|
756
|
+
try {
|
|
757
|
+
handle.close();
|
|
758
|
+
} catch {
|
|
759
|
+
/* already closed */
|
|
760
|
+
}
|
|
761
|
+
closeBootstrap = () => {};
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
return {
|
|
766
|
+
close: () => {
|
|
767
|
+
closed = true;
|
|
768
|
+
closeBootstrap();
|
|
769
|
+
for (const handle of watchers.values()) {
|
|
529
770
|
try {
|
|
530
|
-
|
|
771
|
+
handle.close();
|
|
531
772
|
} catch {
|
|
532
773
|
/* already closed */
|
|
533
774
|
}
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
775
|
+
}
|
|
776
|
+
watchers.clear();
|
|
777
|
+
recursiveRoots.clear();
|
|
778
|
+
recursiveFlag.clear();
|
|
779
|
+
},
|
|
780
|
+
needsKnownKinds: true,
|
|
781
|
+
seededKnownKinds: onDirectorySeen !== undefined,
|
|
782
|
+
watchedPathCount: () => watchers.size,
|
|
783
|
+
attachFailures,
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/**
|
|
788
|
+
* Start watching `hqRoot`, calling `onEvent(absolutePath)` for every change.
|
|
789
|
+
*
|
|
790
|
+
* Backend selection (the whole point of this helper):
|
|
791
|
+
* - macOS / Windows → scoped recursive `fs.watch` handles over the in-scope
|
|
792
|
+
* tree only ({@link startScopedRecursiveWatch}). chokidar 4 dropped its
|
|
793
|
+
* `fsevents` backend, so on macOS it watches via kqueue, which costs ~1
|
|
794
|
+
* open fd PER watched path — ~11k fds over a real HQ tree, which EMFILEs
|
|
795
|
+
* under the default soft `ulimit -n` (256) and silently kills the watcher
|
|
796
|
+
* (instant sync then falls back to the poll).
|
|
797
|
+
* - Linux / other → chokidar (recursive `fs.watch` is unsupported there).
|
|
798
|
+
*
|
|
799
|
+
* The recursive backend does no descent-time filtering WITHIN a watched
|
|
800
|
+
* subtree, so changes there are funneled to `onEvent`, which re-applies the
|
|
801
|
+
* emit filter via {@link TreeWatcher.handleEvent}. That filter remains the
|
|
802
|
+
* authoritative gate; the watch plan is a performance optimization on top of
|
|
803
|
+
* it. This is also why the recursive backend is immune to the `.hqinclude`
|
|
804
|
+
* ancestor-descent pruning that the chokidar backend needs
|
|
805
|
+
* {@link toChokidarIgnored} to avoid.
|
|
806
|
+
*/
|
|
807
|
+
function startTreeWatch(
|
|
808
|
+
hqRoot: string,
|
|
809
|
+
shouldEmit: WatchPathFilter,
|
|
810
|
+
onEvent: (absolutePath: string, kind: BackendChangeKind) => void,
|
|
811
|
+
onError: (err: unknown) => void,
|
|
812
|
+
maxWatchedPaths: number,
|
|
813
|
+
onWatchBudgetExceeded: (info: TreeWatcherWatchBudgetExceeded) => void,
|
|
814
|
+
onDirectorySeen?: (absolutePath: string) => void,
|
|
815
|
+
): WatchBackend {
|
|
816
|
+
if (supportsRecursiveWatch()) {
|
|
817
|
+
try {
|
|
818
|
+
const scoped = startScopedRecursiveWatch(
|
|
819
|
+
hqRoot,
|
|
820
|
+
shouldEmit,
|
|
821
|
+
onEvent,
|
|
822
|
+
onError,
|
|
823
|
+
onDirectorySeen,
|
|
824
|
+
);
|
|
825
|
+
// Partial coverage is worse than a different backend: a planned root we
|
|
826
|
+
// could not attach is silently unwatched forever, and the operator only
|
|
827
|
+
// sees it as "instant sync stopped working for that subtree". Accept the
|
|
828
|
+
// scoped plan only when EVERY planned watch attached.
|
|
829
|
+
if (scoped.attachFailures === 0 && scoped.watchedPathCount() > 0) {
|
|
830
|
+
return scoped;
|
|
831
|
+
}
|
|
832
|
+
scoped.close();
|
|
561
833
|
} catch (err) {
|
|
562
834
|
// Recursive watch unexpectedly unavailable — fall back to chokidar
|
|
563
835
|
// rather than leaving the daemon with no watcher at all.
|
|
@@ -889,6 +1161,8 @@ export class TreeWatcher {
|
|
|
889
1161
|
offendingPath: info.offendingPath,
|
|
890
1162
|
offendingTopLevel: this.topLevelForTelemetry(info.offendingPath),
|
|
891
1163
|
}),
|
|
1164
|
+
onDirectorySeen: (absolutePath) =>
|
|
1165
|
+
this.knownKinds.set(path.resolve(absolutePath), "directory"),
|
|
892
1166
|
};
|
|
893
1167
|
const backend = this.backendFactory
|
|
894
1168
|
? this.backendFactory(backendOpts)
|
|
@@ -899,6 +1173,7 @@ export class TreeWatcher {
|
|
|
899
1173
|
backendOpts.onError,
|
|
900
1174
|
backendOpts.maxWatchedPaths,
|
|
901
1175
|
backendOpts.onWatchBudgetExceeded,
|
|
1176
|
+
backendOpts.onDirectorySeen,
|
|
902
1177
|
);
|
|
903
1178
|
// A test backend (or a future eager backend) can discover exhaustion while
|
|
904
1179
|
// it is being constructed. Never retain that just-created handle after the
|
|
@@ -908,7 +1183,12 @@ export class TreeWatcher {
|
|
|
908
1183
|
return;
|
|
909
1184
|
}
|
|
910
1185
|
this.backend = backend;
|
|
911
|
-
|
|
1186
|
+
// The scoped backend already indexed every in-scope directory while
|
|
1187
|
+
// planning its watch roots — walking the tree again here would double a
|
|
1188
|
+
// multi-second startup cost for an identical result.
|
|
1189
|
+
if (backend.needsKnownKinds && !backend.seededKnownKinds) {
|
|
1190
|
+
this.seedKnownKinds();
|
|
1191
|
+
}
|
|
912
1192
|
}
|
|
913
1193
|
|
|
914
1194
|
private handleBackendError(err: unknown): void {
|
|
@@ -987,6 +1267,13 @@ export class TreeWatcher {
|
|
|
987
1267
|
): void {
|
|
988
1268
|
if (this.disposed) return;
|
|
989
1269
|
const abs = path.resolve(absolutePath);
|
|
1270
|
+
// Cheap pre-gate. When a path is out of scope BOTH as a file and as a
|
|
1271
|
+
// directory, no stat can change the answer — so drop it before paying the
|
|
1272
|
+
// syscall below. Scoped watch roots (see planWatchRoots) keep most excluded
|
|
1273
|
+
// traffic from being delivered at all; this covers the residue a
|
|
1274
|
+
// depth-bounded plan still admits, at the cost of two string matches
|
|
1275
|
+
// instead of one synchronous lstat.
|
|
1276
|
+
if (!this.shouldEmit(abs, false) && !this.shouldEmit(abs, true)) return;
|
|
990
1277
|
let kind: TreeChangeKind;
|
|
991
1278
|
if (backendKind === "rename") {
|
|
992
1279
|
try {
|
|
@@ -1030,6 +1317,17 @@ export class TreeWatcher {
|
|
|
1030
1317
|
}
|
|
1031
1318
|
this.pending.set(abs, rel);
|
|
1032
1319
|
if (kind === "unlink" || kind === "unlinkDir") {
|
|
1320
|
+
// A SECOND delete event for the same path in one window must never
|
|
1321
|
+
// downgrade a directory delete to a file delete. The first `unlinkDir`
|
|
1322
|
+
// clears this path's known-kind hint, so a duplicate event — the OS can
|
|
1323
|
+
// report one path through two overlapping watches — would re-classify it
|
|
1324
|
+
// as `unlink` and, with it, narrow the deletion scope the vault applies
|
|
1325
|
+
// and drop the captured descendant snapshots.
|
|
1326
|
+
if (kind === "unlink" && this.pendingChanges.get(abs)?.kind === "unlinkDir") {
|
|
1327
|
+
this.pending.set(abs, rel);
|
|
1328
|
+
this.arm();
|
|
1329
|
+
return;
|
|
1330
|
+
}
|
|
1033
1331
|
const deleteSnapshots =
|
|
1034
1332
|
this.captureLocalDeleteSnapshots?.(rel, kind) ?? [];
|
|
1035
1333
|
this.pendingChanges.set(abs, {
|