@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.
- package/dist/fs-agent.d.ts +37 -0
- package/dist/fs-agent.js +213 -80
- package/dist/fs-scanner.d.ts +50 -3
- package/package.json +2 -2
package/dist/fs-agent.d.ts
CHANGED
|
@@ -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
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
fileContent
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
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 (
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
583
|
+
if (this._scanCachePath) {
|
|
584
|
+
this._nextBlobCache.set(childRelPath, {
|
|
585
|
+
mtime: mtimeMs,
|
|
586
|
+
size: childStats.size,
|
|
587
|
+
blobId
|
|
588
|
+
});
|
|
561
589
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
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
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
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
|
-
},
|
|
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.
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
|
|
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") {
|
package/dist/fs-scanner.d.ts
CHANGED
|
@@ -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
|
-
*
|
|
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.
|
|
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.
|
|
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",
|