@rljson/fs-agent 0.0.18 → 0.0.20

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.
@@ -112,6 +112,14 @@ export interface TimeoutConfig {
112
112
  */
113
113
  recoveryRetries?: number;
114
114
  }
115
+ /**
116
+ * Longest a disconnect may keep the watcher paused.
117
+ *
118
+ * Generous enough for an ordinary reconnect, short enough that a reconnect
119
+ * which never arrives costs a few seconds of missed notifications rather than
120
+ * every write from then on.
121
+ */
122
+ export declare const DISCONNECT_PAUSE_MAX_MS = 30000;
115
123
  /** Filename for sync error log written to the sync folder */
116
124
  export declare const SYNC_ERROR_FILE = ".sync-errors.log";
117
125
  /**
@@ -141,6 +149,16 @@ export declare class FsAgent {
141
149
  private _lastAppliedRef?;
142
150
  /** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
143
151
  private _lastSentContentKey?;
152
+ /**
153
+ * True while a ref received from a peer is being applied to disk.
154
+ *
155
+ * The safety rescan cannot tell a local change the watcher missed (which it
156
+ * must broadcast) from a remote change not yet applied here (which it must
157
+ * not). The agent can: while this is set, the disk is mid-way through
158
+ * someone else's revision, so a rescan-driven push would re-assert our stale
159
+ * view — and undo a deletion the peer just made.
160
+ */
161
+ private _remoteApplyInFlight;
144
162
  private _timeouts;
145
163
  /** Client-only: resolve DAG-branch conflicts into merge revisions. */
146
164
  private _resolveConflicts;
@@ -406,6 +424,25 @@ export declare class FsAgent {
406
424
  * @param map - Content map (relativePath → blobId)
407
425
  */
408
426
  private _contentKeyFromMap;
427
+ /**
428
+ * Records `treeRef` as the ref describing this folder's current state, and
429
+ * retires the one it supersedes from the connector's dedup sets.
430
+ *
431
+ * A tree ref is a pure content hash, so a folder that returns to an earlier
432
+ * state re-derives that state's exact ref. The connector drops an incoming
433
+ * ref it has already received, which assumes a state is reached once and
434
+ * never returned to — false for content-addressed state, and false in the
435
+ * most ordinary way possible: create a file, then delete it again.
436
+ *
437
+ * Retiring the SUPERSEDED ref is what keeps the return trip deliverable.
438
+ * The ref just adopted stays deduped, so a peer re-advertising the state
439
+ * this folder is actually in is still suppressed as the echo it is. That
440
+ * only works if every adopted state passes through here — a state adopted
441
+ * silently is never retired and blocks its own return for good.
442
+ * @param connector - Connector whose dedup sets to retire from.
443
+ * @param treeRef - The ref that now describes this folder.
444
+ */
445
+ private _adoptAppliedRef;
409
446
  /**
410
447
  * Derives a deterministic content key from an FsTree.
411
448
  * @param tree - Tree structure to derive content key from
package/dist/fs-agent.js CHANGED
@@ -413,6 +413,9 @@ class FsDbAdapter {
413
413
  return this.db;
414
414
  }
415
415
  }
416
+ const _envRescan = Number(process.env["RLJSON_FS_RESCAN_MS"]);
417
+ const SAFETY_RESCAN_INTERVAL_MS = Number.isFinite(_envRescan) && _envRescan > 0 ? _envRescan : 5e3;
418
+ const STUCK_PAUSE_MS = 15e3;
416
419
  class FsScanner {
417
420
  _rootPath;
418
421
  _tree = null;
@@ -424,6 +427,10 @@ class FsScanner {
424
427
  _missedChangesDuringPause = false;
425
428
  /** Periodic full-rescan timer that catches events the native watcher drops. */
426
429
  _safetyTimer = null;
430
+ /** When the current pause began, or null when not paused. */
431
+ _pausedAt = null;
432
+ /** Releases a pause whose resume never arrived (see {@link pauseWatch}). */
433
+ _autoResumeTimer = null;
427
434
  /** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
428
435
  _stopRequested = false;
429
436
  /** Path→content cache backing {@link FsScanOptions.scanCachePath} (unused when unset). */
@@ -434,6 +441,8 @@ class FsScanner {
434
441
  _nextBlobCache = /* @__PURE__ */ new Map();
435
442
  /** Ensures the persisted cache is read from disk only once. */
436
443
  _cacheLoaded = false;
444
+ /** Entries that vanished mid-scan, counted per {@link scan} for one summary. */
445
+ _vanishedDuringScan = 0;
437
446
  constructor(rootPath, options = {}) {
438
447
  this._rootPath = rootPath;
439
448
  this._options = {
@@ -478,6 +487,7 @@ class FsScanner {
478
487
  this._nextBlobCache = /* @__PURE__ */ new Map();
479
488
  }
480
489
  const trees = /* @__PURE__ */ new Map();
490
+ this._vanishedDuringScan = 0;
481
491
  let rootTree;
482
492
  try {
483
493
  rootTree = await this._scanDirectory(this._rootPath, ".", 0, trees);
@@ -493,6 +503,11 @@ class FsScanner {
493
503
  "Failed to generate hash for root tree. Tree structure may be invalid."
494
504
  );
495
505
  }
506
+ if (this._vanishedDuringScan > 0) {
507
+ console.warn(
508
+ `[fs-scanner] ${this._vanishedDuringScan} entr${this._vanishedDuringScan === 1 ? "y" : "ies"} vanished during the scan of ${this._rootPath} — skipped; the next scan picks up the settled state`
509
+ );
510
+ }
496
511
  trees.set(rootHashStr, rootTree);
497
512
  this._tree = {
498
513
  rootHash: rootHashStr,
@@ -506,6 +521,7 @@ class FsScanner {
506
521
  }
507
522
  async _scanDirectory(absolutePath, relativePath, depth, trees) {
508
523
  const entries = await readdir(absolutePath, { withFileTypes: true });
524
+ const childTrees = [];
509
525
  const childRefs = [];
510
526
  for (const entry of entries) {
511
527
  if (this._shouldIgnore(entry.name)) {
@@ -519,78 +535,87 @@ class FsScanner {
519
535
  if (this._options.maxDepth !== void 0 && depth >= this._options.maxDepth) {
520
536
  continue;
521
537
  }
522
- const childStats = await stat(childPath);
523
- if (entry.isDirectory()) {
524
- const childTree = await this._scanDirectory(
525
- childPath,
526
- childRelPath,
527
- depth + 1,
528
- trees
529
- );
530
- hip(childTree);
531
- const childHashStr = childTree._hash;
532
- trees.set(childHashStr, childTree);
533
- childRefs.push(childHashStr);
534
- } else if (entry.isFile()) {
535
- const mtimeMs = childStats.mtime.getTime();
536
- const cached = this._scanCachePath ? this._blobCache.get(childRelPath) : void 0;
537
- let blobId;
538
- if (cached && cached.mtime === mtimeMs && cached.size === childStats.size) {
539
- blobId = cached.blobId;
540
- } else {
541
- let fileContent;
542
- try {
543
- fileContent = await readFile(childPath);
544
- } catch (error) {
545
- throw new Error(
546
- `Failed to read file "${childRelPath}": ${error instanceof Error ? error.message : String(error)}`
547
- );
548
- }
549
- let blobProps;
550
- try {
551
- blobProps = await this._bs.setBlob(fileContent);
552
- } catch (error) {
553
- throw new Error(
554
- `Failed to store blob for file "${childRelPath}": ${error instanceof Error ? error.message : String(error)}`
555
- );
538
+ try {
539
+ const childStats = await stat(childPath);
540
+ if (entry.isDirectory()) {
541
+ const childTree = await this._scanDirectory(
542
+ childPath,
543
+ childRelPath,
544
+ depth + 1,
545
+ trees
546
+ );
547
+ childTrees.push(childTree);
548
+ hip(childTree);
549
+ const childHashStr = childTree._hash;
550
+ trees.set(childHashStr, childTree);
551
+ childRefs.push(childHashStr);
552
+ } else if (entry.isFile()) {
553
+ const mtimeMs = childStats.mtime.getTime();
554
+ const cached = this._scanCachePath ? this._blobCache.get(childRelPath) : void 0;
555
+ let blobId;
556
+ if (cached && cached.mtime === mtimeMs && cached.size === childStats.size) {
557
+ blobId = cached.blobId;
558
+ } else {
559
+ let fileContent;
560
+ try {
561
+ fileContent = await readFile(childPath);
562
+ } catch (error) {
563
+ if (FsScanner._isVanished(error)) throw error;
564
+ throw new Error(
565
+ `Failed to read file "${childRelPath}": ${error instanceof Error ? error.message : String(error)}`
566
+ );
567
+ }
568
+ let blobProps;
569
+ try {
570
+ blobProps = await this._bs.setBlob(fileContent);
571
+ } catch (error) {
572
+ throw new Error(
573
+ `Failed to store blob for file "${childRelPath}": ${error instanceof Error ? error.message : String(error)}`
574
+ );
575
+ }
576
+ if (!blobProps || !blobProps.blobId) {
577
+ throw new Error(
578
+ `Blob storage returned invalid blobId for file "${childRelPath}"`
579
+ );
580
+ }
581
+ blobId = blobProps.blobId;
556
582
  }
557
- if (!blobProps || !blobProps.blobId) {
558
- throw new Error(
559
- `Blob storage returned invalid blobId for file "${childRelPath}"`
560
- );
583
+ if (this._scanCachePath) {
584
+ this._nextBlobCache.set(childRelPath, {
585
+ mtime: mtimeMs,
586
+ size: childStats.size,
587
+ blobId
588
+ });
561
589
  }
562
- blobId = blobProps.blobId;
563
- }
564
- if (this._scanCachePath) {
565
- this._nextBlobCache.set(childRelPath, {
566
- mtime: mtimeMs,
590
+ const fileMeta = {
591
+ name: entry.name,
592
+ type: "file",
593
+ relativePath: childRelPath,
567
594
  size: childStats.size,
595
+ // mtime is kept for files (restore preserves it, so it round-trips to
596
+ // the same ref on every client) but NOT for directories (a folder's
597
+ // mtime is per-machine and does not round-trip). The absolute `path`
598
+ // is excluded everywhere — it is folder-specific.
599
+ mtime: mtimeMs,
568
600
  blobId
569
- });
601
+ // Link to content in Bs
602
+ };
603
+ const fileTree = {
604
+ id: entry.name,
605
+ isParent: false,
606
+ meta: fileMeta,
607
+ children: null
608
+ };
609
+ childTrees.push(fileTree);
610
+ hip(fileTree);
611
+ const fileTreeHashStr = fileTree._hash;
612
+ trees.set(fileTreeHashStr, fileTree);
613
+ childRefs.push(fileTreeHashStr);
570
614
  }
571
- const fileMeta = {
572
- name: entry.name,
573
- type: "file",
574
- relativePath: childRelPath,
575
- size: childStats.size,
576
- // mtime is kept for files (restore preserves it, so it round-trips to
577
- // the same ref on every client) but NOT for directories (a folder's
578
- // mtime is per-machine and does not round-trip). The absolute `path`
579
- // is excluded everywhere — it is folder-specific.
580
- mtime: mtimeMs,
581
- blobId
582
- // Link to content in Bs
583
- };
584
- const fileTree = {
585
- id: entry.name,
586
- isParent: false,
587
- meta: fileMeta,
588
- children: null
589
- };
590
- hip(fileTree);
591
- const fileTreeHashStr = fileTree._hash;
592
- trees.set(fileTreeHashStr, fileTree);
593
- childRefs.push(fileTreeHashStr);
615
+ } catch (error) {
616
+ if (!FsScanner._isVanished(error)) throw error;
617
+ this._vanishedDuringScan++;
618
+ continue;
594
619
  }
595
620
  }
596
621
  const dirName = relativePath === "." ? "." : (
@@ -697,7 +722,7 @@ class FsScanner {
697
722
  if (!this._safetyTimer) {
698
723
  this._safetyTimer = setInterval(() => {
699
724
  void this._runSafetyRescan();
700
- }, 3e4);
725
+ }, SAFETY_RESCAN_INTERVAL_MS);
701
726
  this._safetyTimer.unref?.();
702
727
  }
703
728
  }
@@ -708,7 +733,10 @@ class FsScanner {
708
733
  * scan failures are no-ops.
709
734
  */
710
735
  async _runSafetyRescan() {
711
- if (this._paused || this._stopRequested) return;
736
+ if (this._stopRequested) return;
737
+ if (this._paused && !this._pauseLooksStuck({ type: "safety-rescan", path: "." })) {
738
+ return;
739
+ }
712
740
  const prevKey = this._tree ? this._safetyContentKey(this._tree) : null;
713
741
  try {
714
742
  await this.scan();
@@ -718,7 +746,7 @@ class FsScanner {
718
746
  );
719
747
  return;
720
748
  }
721
- if (this._paused || this._stopRequested) return;
749
+ if (this._stopRequested) return;
722
750
  const nextKey = this._tree ? this._safetyContentKey(this._tree) : null;
723
751
  if (prevKey !== nextKey) {
724
752
  console.warn(
@@ -756,6 +784,20 @@ class FsScanner {
756
784
  static _errMessage(err) {
757
785
  return err instanceof Error ? err.message : String(err);
758
786
  }
787
+ /**
788
+ * Whether a caught value is a "no longer there" filesystem error.
789
+ *
790
+ * A scan walks a directory that is still being written to. `readdir` returns
791
+ * a name, and by the time the entry is `stat`ed, read, or descended into it
792
+ * can be gone — a save-and-rename editor, a build tool, a peer applying a
793
+ * deletion. That is ordinary, not exceptional.
794
+ * @param err - The caught value.
795
+ * @returns `true` for ENOENT / ENOTDIR.
796
+ */
797
+ static _isVanished(err) {
798
+ const code = err?.code;
799
+ return code === "ENOENT" || code === "ENOTDIR";
800
+ }
759
801
  /** Whether the host is Windows — gates Windows-specific watcher hardening. */
760
802
  static get _isWindows() {
761
803
  return process.platform === "win32";
@@ -819,8 +861,24 @@ class FsScanner {
819
861
  }
820
862
  return void 0;
821
863
  }
864
+ /**
865
+ * Whether this notification should escape the pause.
866
+ *
867
+ * Only the safety rescan may, and only once the pause has outlasted any
868
+ * plausible restore. Letting it through a SHORT pause reintroduces exactly
869
+ * the echo the pause prevents — a rescan firing mid-restore notifies, the
870
+ * agent stores a tree built from half-restored files, and delete propagation
871
+ * breaks (caught by the client-server suite, not by unit tests).
872
+ * @param change - The pending notification.
873
+ * @returns `true` when the pause has lasted long enough to look stuck.
874
+ */
875
+ _pauseLooksStuck(change) {
876
+ if (change.type !== "safety-rescan") return false;
877
+ if (this._pausedAt === null) return false;
878
+ return Date.now() - this._pausedAt >= STUCK_PAUSE_MS;
879
+ }
822
880
  async _notifyChange(change) {
823
- if (this._paused) {
881
+ if (this._paused && !this._pauseLooksStuck(change)) {
824
882
  return;
825
883
  }
826
884
  for (const callback of this._changeCallbacks) {
@@ -837,6 +895,10 @@ class FsScanner {
837
895
  }
838
896
  stopWatch() {
839
897
  this._stopRequested = true;
898
+ if (this._autoResumeTimer) {
899
+ clearTimeout(this._autoResumeTimer);
900
+ this._autoResumeTimer = null;
901
+ }
840
902
  if (this._safetyTimer) {
841
903
  clearInterval(this._safetyTimer);
842
904
  this._safetyTimer = null;
@@ -847,12 +909,37 @@ class FsScanner {
847
909
  }
848
910
  }
849
911
  /**
850
- * Temporarily pause file change notifications
851
- * Used to prevent loops when updating filesystem from external source
912
+ * Temporarily pause file change notifications, so an external restore does
913
+ * not loop back as a local change.
914
+ *
915
+ * `autoResumeMs` bounds the pause. A pause taken on socket disconnect is
916
+ * released by the matching reconnect — and when that reconnect never fires,
917
+ * an unbounded pause silences the node permanently. A bounded one degrades
918
+ * to a little duplicate work instead, which is the right way round: the
919
+ * loop-suppression this exists for is an optimisation, staying alive is not.
920
+ * @param autoResumeMs - Release the pause after this many milliseconds.
921
+ * Omit to pause until an explicit `resumeWatch()`.
852
922
  */
853
- pauseWatch() {
923
+ pauseWatch(autoResumeMs) {
924
+ if (!this._paused) this._pausedAt = Date.now();
854
925
  this._paused = true;
855
926
  this._missedChangesDuringPause = false;
927
+ if (this._autoResumeTimer) {
928
+ clearTimeout(this._autoResumeTimer);
929
+ this._autoResumeTimer = null;
930
+ }
931
+ if (autoResumeMs !== void 0) {
932
+ this._autoResumeTimer = setTimeout(() => {
933
+ this._autoResumeTimer = null;
934
+ if (this._paused) {
935
+ console.warn(
936
+ `[fs-scanner] pause exceeded ${autoResumeMs}ms without a resume — releasing it so ${this._rootPath} keeps syncing`
937
+ );
938
+ this.resumeWatch();
939
+ }
940
+ }, autoResumeMs);
941
+ this._autoResumeTimer.unref?.();
942
+ }
856
943
  }
857
944
  /**
858
945
  * Resume file change notifications.
@@ -860,8 +947,13 @@ class FsScanner {
860
947
  * an asynchronous rescan so that syncToDb can detect and push the changes.
861
948
  */
862
949
  resumeWatch() {
950
+ if (this._autoResumeTimer) {
951
+ clearTimeout(this._autoResumeTimer);
952
+ this._autoResumeTimer = null;
953
+ }
863
954
  const missedChanges = this._missedChangesDuringPause;
864
955
  this._paused = false;
956
+ this._pausedAt = null;
865
957
  this._missedChangesDuringPause = false;
866
958
  if (missedChanges) {
867
959
  void this._rescanAfterPause();
@@ -921,6 +1013,7 @@ const DEFAULT_TIMEOUTS = {
921
1013
  processRefRetryDelayMs: 5e3,
922
1014
  recoveryRetries: 10
923
1015
  };
1016
+ const DISCONNECT_PAUSE_MAX_MS = 3e4;
924
1017
  const SYNC_ERROR_FILE = ".sync-errors.log";
925
1018
  const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
926
1019
  class FsAgent {
@@ -941,6 +1034,16 @@ class FsAgent {
941
1034
  _lastAppliedRef;
942
1035
  /** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
943
1036
  _lastSentContentKey;
1037
+ /**
1038
+ * True while a ref received from a peer is being applied to disk.
1039
+ *
1040
+ * The safety rescan cannot tell a local change the watcher missed (which it
1041
+ * must broadcast) from a remote change not yet applied here (which it must
1042
+ * not). The agent can: while this is set, the disk is mid-way through
1043
+ * someone else's revision, so a rescan-driven push would re-assert our stale
1044
+ * view — and undo a deletion the peer just made.
1045
+ */
1046
+ _remoteApplyInFlight = false;
944
1047
  _timeouts;
945
1048
  /** Client-only: resolve DAG-branch conflicts into merge revisions. */
946
1049
  _resolveConflicts;
@@ -1537,7 +1640,10 @@ ${err.stack}` : String(err);
1537
1640
  );
1538
1641
  }
1539
1642
  let debounceTimer = null;
1540
- const debouncedSync = () => {
1643
+ const debouncedSync = (change) => {
1644
+ if (change?.type === "safety-rescan" && this._remoteApplyInFlight) {
1645
+ return;
1646
+ }
1541
1647
  if (debounceTimer) clearTimeout(debounceTimer);
1542
1648
  debounceTimer = setTimeout(async () => {
1543
1649
  debounceTimer = null;
@@ -1726,6 +1832,30 @@ ${err.stack}` : String(err);
1726
1832
  );
1727
1833
  return sorted.map(([p, b]) => `${p}:${b}`).join("\n");
1728
1834
  }
1835
+ /**
1836
+ * Records `treeRef` as the ref describing this folder's current state, and
1837
+ * retires the one it supersedes from the connector's dedup sets.
1838
+ *
1839
+ * A tree ref is a pure content hash, so a folder that returns to an earlier
1840
+ * state re-derives that state's exact ref. The connector drops an incoming
1841
+ * ref it has already received, which assumes a state is reached once and
1842
+ * never returned to — false for content-addressed state, and false in the
1843
+ * most ordinary way possible: create a file, then delete it again.
1844
+ *
1845
+ * Retiring the SUPERSEDED ref is what keeps the return trip deliverable.
1846
+ * The ref just adopted stays deduped, so a peer re-advertising the state
1847
+ * this folder is actually in is still suppressed as the echo it is. That
1848
+ * only works if every adopted state passes through here — a state adopted
1849
+ * silently is never retired and blocks its own return for good.
1850
+ * @param connector - Connector whose dedup sets to retire from.
1851
+ * @param treeRef - The ref that now describes this folder.
1852
+ */
1853
+ _adoptAppliedRef(connector, treeRef) {
1854
+ if (this._lastAppliedRef && this._lastAppliedRef !== treeRef) {
1855
+ connector.invalidateSent?.(this._lastAppliedRef);
1856
+ }
1857
+ this._lastAppliedRef = treeRef;
1858
+ }
1729
1859
  /**
1730
1860
  * Derives a deterministic content key from an FsTree.
1731
1861
  * @param tree - Tree structure to derive content key from
@@ -1816,6 +1946,7 @@ ${err.stack}` : String(err);
1816
1946
  const maxAttempts = this._timeouts.processRefRetries + 1;
1817
1947
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1818
1948
  this._scanner.pauseWatch();
1949
+ this._remoteApplyInFlight = true;
1819
1950
  try {
1820
1951
  const incomingTree = await FsAgent._withTimeout(
1821
1952
  this._fetchTreeFromDb(db, treeKey, treeRef),
@@ -1837,6 +1968,7 @@ ${err.stack}` : String(err);
1837
1968
  console.log(
1838
1969
  `[FsAgent] syncFromDb: equivalent content, skipping restore (incoming=${incomingFiles.size} entries, current=${currentFiles.size} entries, ref=${treeRef.slice(0, 8)}…)`
1839
1970
  );
1971
+ this._adoptAppliedRef(connector, treeRef);
1840
1972
  return;
1841
1973
  }
1842
1974
  if (this._resolveConflicts && this._currentRef && predecessorRefs && predecessorRefs.length > 0) {
@@ -1877,10 +2009,7 @@ ${err.stack}` : String(err);
1877
2009
  skipNotification: true,
1878
2010
  previous
1879
2011
  });
1880
- if (this._lastAppliedRef && this._lastAppliedRef !== treeRef) {
1881
- connector.invalidateSent?.(this._lastAppliedRef);
1882
- }
1883
- this._lastAppliedRef = treeRef;
2012
+ this._adoptAppliedRef(connector, treeRef);
1884
2013
  this._lastSentRef = postRestoreRef;
1885
2014
  this._lastSentContentKey = this._contentKeyFromTree(postRestoreTree);
1886
2015
  this._currentRef = postRestoreRef;
@@ -1914,6 +2043,7 @@ ${err.stack}` : String(err);
1914
2043
  );
1915
2044
  }
1916
2045
  } finally {
2046
+ this._remoteApplyInFlight = false;
1917
2047
  this._scanner.resumeWatch();
1918
2048
  }
1919
2049
  await new Promise(
@@ -1922,6 +2052,9 @@ ${err.stack}` : String(err);
1922
2052
  }
1923
2053
  };
1924
2054
  const scheduleProcess = (ref, delayMs, recoveryAttempt, predecessorRefs) => {
2055
+ if (pendingRef && pendingRef !== ref) {
2056
+ connector.invalidateReceived(pendingRef);
2057
+ }
1925
2058
  pendingRef = ref;
1926
2059
  pendingRecoveryAttempt = recoveryAttempt;
1927
2060
  pendingPredecessorRefs = predecessorRefs;
@@ -2002,7 +2135,7 @@ ${err.stack}` : String(err);
2002
2135
  };
2003
2136
  if (typeof client.onDisconnect === "function") {
2004
2137
  client.onDisconnect(() => {
2005
- agent.scanner.pauseWatch();
2138
+ agent.scanner.pauseWatch(DISCONNECT_PAUSE_MAX_MS);
2006
2139
  });
2007
2140
  }
2008
2141
  if (typeof client.onReconnect === "function") {
@@ -1,6 +1,16 @@
1
1
  import { Bs } from '@rljson/bs';
2
2
  import { Json } from '@rljson/json';
3
3
  import { Tree, TreeRef } from '@rljson/rljson';
4
+ export declare const SAFETY_RESCAN_INTERVAL_MS: number;
5
+ /**
6
+ * How long a pause must last before the safety rescan treats it as stuck and
7
+ * reports through it.
8
+ *
9
+ * Longer than any restore takes, so ordinary loop-suppression is untouched;
10
+ * short enough that a pause whose resume never arrives costs seconds, not
11
+ * every write from then on.
12
+ */
13
+ export declare const STUCK_PAUSE_MS = 15000;
4
14
  /**
5
15
  * Metadata stored in Tree.meta for file system nodes
6
16
  */
@@ -93,6 +103,10 @@ export declare class FsScanner {
93
103
  private _missedChangesDuringPause;
94
104
  /** Periodic full-rescan timer that catches events the native watcher drops. */
95
105
  private _safetyTimer;
106
+ /** When the current pause began, or null when not paused. */
107
+ private _pausedAt;
108
+ /** Releases a pause whose resume never arrived (see {@link pauseWatch}). */
109
+ private _autoResumeTimer;
96
110
  /** Set by stopWatch() so a pending watcher reinstall / rescan bails out. */
97
111
  private _stopRequested;
98
112
  /** Path→content cache backing {@link FsScanOptions.scanCachePath} (unused when unset). */
@@ -103,6 +117,8 @@ export declare class FsScanner {
103
117
  private _nextBlobCache;
104
118
  /** Ensures the persisted cache is read from disk only once. */
105
119
  private _cacheLoaded;
120
+ /** Entries that vanished mid-scan, counted per {@link scan} for one summary. */
121
+ private _vanishedDuringScan;
106
122
  constructor(rootPath: string, options?: FsScanOptions);
107
123
  get tree(): FsTree | null;
108
124
  get rootPath(): string;
@@ -142,19 +158,50 @@ export declare class FsScanner {
142
158
  * @returns A message string
143
159
  */
144
160
  private static _errMessage;
161
+ /**
162
+ * Whether a caught value is a "no longer there" filesystem error.
163
+ *
164
+ * A scan walks a directory that is still being written to. `readdir` returns
165
+ * a name, and by the time the entry is `stat`ed, read, or descended into it
166
+ * can be gone — a save-and-rename editor, a build tool, a peer applying a
167
+ * deletion. That is ordinary, not exceptional.
168
+ * @param err - The caught value.
169
+ * @returns `true` for ENOENT / ENOTDIR.
170
+ */
171
+ private static _isVanished;
145
172
  /** Whether the host is Windows — gates Windows-specific watcher hardening. */
146
173
  private static get _isWindows();
147
174
  private _handleFileChange;
148
175
  private _findTreeByPath;
176
+ /**
177
+ * Whether this notification should escape the pause.
178
+ *
179
+ * Only the safety rescan may, and only once the pause has outlasted any
180
+ * plausible restore. Letting it through a SHORT pause reintroduces exactly
181
+ * the echo the pause prevents — a rescan firing mid-restore notifies, the
182
+ * agent stores a tree built from half-restored files, and delete propagation
183
+ * breaks (caught by the client-server suite, not by unit tests).
184
+ * @param change - The pending notification.
185
+ * @returns `true` when the pause has lasted long enough to look stuck.
186
+ */
187
+ private _pauseLooksStuck;
149
188
  private _notifyChange;
150
189
  onChange(callback: FsChangeCallback): void;
151
190
  offChange(callback: FsChangeCallback): void;
152
191
  stopWatch(): void;
153
192
  /**
154
- * Temporarily pause file change notifications
155
- * Used to prevent loops when updating filesystem from external source
193
+ * Temporarily pause file change notifications, so an external restore does
194
+ * not loop back as a local change.
195
+ *
196
+ * `autoResumeMs` bounds the pause. A pause taken on socket disconnect is
197
+ * released by the matching reconnect — and when that reconnect never fires,
198
+ * an unbounded pause silences the node permanently. A bounded one degrades
199
+ * to a little duplicate work instead, which is the right way round: the
200
+ * loop-suppression this exists for is an optimisation, staying alive is not.
201
+ * @param autoResumeMs - Release the pause after this many milliseconds.
202
+ * Omit to pause until an explicit `resumeWatch()`.
156
203
  */
157
- pauseWatch(): void;
204
+ pauseWatch(autoResumeMs?: number): void;
158
205
  /**
159
206
  * Resume file change notifications.
160
207
  * If any filesystem events were missed during the pause, triggers
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rljson/fs-agent",
3
- "version": "0.0.18",
3
+ "version": "0.0.20",
4
4
  "description": "Rljson fs-agent description",
5
5
  "homepage": "https://github.com/rljson/fs-agent",
6
6
  "bugs": "https://github.com/rljson/fs-agent/issues",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@rljson/bs": "^0.0.21",
54
- "@rljson/db": "^0.0.30",
54
+ "@rljson/db": "^0.0.31",
55
55
  "@rljson/hash": "^0.0.18",
56
56
  "@rljson/io": "^0.0.66",
57
57
  "@rljson/json": "^0.0.23",