@rljson/fs-agent 0.0.39 → 0.0.41
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 +61 -0
- package/dist/fs-agent.js +73 -9
- package/package.json +19 -9
package/dist/fs-agent.d.ts
CHANGED
|
@@ -176,6 +176,15 @@ export declare const TREE_FETCH_CONCURRENCY = 64;
|
|
|
176
176
|
* which is the cheap half of the trade.
|
|
177
177
|
*/
|
|
178
178
|
export declare const RESTORED_BLOB_MEMORY_MAX = 50000;
|
|
179
|
+
/**
|
|
180
|
+
* What an agent concluded about an inbound ref.
|
|
181
|
+
*
|
|
182
|
+
* See `FsAgent._inboundRefVerdict` for why this is one decision rather than
|
|
183
|
+
* several conditions.
|
|
184
|
+
*/
|
|
185
|
+
export type InboundRefVerdict = 'apply' | 'own-echo' | 'stale';
|
|
186
|
+
/** How each non-applying verdict reads in a log line. */
|
|
187
|
+
export declare const VERDICT_REASON: Record<Exclude<InboundRefVerdict, 'apply'>, string>;
|
|
179
188
|
export declare const MASS_DELETE_MIN_FILES = 100;
|
|
180
189
|
/**
|
|
181
190
|
* Above this share of the folder, a prune is treated as suspicious rather than
|
|
@@ -254,6 +263,16 @@ export declare class FsAgent {
|
|
|
254
263
|
* view — and undo a deletion the peer just made.
|
|
255
264
|
*/
|
|
256
265
|
private _remoteApplyInFlight;
|
|
266
|
+
/**
|
|
267
|
+
* Whether a safety rescan was suppressed while a remote apply was running.
|
|
268
|
+
*
|
|
269
|
+
* The rescan is what covers a watcher that drops or coalesces events, so a
|
|
270
|
+
* suppressed one has to be re-run rather than forgotten — see the deferral in
|
|
271
|
+
* `syncToDb`'s change handler.
|
|
272
|
+
*/
|
|
273
|
+
private _rescanDeferred;
|
|
274
|
+
/** Re-runs a deferred rescan once the apply that blocked it has finished. */
|
|
275
|
+
private _flushDeferredRescan;
|
|
257
276
|
/** Files written vs left alone by the current {@link restore}. */
|
|
258
277
|
private _restoreWritten;
|
|
259
278
|
private _restoreSkipped;
|
|
@@ -653,6 +672,48 @@ export declare class FsAgent {
|
|
|
653
672
|
* @returns `true` when the tree carries no entries at all.
|
|
654
673
|
*/
|
|
655
674
|
private _treeIsEmpty;
|
|
675
|
+
/**
|
|
676
|
+
* Whether an inbound ref is news to this agent, and if not, why.
|
|
677
|
+
*
|
|
678
|
+
* "Is this ref news to me" is the question this subsystem keeps getting
|
|
679
|
+
* wrong. It has been answered in five separate places — the connector's sent
|
|
680
|
+
* and received dedup sets, `_lastSentContentKey`, `_lastSentRef`,
|
|
681
|
+
* `_lastAppliedRef` — and each has been wrong at least once:
|
|
682
|
+
*
|
|
683
|
+
* - an agent applied its OWN last advertisement over its own newer edit,
|
|
684
|
+
* destroying it and then declining to re-send (fixed 0.0.30);
|
|
685
|
+
* - a delete propagated only from the client that had created the file,
|
|
686
|
+
* because the send path never retired the state it left (0.0.31);
|
|
687
|
+
* - a refusal consumed the ref it refused, so the same emptiness arriving
|
|
688
|
+
* twice was silent the second time (0.0.33);
|
|
689
|
+
* - a restarted agent inherited its predecessor's conclusions (0.0.34);
|
|
690
|
+
* - a quiet join announced anyway, because the connector broadcasts on
|
|
691
|
+
* local insert and the gate was on the send (0.0.38).
|
|
692
|
+
*
|
|
693
|
+
* Collecting the pre-fetch gates here does not fix a sixth. It makes the
|
|
694
|
+
* question answerable in one place, in one order, with the reasoning
|
|
695
|
+
* attached — which is what the previous five each lacked.
|
|
696
|
+
*
|
|
697
|
+
* Order matters and is deliberate. The own-echo check comes first because it
|
|
698
|
+
* is the only one that holds regardless of what the sender believes: the two
|
|
699
|
+
* defences that ought to catch an echo both miss it. The origin filter
|
|
700
|
+
* compares the payload's origin to this connector's, and a bootstrap carries
|
|
701
|
+
* the SERVER as origin rather than the client the ref came from; the
|
|
702
|
+
* staleness check then measures the server's sequence, which did advance, so
|
|
703
|
+
* the echo reads as news.
|
|
704
|
+
*
|
|
705
|
+
* KNOWN LIMIT, stated because the next person will meet it: this compares
|
|
706
|
+
* against the LAST ref this agent sent, so an echo of an OLDER
|
|
707
|
+
* self-originated ref still gets through. Widening it to a set would also
|
|
708
|
+
* suppress a peer's legitimate revert to a state this agent once held. The
|
|
709
|
+
* real fix is for the bootstrap to carry the originating client, so the
|
|
710
|
+
* origin filter works and this check stops being needed at all.
|
|
711
|
+
* @param treeRef - The inbound ref.
|
|
712
|
+
* @param isNewestFromSender - Whether the connector judged it the newest
|
|
713
|
+
* thing its sender has advertised. Unknown answers `true`.
|
|
714
|
+
* @returns `apply`, or the reason it is not news.
|
|
715
|
+
*/
|
|
716
|
+
private _inboundRefVerdict;
|
|
656
717
|
private _adoptAppliedRef;
|
|
657
718
|
/**
|
|
658
719
|
* Derives a deterministic content key from an FsTree.
|
package/dist/fs-agent.js
CHANGED
|
@@ -1030,6 +1030,10 @@ const AGENT_STATE_FILE = ".fsagent-state.json";
|
|
|
1030
1030
|
const REFUSAL_ANSWER_COOLDOWN_MS = 5e3;
|
|
1031
1031
|
const TREE_FETCH_CONCURRENCY = 64;
|
|
1032
1032
|
const RESTORED_BLOB_MEMORY_MAX = 5e4;
|
|
1033
|
+
const VERDICT_REASON = {
|
|
1034
|
+
"own-echo": "is this agent's own last advertisement echoed back",
|
|
1035
|
+
stale: "is not the newest its sender has advertised"
|
|
1036
|
+
};
|
|
1033
1037
|
const MASS_DELETE_MIN_FILES = 100;
|
|
1034
1038
|
const MASS_DELETE_MAX_RATIO = 0.3;
|
|
1035
1039
|
class RestoreIncompleteError extends Error {
|
|
@@ -1086,6 +1090,16 @@ class FsAgent {
|
|
|
1086
1090
|
* view — and undo a deletion the peer just made.
|
|
1087
1091
|
*/
|
|
1088
1092
|
_remoteApplyInFlight = false;
|
|
1093
|
+
/**
|
|
1094
|
+
* Whether a safety rescan was suppressed while a remote apply was running.
|
|
1095
|
+
*
|
|
1096
|
+
* The rescan is what covers a watcher that drops or coalesces events, so a
|
|
1097
|
+
* suppressed one has to be re-run rather than forgotten — see the deferral in
|
|
1098
|
+
* `syncToDb`'s change handler.
|
|
1099
|
+
*/
|
|
1100
|
+
_rescanDeferred = false;
|
|
1101
|
+
/** Re-runs a deferred rescan once the apply that blocked it has finished. */
|
|
1102
|
+
_flushDeferredRescan;
|
|
1089
1103
|
/** Files written vs left alone by the current {@link restore}. */
|
|
1090
1104
|
_restoreWritten = 0;
|
|
1091
1105
|
_restoreSkipped = 0;
|
|
@@ -1928,8 +1942,14 @@ ${err.stack}` : String(err);
|
|
|
1928
1942
|
);
|
|
1929
1943
|
}
|
|
1930
1944
|
let debounceTimer = null;
|
|
1945
|
+
this._flushDeferredRescan = () => {
|
|
1946
|
+
if (!this._rescanDeferred) return;
|
|
1947
|
+
this._rescanDeferred = false;
|
|
1948
|
+
debouncedSync({ type: "safety-rescan" });
|
|
1949
|
+
};
|
|
1931
1950
|
const debouncedSync = (change) => {
|
|
1932
1951
|
if (change?.type === "safety-rescan" && this._remoteApplyInFlight) {
|
|
1952
|
+
this._rescanDeferred = true;
|
|
1933
1953
|
return;
|
|
1934
1954
|
}
|
|
1935
1955
|
if (debounceTimer) clearTimeout(debounceTimer);
|
|
@@ -2183,6 +2203,52 @@ ${err.stack}` : String(err);
|
|
|
2183
2203
|
_treeIsEmpty(tree) {
|
|
2184
2204
|
return this._getFileContentMap(tree).size === 0;
|
|
2185
2205
|
}
|
|
2206
|
+
/**
|
|
2207
|
+
* Whether an inbound ref is news to this agent, and if not, why.
|
|
2208
|
+
*
|
|
2209
|
+
* "Is this ref news to me" is the question this subsystem keeps getting
|
|
2210
|
+
* wrong. It has been answered in five separate places — the connector's sent
|
|
2211
|
+
* and received dedup sets, `_lastSentContentKey`, `_lastSentRef`,
|
|
2212
|
+
* `_lastAppliedRef` — and each has been wrong at least once:
|
|
2213
|
+
*
|
|
2214
|
+
* - an agent applied its OWN last advertisement over its own newer edit,
|
|
2215
|
+
* destroying it and then declining to re-send (fixed 0.0.30);
|
|
2216
|
+
* - a delete propagated only from the client that had created the file,
|
|
2217
|
+
* because the send path never retired the state it left (0.0.31);
|
|
2218
|
+
* - a refusal consumed the ref it refused, so the same emptiness arriving
|
|
2219
|
+
* twice was silent the second time (0.0.33);
|
|
2220
|
+
* - a restarted agent inherited its predecessor's conclusions (0.0.34);
|
|
2221
|
+
* - a quiet join announced anyway, because the connector broadcasts on
|
|
2222
|
+
* local insert and the gate was on the send (0.0.38).
|
|
2223
|
+
*
|
|
2224
|
+
* Collecting the pre-fetch gates here does not fix a sixth. It makes the
|
|
2225
|
+
* question answerable in one place, in one order, with the reasoning
|
|
2226
|
+
* attached — which is what the previous five each lacked.
|
|
2227
|
+
*
|
|
2228
|
+
* Order matters and is deliberate. The own-echo check comes first because it
|
|
2229
|
+
* is the only one that holds regardless of what the sender believes: the two
|
|
2230
|
+
* defences that ought to catch an echo both miss it. The origin filter
|
|
2231
|
+
* compares the payload's origin to this connector's, and a bootstrap carries
|
|
2232
|
+
* the SERVER as origin rather than the client the ref came from; the
|
|
2233
|
+
* staleness check then measures the server's sequence, which did advance, so
|
|
2234
|
+
* the echo reads as news.
|
|
2235
|
+
*
|
|
2236
|
+
* KNOWN LIMIT, stated because the next person will meet it: this compares
|
|
2237
|
+
* against the LAST ref this agent sent, so an echo of an OLDER
|
|
2238
|
+
* self-originated ref still gets through. Widening it to a set would also
|
|
2239
|
+
* suppress a peer's legitimate revert to a state this agent once held. The
|
|
2240
|
+
* real fix is for the bootstrap to carry the originating client, so the
|
|
2241
|
+
* origin filter works and this check stops being needed at all.
|
|
2242
|
+
* @param treeRef - The inbound ref.
|
|
2243
|
+
* @param isNewestFromSender - Whether the connector judged it the newest
|
|
2244
|
+
* thing its sender has advertised. Unknown answers `true`.
|
|
2245
|
+
* @returns `apply`, or the reason it is not news.
|
|
2246
|
+
*/
|
|
2247
|
+
_inboundRefVerdict(treeRef, isNewestFromSender) {
|
|
2248
|
+
if (treeRef === this._lastSentRef) return "own-echo";
|
|
2249
|
+
if (!isNewestFromSender) return "stale";
|
|
2250
|
+
return "apply";
|
|
2251
|
+
}
|
|
2186
2252
|
_adoptAppliedRef(connector, treeRef) {
|
|
2187
2253
|
if (this._lastAppliedRef && this._lastAppliedRef !== treeRef) {
|
|
2188
2254
|
connector.invalidateSent?.(this._lastAppliedRef);
|
|
@@ -2278,17 +2344,14 @@ ${err.stack}` : String(err);
|
|
|
2278
2344
|
const processRef = async (treeRef, recoveryAttempt = 0, predecessorRefs, isNewestFromSender = true) => {
|
|
2279
2345
|
const maxAttempts = this._timeouts.processRefRetries + 1;
|
|
2280
2346
|
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
2281
|
-
|
|
2347
|
+
const verdict = this._inboundRefVerdict(treeRef, isNewestFromSender);
|
|
2348
|
+
if (verdict !== "apply") {
|
|
2282
2349
|
console.warn(
|
|
2283
|
-
`[FsAgent] ref=${treeRef.slice(0, 8)}…
|
|
2350
|
+
`[FsAgent] ref=${treeRef.slice(0, 8)}… ${VERDICT_REASON[verdict]} — ignoring it.`
|
|
2284
2351
|
);
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
console.warn(
|
|
2289
|
-
`[FsAgent] ref=${treeRef.slice(0, 8)}… is not the newest its sender has advertised — ignoring it.`
|
|
2290
|
-
);
|
|
2291
|
-
connector.invalidateReceived(treeRef);
|
|
2352
|
+
if (verdict === "stale") {
|
|
2353
|
+
connector.invalidateReceived(treeRef);
|
|
2354
|
+
}
|
|
2292
2355
|
return;
|
|
2293
2356
|
}
|
|
2294
2357
|
this._scanner.pauseWatch();
|
|
@@ -2412,6 +2475,7 @@ ${err.stack}` : String(err);
|
|
|
2412
2475
|
} finally {
|
|
2413
2476
|
this._remoteApplyInFlight = false;
|
|
2414
2477
|
this._scanner.resumeWatch();
|
|
2478
|
+
this._flushDeferredRescan?.();
|
|
2415
2479
|
}
|
|
2416
2480
|
await new Promise(
|
|
2417
2481
|
(r) => setTimeout(r, attempt * this._timeouts.processRefRetryDelayMs)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rljson/fs-agent",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.41",
|
|
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",
|
|
@@ -19,6 +19,14 @@
|
|
|
19
19
|
"dist"
|
|
20
20
|
],
|
|
21
21
|
"type": "module",
|
|
22
|
+
"scripts": {
|
|
23
|
+
"build": "pnpm exec vite build && tsc && node scripts/copy-readme-to-dist.js",
|
|
24
|
+
"test": "cross-env NODE_OPTIONS=--max-old-space-size=8192 pnpm exec vitest run --coverage && pnpm run lint",
|
|
25
|
+
"prebuild": "npm run test",
|
|
26
|
+
"prepublishOnly": "npm run build",
|
|
27
|
+
"lint": "pnpm exec eslint .",
|
|
28
|
+
"updateGoldens": "cross-env UPDATE_GOLDENS=true pnpm test"
|
|
29
|
+
},
|
|
22
30
|
"devDependencies": {
|
|
23
31
|
"@rljson/server": "^0.0.43",
|
|
24
32
|
"@types/node": "^25.3.1",
|
|
@@ -51,11 +59,13 @@
|
|
|
51
59
|
"socket.io": "^4.8.3",
|
|
52
60
|
"socket.io-client": "^4.8.3"
|
|
53
61
|
},
|
|
54
|
-
"
|
|
55
|
-
"
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
"
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
62
|
+
"pnpm": {
|
|
63
|
+
"onlyBuiltDependencies": [
|
|
64
|
+
"esbuild"
|
|
65
|
+
],
|
|
66
|
+
"overrides": {
|
|
67
|
+
"@rljson/rljson": "^0.0.78"
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"packageManager": "pnpm@10.11.0"
|
|
71
|
+
}
|