@rljson/fs-agent 0.0.17 → 0.0.19

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
  /**
@@ -133,8 +141,24 @@ export declare class FsAgent {
133
141
  private _stopSync?;
134
142
  private _stopSyncFromDb?;
135
143
  private _lastSentRef?;
144
+ /**
145
+ * The incoming ref most recently applied. Retired from the connector's dedup
146
+ * sets when the next one supersedes it, so a peer returning the tree to that
147
+ * state can still reach this agent.
148
+ */
149
+ private _lastAppliedRef?;
136
150
  /** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
137
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;
138
162
  private _timeouts;
139
163
  /** Client-only: resolve DAG-branch conflicts into merge revisions. */
140
164
  private _resolveConflicts;
@@ -400,6 +424,25 @@ export declare class FsAgent {
400
424
  * @param map - Content map (relativePath → blobId)
401
425
  */
402
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;
403
446
  /**
404
447
  * Derives a deterministic content key from an FsTree.
405
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). */
@@ -697,7 +704,7 @@ class FsScanner {
697
704
  if (!this._safetyTimer) {
698
705
  this._safetyTimer = setInterval(() => {
699
706
  void this._runSafetyRescan();
700
- }, 3e4);
707
+ }, SAFETY_RESCAN_INTERVAL_MS);
701
708
  this._safetyTimer.unref?.();
702
709
  }
703
710
  }
@@ -708,7 +715,10 @@ class FsScanner {
708
715
  * scan failures are no-ops.
709
716
  */
710
717
  async _runSafetyRescan() {
711
- if (this._paused || this._stopRequested) return;
718
+ if (this._stopRequested) return;
719
+ if (this._paused && !this._pauseLooksStuck({ type: "safety-rescan", path: "." })) {
720
+ return;
721
+ }
712
722
  const prevKey = this._tree ? this._safetyContentKey(this._tree) : null;
713
723
  try {
714
724
  await this.scan();
@@ -718,7 +728,7 @@ class FsScanner {
718
728
  );
719
729
  return;
720
730
  }
721
- if (this._paused || this._stopRequested) return;
731
+ if (this._stopRequested) return;
722
732
  const nextKey = this._tree ? this._safetyContentKey(this._tree) : null;
723
733
  if (prevKey !== nextKey) {
724
734
  console.warn(
@@ -819,8 +829,24 @@ class FsScanner {
819
829
  }
820
830
  return void 0;
821
831
  }
832
+ /**
833
+ * Whether this notification should escape the pause.
834
+ *
835
+ * Only the safety rescan may, and only once the pause has outlasted any
836
+ * plausible restore. Letting it through a SHORT pause reintroduces exactly
837
+ * the echo the pause prevents — a rescan firing mid-restore notifies, the
838
+ * agent stores a tree built from half-restored files, and delete propagation
839
+ * breaks (caught by the client-server suite, not by unit tests).
840
+ * @param change - The pending notification.
841
+ * @returns `true` when the pause has lasted long enough to look stuck.
842
+ */
843
+ _pauseLooksStuck(change) {
844
+ if (change.type !== "safety-rescan") return false;
845
+ if (this._pausedAt === null) return false;
846
+ return Date.now() - this._pausedAt >= STUCK_PAUSE_MS;
847
+ }
822
848
  async _notifyChange(change) {
823
- if (this._paused) {
849
+ if (this._paused && !this._pauseLooksStuck(change)) {
824
850
  return;
825
851
  }
826
852
  for (const callback of this._changeCallbacks) {
@@ -837,6 +863,10 @@ class FsScanner {
837
863
  }
838
864
  stopWatch() {
839
865
  this._stopRequested = true;
866
+ if (this._autoResumeTimer) {
867
+ clearTimeout(this._autoResumeTimer);
868
+ this._autoResumeTimer = null;
869
+ }
840
870
  if (this._safetyTimer) {
841
871
  clearInterval(this._safetyTimer);
842
872
  this._safetyTimer = null;
@@ -847,12 +877,37 @@ class FsScanner {
847
877
  }
848
878
  }
849
879
  /**
850
- * Temporarily pause file change notifications
851
- * Used to prevent loops when updating filesystem from external source
880
+ * Temporarily pause file change notifications, so an external restore does
881
+ * not loop back as a local change.
882
+ *
883
+ * `autoResumeMs` bounds the pause. A pause taken on socket disconnect is
884
+ * released by the matching reconnect — and when that reconnect never fires,
885
+ * an unbounded pause silences the node permanently. A bounded one degrades
886
+ * to a little duplicate work instead, which is the right way round: the
887
+ * loop-suppression this exists for is an optimisation, staying alive is not.
888
+ * @param autoResumeMs - Release the pause after this many milliseconds.
889
+ * Omit to pause until an explicit `resumeWatch()`.
852
890
  */
853
- pauseWatch() {
891
+ pauseWatch(autoResumeMs) {
892
+ if (!this._paused) this._pausedAt = Date.now();
854
893
  this._paused = true;
855
894
  this._missedChangesDuringPause = false;
895
+ if (this._autoResumeTimer) {
896
+ clearTimeout(this._autoResumeTimer);
897
+ this._autoResumeTimer = null;
898
+ }
899
+ if (autoResumeMs !== void 0) {
900
+ this._autoResumeTimer = setTimeout(() => {
901
+ this._autoResumeTimer = null;
902
+ if (this._paused) {
903
+ console.warn(
904
+ `[fs-scanner] pause exceeded ${autoResumeMs}ms without a resume — releasing it so ${this._rootPath} keeps syncing`
905
+ );
906
+ this.resumeWatch();
907
+ }
908
+ }, autoResumeMs);
909
+ this._autoResumeTimer.unref?.();
910
+ }
856
911
  }
857
912
  /**
858
913
  * Resume file change notifications.
@@ -860,8 +915,13 @@ class FsScanner {
860
915
  * an asynchronous rescan so that syncToDb can detect and push the changes.
861
916
  */
862
917
  resumeWatch() {
918
+ if (this._autoResumeTimer) {
919
+ clearTimeout(this._autoResumeTimer);
920
+ this._autoResumeTimer = null;
921
+ }
863
922
  const missedChanges = this._missedChangesDuringPause;
864
923
  this._paused = false;
924
+ this._pausedAt = null;
865
925
  this._missedChangesDuringPause = false;
866
926
  if (missedChanges) {
867
927
  void this._rescanAfterPause();
@@ -921,6 +981,7 @@ const DEFAULT_TIMEOUTS = {
921
981
  processRefRetryDelayMs: 5e3,
922
982
  recoveryRetries: 10
923
983
  };
984
+ const DISCONNECT_PAUSE_MAX_MS = 3e4;
924
985
  const SYNC_ERROR_FILE = ".sync-errors.log";
925
986
  const ATOMIC_TMP_PREFIX = ".fsagent-tmp-";
926
987
  class FsAgent {
@@ -933,8 +994,24 @@ class FsAgent {
933
994
  _stopSync;
934
995
  _stopSyncFromDb;
935
996
  _lastSentRef;
997
+ /**
998
+ * The incoming ref most recently applied. Retired from the connector's dedup
999
+ * sets when the next one supersedes it, so a peer returning the tree to that
1000
+ * state can still reach this agent.
1001
+ */
1002
+ _lastAppliedRef;
936
1003
  /** Content fingerprint of the last tree we broadcasted (paths+blobIds) */
937
1004
  _lastSentContentKey;
1005
+ /**
1006
+ * True while a ref received from a peer is being applied to disk.
1007
+ *
1008
+ * The safety rescan cannot tell a local change the watcher missed (which it
1009
+ * must broadcast) from a remote change not yet applied here (which it must
1010
+ * not). The agent can: while this is set, the disk is mid-way through
1011
+ * someone else's revision, so a rescan-driven push would re-assert our stale
1012
+ * view — and undo a deletion the peer just made.
1013
+ */
1014
+ _remoteApplyInFlight = false;
938
1015
  _timeouts;
939
1016
  /** Client-only: resolve DAG-branch conflicts into merge revisions. */
940
1017
  _resolveConflicts;
@@ -1119,6 +1196,7 @@ ${err.stack}` : String(err);
1119
1196
  * via an explicit send, which pre-empts the Connector's db-observer path.
1120
1197
  */
1121
1198
  async _sendRef(connector, ref, predecessorRefs) {
1199
+ connector.invalidateSent?.(ref);
1122
1200
  if (this._resolveConflicts) {
1123
1201
  connector.setPredecessors(predecessorRefs ?? []);
1124
1202
  }
@@ -1530,7 +1608,10 @@ ${err.stack}` : String(err);
1530
1608
  );
1531
1609
  }
1532
1610
  let debounceTimer = null;
1533
- const debouncedSync = () => {
1611
+ const debouncedSync = (change) => {
1612
+ if (change?.type === "safety-rescan" && this._remoteApplyInFlight) {
1613
+ return;
1614
+ }
1534
1615
  if (debounceTimer) clearTimeout(debounceTimer);
1535
1616
  debounceTimer = setTimeout(async () => {
1536
1617
  debounceTimer = null;
@@ -1719,6 +1800,30 @@ ${err.stack}` : String(err);
1719
1800
  );
1720
1801
  return sorted.map(([p, b]) => `${p}:${b}`).join("\n");
1721
1802
  }
1803
+ /**
1804
+ * Records `treeRef` as the ref describing this folder's current state, and
1805
+ * retires the one it supersedes from the connector's dedup sets.
1806
+ *
1807
+ * A tree ref is a pure content hash, so a folder that returns to an earlier
1808
+ * state re-derives that state's exact ref. The connector drops an incoming
1809
+ * ref it has already received, which assumes a state is reached once and
1810
+ * never returned to — false for content-addressed state, and false in the
1811
+ * most ordinary way possible: create a file, then delete it again.
1812
+ *
1813
+ * Retiring the SUPERSEDED ref is what keeps the return trip deliverable.
1814
+ * The ref just adopted stays deduped, so a peer re-advertising the state
1815
+ * this folder is actually in is still suppressed as the echo it is. That
1816
+ * only works if every adopted state passes through here — a state adopted
1817
+ * silently is never retired and blocks its own return for good.
1818
+ * @param connector - Connector whose dedup sets to retire from.
1819
+ * @param treeRef - The ref that now describes this folder.
1820
+ */
1821
+ _adoptAppliedRef(connector, treeRef) {
1822
+ if (this._lastAppliedRef && this._lastAppliedRef !== treeRef) {
1823
+ connector.invalidateSent?.(this._lastAppliedRef);
1824
+ }
1825
+ this._lastAppliedRef = treeRef;
1826
+ }
1722
1827
  /**
1723
1828
  * Derives a deterministic content key from an FsTree.
1724
1829
  * @param tree - Tree structure to derive content key from
@@ -1809,6 +1914,7 @@ ${err.stack}` : String(err);
1809
1914
  const maxAttempts = this._timeouts.processRefRetries + 1;
1810
1915
  for (let attempt = 1; attempt <= maxAttempts; attempt++) {
1811
1916
  this._scanner.pauseWatch();
1917
+ this._remoteApplyInFlight = true;
1812
1918
  try {
1813
1919
  const incomingTree = await FsAgent._withTimeout(
1814
1920
  this._fetchTreeFromDb(db, treeKey, treeRef),
@@ -1830,6 +1936,7 @@ ${err.stack}` : String(err);
1830
1936
  console.log(
1831
1937
  `[FsAgent] syncFromDb: equivalent content, skipping restore (incoming=${incomingFiles.size} entries, current=${currentFiles.size} entries, ref=${treeRef.slice(0, 8)}…)`
1832
1938
  );
1939
+ this._adoptAppliedRef(connector, treeRef);
1833
1940
  return;
1834
1941
  }
1835
1942
  if (this._resolveConflicts && this._currentRef && predecessorRefs && predecessorRefs.length > 0) {
@@ -1870,6 +1977,7 @@ ${err.stack}` : String(err);
1870
1977
  skipNotification: true,
1871
1978
  previous
1872
1979
  });
1980
+ this._adoptAppliedRef(connector, treeRef);
1873
1981
  this._lastSentRef = postRestoreRef;
1874
1982
  this._lastSentContentKey = this._contentKeyFromTree(postRestoreTree);
1875
1983
  this._currentRef = postRestoreRef;
@@ -1903,6 +2011,7 @@ ${err.stack}` : String(err);
1903
2011
  );
1904
2012
  }
1905
2013
  } finally {
2014
+ this._remoteApplyInFlight = false;
1906
2015
  this._scanner.resumeWatch();
1907
2016
  }
1908
2017
  await new Promise(
@@ -1911,6 +2020,9 @@ ${err.stack}` : String(err);
1911
2020
  }
1912
2021
  };
1913
2022
  const scheduleProcess = (ref, delayMs, recoveryAttempt, predecessorRefs) => {
2023
+ if (pendingRef && pendingRef !== ref) {
2024
+ connector.invalidateReceived(pendingRef);
2025
+ }
1914
2026
  pendingRef = ref;
1915
2027
  pendingRecoveryAttempt = recoveryAttempt;
1916
2028
  pendingPredecessorRefs = predecessorRefs;
@@ -1991,7 +2103,7 @@ ${err.stack}` : String(err);
1991
2103
  };
1992
2104
  if (typeof client.onDisconnect === "function") {
1993
2105
  client.onDisconnect(() => {
1994
- agent.scanner.pauseWatch();
2106
+ agent.scanner.pauseWatch(DISCONNECT_PAUSE_MAX_MS);
1995
2107
  });
1996
2108
  }
1997
2109
  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). */
@@ -146,15 +160,35 @@ export declare class FsScanner {
146
160
  private static get _isWindows();
147
161
  private _handleFileChange;
148
162
  private _findTreeByPath;
163
+ /**
164
+ * Whether this notification should escape the pause.
165
+ *
166
+ * Only the safety rescan may, and only once the pause has outlasted any
167
+ * plausible restore. Letting it through a SHORT pause reintroduces exactly
168
+ * the echo the pause prevents — a rescan firing mid-restore notifies, the
169
+ * agent stores a tree built from half-restored files, and delete propagation
170
+ * breaks (caught by the client-server suite, not by unit tests).
171
+ * @param change - The pending notification.
172
+ * @returns `true` when the pause has lasted long enough to look stuck.
173
+ */
174
+ private _pauseLooksStuck;
149
175
  private _notifyChange;
150
176
  onChange(callback: FsChangeCallback): void;
151
177
  offChange(callback: FsChangeCallback): void;
152
178
  stopWatch(): void;
153
179
  /**
154
- * Temporarily pause file change notifications
155
- * Used to prevent loops when updating filesystem from external source
180
+ * Temporarily pause file change notifications, so an external restore does
181
+ * not loop back as a local change.
182
+ *
183
+ * `autoResumeMs` bounds the pause. A pause taken on socket disconnect is
184
+ * released by the matching reconnect — and when that reconnect never fires,
185
+ * an unbounded pause silences the node permanently. A bounded one degrades
186
+ * to a little duplicate work instead, which is the right way round: the
187
+ * loop-suppression this exists for is an optimisation, staying alive is not.
188
+ * @param autoResumeMs - Release the pause after this many milliseconds.
189
+ * Omit to pause until an explicit `resumeWatch()`.
156
190
  */
157
- pauseWatch(): void;
191
+ pauseWatch(autoResumeMs?: number): void;
158
192
  /**
159
193
  * Resume file change notifications.
160
194
  * 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.17",
3
+ "version": "0.0.19",
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",
@@ -28,7 +28,7 @@
28
28
  "updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
29
29
  },
30
30
  "devDependencies": {
31
- "@rljson/server": "^0.0.10",
31
+ "@rljson/server": "^0.0.43",
32
32
  "@types/node": "^25.3.1",
33
33
  "@typescript-eslint/eslint-plugin": "^8.56.1",
34
34
  "@typescript-eslint/parser": "^8.56.1",
@@ -51,7 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@rljson/bs": "^0.0.21",
54
- "@rljson/db": "^0.0.23",
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",