@openclaw/fs-safe 0.20.0 → 0.21.1
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/CHANGELOG.md +47 -0
- package/README.md +9 -1
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -0
- package/dist/archive-zip-directory.js +4 -0
- package/dist/archive-zip-loader.js +3 -1
- package/dist/archive-zip-manifest.js +3 -1
- package/dist/atomic.d.ts +1 -1
- package/dist/copy-publication.d.ts +1 -3
- package/dist/copy-publication.js +2 -6
- package/dist/deny-mutation-match.d.ts +2 -0
- package/dist/deny-mutation-match.js +71 -0
- package/dist/deny-mutations.js +4 -3
- package/dist/file-identity.js +10 -2
- package/dist/file-lock-sync-admission.js +5 -1
- package/dist/file-lock-sync-root-io.d.ts +2 -2
- package/dist/file-lock-sync-root-io.js +1 -1
- package/dist/file-lock-sync.js +2 -2
- package/dist/file-store-prune.js +4 -4
- package/dist/mutation-authority.js +5 -0
- package/dist/native-binding.d.ts +33 -0
- package/dist/native-pinned-write.js +8 -2
- package/dist/pinned-mutation-admission.js +4 -2
- package/dist/replace-file-buffer.d.ts +4 -0
- package/dist/replace-file-buffer.js +36 -0
- package/dist/replace-file-copy-fallback.d.ts +2 -0
- package/dist/replace-file-copy-fallback.js +66 -38
- package/dist/replace-file-descriptor.d.ts +4 -0
- package/dist/replace-file-descriptor.js +9 -1
- package/dist/replace-file-destination.d.ts +21 -0
- package/dist/replace-file-destination.js +123 -0
- package/dist/replace-file-mutation.d.ts +26 -0
- package/dist/replace-file-mutation.js +47 -0
- package/dist/replace-file-temp-owner.d.ts +2 -2
- package/dist/replace-file-temp-owner.js +16 -4
- package/dist/replace-file-types.d.ts +55 -0
- package/dist/replace-file-types.js +1 -0
- package/dist/replace-file.d.ts +3 -55
- package/dist/replace-file.js +31 -11
- package/dist/retained-file-types.d.ts +50 -0
- package/dist/retained-file-types.js +1 -0
- package/dist/retained-file.d.ts +3 -0
- package/dist/retained-file.js +121 -0
- package/dist/root-directory-entry.d.ts +9 -0
- package/dist/root-directory-entry.js +28 -0
- package/dist/root-directory-list.d.ts +9 -1
- package/dist/root-directory-list.js +88 -37
- package/dist/root-handle-context.d.ts +4 -0
- package/dist/root-handle-context.js +12 -0
- package/dist/root-impl.d.ts +3 -3
- package/dist/root-impl.js +8 -2
- package/dist/root-move-noreplace.js +3 -3
- package/dist/root-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- package/dist/sidecar-lock-acquire.js +3 -3
- package/dist/sidecar-lock-reclaim.d.ts +2 -2
- package/dist/sidecar-lock-reclaim.js +6 -6
- package/dist/sidecar-lock.js +4 -4
- package/dist/staged-symlink-types.d.ts +4 -14
- package/dist/temp-target.js +3 -2
- package/dist/temp-workspace-admission.js +22 -21
- package/dist/temp-workspace-child-admission.d.ts +1 -1
- package/dist/temp-workspace-child-admission.js +14 -9
- package/dist/temp-workspace-ownership.d.ts +8 -0
- package/dist/temp-workspace-ownership.js +52 -0
- package/dist/test-hooks.d.ts +4 -0
- package/dist/watch-alias.d.ts +6 -0
- package/dist/watch-alias.js +88 -0
- package/dist/watch-hints.d.ts +9 -0
- package/dist/watch-hints.js +93 -0
- package/dist/watch-native.d.ts +36 -0
- package/dist/watch-native.js +73 -0
- package/dist/watch-scan.d.ts +28 -0
- package/dist/watch-scan.js +300 -0
- package/dist/watch-stream.d.ts +8 -0
- package/dist/watch-stream.js +32 -0
- package/dist/watch-types.d.ts +60 -0
- package/dist/watch-types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +530 -0
- package/docs/advanced.md +1 -0
- package/docs/archive.md +6 -0
- package/docs/atomic.md +82 -0
- package/docs/contributing.md +74 -6
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/install.md +2 -0
- package/docs/native-helper.md +9 -0
- package/docs/native.md +24 -6
- package/docs/public-api.md +23 -2
- package/docs/retained-file.md +115 -0
- package/docs/root.md +25 -8
- package/docs/sidecar-lock.md +1 -1
- package/docs/staged-symlink.md +2 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +186 -4
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +251 -0
- package/package.json +12 -8
package/dist/watch.js
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
import { FsSafeError } from "./errors.js";
|
|
2
|
+
import { assertSynchronousCallbackResult } from "./mutation-authority.js";
|
|
3
|
+
import { assertRootIdentityCurrent } from "./root-context.js";
|
|
4
|
+
import { rootHandleContext } from "./root-handle-context.js";
|
|
5
|
+
import { createSuppressedError } from "./suppressed-error.js";
|
|
6
|
+
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
7
|
+
import { admittedNativeChanges } from "./watch-alias.js";
|
|
8
|
+
import { watchStreamPaths } from "./watch-stream.js";
|
|
9
|
+
import { changedEntries, excludedWatchPath, guardedHintChanges, scopedChanges } from "./watch-hints.js";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { watchBinding, NativeWatchBackend } from "./watch-native.js";
|
|
12
|
+
import { getFsSafeNativeConfig } from "./native-config.js";
|
|
13
|
+
import { isWatchPathError, scanWatch, watchScopes } from "./watch-scan.js";
|
|
14
|
+
function deferred() {
|
|
15
|
+
let settled = false;
|
|
16
|
+
let resolve;
|
|
17
|
+
let reject;
|
|
18
|
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
19
|
+
// Ownership exists even when a consumer closes without awaiting startup.
|
|
20
|
+
void promise.catch(() => { });
|
|
21
|
+
return { promise, get settled() { return settled; },
|
|
22
|
+
resolve() { settled = true; resolve(); },
|
|
23
|
+
reject(error) { settled = true; reject(error); },
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
function budget(value, fallback, name, max = 1_000_000) {
|
|
27
|
+
const result = value ?? fallback;
|
|
28
|
+
if (!Number.isSafeInteger(result) || result < 1 || result > max)
|
|
29
|
+
throw new RangeError("invalid watch " + name);
|
|
30
|
+
return result;
|
|
31
|
+
}
|
|
32
|
+
const retired = () => new DOMException("Watch generation retired", "AbortError");
|
|
33
|
+
const coalesceMs = 25;
|
|
34
|
+
/** Advisory observation only. Hints never grant filesystem authority. */
|
|
35
|
+
export function watch(root, input) {
|
|
36
|
+
const context = rootHandleContext(root);
|
|
37
|
+
const options = { ...input };
|
|
38
|
+
const persistent = options.persistent !== false;
|
|
39
|
+
if (!["auto", "events", "poll"].includes(options.mode))
|
|
40
|
+
throw new TypeError("invalid watch mode");
|
|
41
|
+
let binding;
|
|
42
|
+
let selectionFailure;
|
|
43
|
+
try {
|
|
44
|
+
binding = watchBinding(options.mode);
|
|
45
|
+
}
|
|
46
|
+
catch (error) {
|
|
47
|
+
selectionFailure = error;
|
|
48
|
+
}
|
|
49
|
+
let mode = binding || options.mode === "events" ? "events" : "poll";
|
|
50
|
+
let intervalMs = budget(options.intervalMs, mode === "events" ? 30_000 : 1000, "intervalMs", 2_147_483_647);
|
|
51
|
+
if (intervalMs < 20)
|
|
52
|
+
throw new RangeError("watch intervalMs must be at least 20");
|
|
53
|
+
const pollIntervalMs = budget(options.pollIntervalMs, options.intervalMs ?? 1000, "pollIntervalMs", 2_147_483_647);
|
|
54
|
+
if (pollIntervalMs < 20)
|
|
55
|
+
throw new RangeError("watch pollIntervalMs must be at least 20");
|
|
56
|
+
if (mode === "poll")
|
|
57
|
+
intervalMs = pollIntervalMs;
|
|
58
|
+
const maxDirectories = budget(options.maxDirectories, 4096, "maxDirectories");
|
|
59
|
+
const maxEntries = budget(options.maxEntries, 100_000, "maxEntries");
|
|
60
|
+
const maxPendingPaths = budget(options.maxPendingPaths, 256, "maxPendingPaths", 4096);
|
|
61
|
+
if (typeof options.onInvalidate !== "function")
|
|
62
|
+
throw new TypeError("watch requires onInvalidate");
|
|
63
|
+
const makeGeneration = (scopes) => ({
|
|
64
|
+
scopes: watchScopes(scopes), abort: new AbortController(), waiter: deferred(),
|
|
65
|
+
});
|
|
66
|
+
let current = makeGeneration(options.scopes);
|
|
67
|
+
const ready = current.waiter.promise;
|
|
68
|
+
let state = "starting";
|
|
69
|
+
let terminal = false;
|
|
70
|
+
let failure;
|
|
71
|
+
let failureInfo;
|
|
72
|
+
let retirementFailure;
|
|
73
|
+
const retirementErrors = new Set();
|
|
74
|
+
let closing;
|
|
75
|
+
let active;
|
|
76
|
+
let backend;
|
|
77
|
+
const registered = new Map();
|
|
78
|
+
let observedDirectories = 0;
|
|
79
|
+
let snapshot;
|
|
80
|
+
let resetRequested = false;
|
|
81
|
+
let refreshBackend = false;
|
|
82
|
+
let pending = false;
|
|
83
|
+
let pendingWaiter;
|
|
84
|
+
let runningWaiter;
|
|
85
|
+
let pendingHint = false;
|
|
86
|
+
let pendingBackendOverflow = false;
|
|
87
|
+
let pendingChanges = new Map();
|
|
88
|
+
let timer;
|
|
89
|
+
let hintTimer;
|
|
90
|
+
const combinedFailure = () => {
|
|
91
|
+
if (!retirementFailure)
|
|
92
|
+
return failure;
|
|
93
|
+
const error = retirementFailure.error;
|
|
94
|
+
if (failure === undefined || error === failure || error?.suppressed === failure)
|
|
95
|
+
return error;
|
|
96
|
+
return createSuppressedError(error, failure, "watch observation and retirement failed");
|
|
97
|
+
};
|
|
98
|
+
const retainRetirement = (error) => {
|
|
99
|
+
if (retirementErrors.has(error))
|
|
100
|
+
return;
|
|
101
|
+
retirementErrors.add(error);
|
|
102
|
+
retirementFailure = { error: retirementFailure
|
|
103
|
+
? createSuppressedError(error, retirementFailure.error, "watch retirement failed more than once") : error };
|
|
104
|
+
};
|
|
105
|
+
const health = () => Object.freeze({
|
|
106
|
+
state, mode, directories: observedDirectories,
|
|
107
|
+
...(failure === undefined && !retirementFailure ? {} : {
|
|
108
|
+
failure: retirementFailure ? Object.freeze({ operation: "close", error: combinedFailure() }) : failureInfo,
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
const check = (g) => {
|
|
112
|
+
g.abort.signal.throwIfAborted();
|
|
113
|
+
if (terminal || current !== g)
|
|
114
|
+
throw retired();
|
|
115
|
+
if (failure !== undefined)
|
|
116
|
+
throw failure;
|
|
117
|
+
if (retirementFailure)
|
|
118
|
+
throw retirementFailure.error;
|
|
119
|
+
};
|
|
120
|
+
const clearTimers = () => {
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
clearTimeout(hintTimer);
|
|
123
|
+
timer = undefined;
|
|
124
|
+
hintTimer = undefined;
|
|
125
|
+
pending = false;
|
|
126
|
+
pendingHint = false;
|
|
127
|
+
pendingChanges = new Map();
|
|
128
|
+
pendingBackendOverflow = false;
|
|
129
|
+
};
|
|
130
|
+
const notifyHealth = () => {
|
|
131
|
+
try {
|
|
132
|
+
const result = options.onHealth?.(health());
|
|
133
|
+
assertSynchronousCallbackResult(result, "watch onHealth");
|
|
134
|
+
}
|
|
135
|
+
catch (cause) {
|
|
136
|
+
throw new FsSafeError("helper-failed", "watch health callback failed", { cause, details: { operation: "callback" } });
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
const dirty = (g, reason, changes) => {
|
|
140
|
+
check(g);
|
|
141
|
+
try {
|
|
142
|
+
const result = options.onInvalidate(Object.freeze({ reason, changes: changes && Object.freeze(changes) }));
|
|
143
|
+
assertSynchronousCallbackResult(result, "watch onInvalidate");
|
|
144
|
+
}
|
|
145
|
+
catch (cause) {
|
|
146
|
+
throw new FsSafeError("helper-failed", "watch dirty callback failed", { cause, details: { operation: "callback" } });
|
|
147
|
+
}
|
|
148
|
+
check(g);
|
|
149
|
+
};
|
|
150
|
+
const retain = (error) => {
|
|
151
|
+
if (failure === undefined) {
|
|
152
|
+
failure = error;
|
|
153
|
+
failureInfo = Object.freeze({ operation: "close", error });
|
|
154
|
+
}
|
|
155
|
+
else if (error !== failure)
|
|
156
|
+
failure = createSuppressedError(error, failure, "watch and retirement both failed");
|
|
157
|
+
};
|
|
158
|
+
const retireBackend = async () => {
|
|
159
|
+
const old = backend;
|
|
160
|
+
try {
|
|
161
|
+
await old?.close();
|
|
162
|
+
}
|
|
163
|
+
catch (error) {
|
|
164
|
+
retainRetirement(error);
|
|
165
|
+
}
|
|
166
|
+
if (backend === old) {
|
|
167
|
+
backend = undefined;
|
|
168
|
+
registered.clear();
|
|
169
|
+
observedDirectories = 0;
|
|
170
|
+
}
|
|
171
|
+
if (retirementFailure)
|
|
172
|
+
throw retirementFailure.error;
|
|
173
|
+
};
|
|
174
|
+
const lose = (error, operation = "scan") => {
|
|
175
|
+
if (terminal || failure !== undefined)
|
|
176
|
+
return;
|
|
177
|
+
if (error === undefined)
|
|
178
|
+
error = new FsSafeError("helper-failed", "watch operation failed without an error value", { details: { operation } });
|
|
179
|
+
retain(error);
|
|
180
|
+
const details = error instanceof FsSafeError ? error.details : undefined;
|
|
181
|
+
const code = details?.code ?? error?.code;
|
|
182
|
+
failureInfo = Object.freeze({ error, operation: details?.operation === "watch" ? "watch" : details?.operation === "callback" ? "callback" : details?.operation === "close" ? "close" : operation, ...(typeof code === "string" ? { code } : {}) });
|
|
183
|
+
current.abort.abort(error);
|
|
184
|
+
current.waiter.reject(error);
|
|
185
|
+
pendingWaiter?.reject(error);
|
|
186
|
+
runningWaiter?.reject(error);
|
|
187
|
+
pendingWaiter = undefined;
|
|
188
|
+
runningWaiter = undefined;
|
|
189
|
+
clearTimers();
|
|
190
|
+
state = "unavailable";
|
|
191
|
+
// Stop backend admission before notifying. Its join remains owned by active/close.
|
|
192
|
+
try {
|
|
193
|
+
backend?.close();
|
|
194
|
+
}
|
|
195
|
+
catch (error) {
|
|
196
|
+
retainRetirement(error);
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
notifyHealth();
|
|
200
|
+
}
|
|
201
|
+
catch (callbackError) {
|
|
202
|
+
retain(callbackError);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
const scheduleInterval = () => {
|
|
206
|
+
if (terminal || failure !== undefined)
|
|
207
|
+
return;
|
|
208
|
+
timer = setTimeout(() => { timer = undefined; void request().catch(() => { }); }, intervalMs);
|
|
209
|
+
if (!persistent)
|
|
210
|
+
timer.unref();
|
|
211
|
+
};
|
|
212
|
+
const onHint = (g, batch) => {
|
|
213
|
+
if (terminal || current !== g || g.abort.signal.aborted || failure !== undefined)
|
|
214
|
+
return;
|
|
215
|
+
if (batch.overflow) {
|
|
216
|
+
pendingBackendOverflow = true;
|
|
217
|
+
try {
|
|
218
|
+
assertSynchronousCallbackResult(getFsSafeTestHooks()?.afterWatchBackendOverflow?.(context.rootReal, "received"), "afterWatchBackendOverflow");
|
|
219
|
+
}
|
|
220
|
+
catch (error) {
|
|
221
|
+
lose(error, "callback");
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
if (batch.error === "ESTALE")
|
|
226
|
+
refreshBackend = true;
|
|
227
|
+
else if (batch.error) {
|
|
228
|
+
lose(new FsSafeError("helper-failed", "native watch failed", { details: { operation: "watch", code: batch.error } }));
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (batch.overflow)
|
|
232
|
+
pendingChanges = undefined;
|
|
233
|
+
else if (pendingChanges)
|
|
234
|
+
for (const hint of batch.hints) {
|
|
235
|
+
if (typeof hint.name === "string" && excludedWatchPath(snapshot, hint.directory ? path.join(hint.directory, hint.name) : hint.name))
|
|
236
|
+
continue;
|
|
237
|
+
const key = JSON.stringify([hint.directory, hint.name]);
|
|
238
|
+
if (!pendingChanges.has(key) && pendingChanges.size >= maxPendingPaths) {
|
|
239
|
+
pendingChanges = undefined;
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
const previous = pendingChanges.get(key);
|
|
243
|
+
pendingChanges.set(key, previous?.event === "rename" ? previous : hint);
|
|
244
|
+
}
|
|
245
|
+
pendingHint = true;
|
|
246
|
+
pending = true;
|
|
247
|
+
if (hintTimer)
|
|
248
|
+
return;
|
|
249
|
+
hintTimer = setTimeout(() => {
|
|
250
|
+
hintTimer = undefined;
|
|
251
|
+
if (terminal || current !== g || failure !== undefined)
|
|
252
|
+
return;
|
|
253
|
+
// Raw backend filenames stay private. Reconcile before publishing detail.
|
|
254
|
+
void request().catch(() => { });
|
|
255
|
+
}, coalesceMs);
|
|
256
|
+
if (!persistent)
|
|
257
|
+
hintTimer.unref();
|
|
258
|
+
};
|
|
259
|
+
const fallBack = (error) => {
|
|
260
|
+
if (options.mode !== "auto" || getFsSafeNativeConfig().mode === "require" ||
|
|
261
|
+
!(error instanceof FsSafeError) || error.code !== "helper-unavailable")
|
|
262
|
+
return false;
|
|
263
|
+
mode = "poll";
|
|
264
|
+
binding = undefined;
|
|
265
|
+
intervalMs = pollIntervalMs;
|
|
266
|
+
return true;
|
|
267
|
+
};
|
|
268
|
+
const observe = async (g) => {
|
|
269
|
+
check(g);
|
|
270
|
+
if (selectionFailure !== undefined)
|
|
271
|
+
throw new FsSafeError("helper-unavailable", "native watch events are unavailable", { cause: selectionFailure, details: { operation: "watch" } });
|
|
272
|
+
if (refreshBackend) {
|
|
273
|
+
await retireBackend();
|
|
274
|
+
check(g);
|
|
275
|
+
refreshBackend = false;
|
|
276
|
+
}
|
|
277
|
+
if (resetRequested) {
|
|
278
|
+
await retireBackend();
|
|
279
|
+
check(g);
|
|
280
|
+
snapshot = undefined;
|
|
281
|
+
resetRequested = false;
|
|
282
|
+
}
|
|
283
|
+
state = snapshot ? "reconciling" : "starting";
|
|
284
|
+
notifyHealth();
|
|
285
|
+
check(g);
|
|
286
|
+
const hadHints = pendingHint;
|
|
287
|
+
const hadBackendOverflow = pendingBackendOverflow;
|
|
288
|
+
const hints = pendingChanges;
|
|
289
|
+
pendingHint = false;
|
|
290
|
+
pendingChanges = new Map();
|
|
291
|
+
pendingBackendOverflow = false;
|
|
292
|
+
clearTimeout(hintTimer);
|
|
293
|
+
hintTimer = undefined;
|
|
294
|
+
if (mode === "events" && !backend && g.scopes.length) {
|
|
295
|
+
try {
|
|
296
|
+
const candidate = new NativeWatchBackend(binding, context, batch => {
|
|
297
|
+
if (backend === candidate)
|
|
298
|
+
onHint(g, batch);
|
|
299
|
+
}, maxPendingPaths, persistent);
|
|
300
|
+
backend = candidate;
|
|
301
|
+
if (snapshot)
|
|
302
|
+
candidate.configure(watchStreamPaths(snapshot, g.scopes));
|
|
303
|
+
const hookResult = getFsSafeTestHooks()?.afterWatchBackendCreated?.(context.rootReal, batch => {
|
|
304
|
+
if (backend === candidate)
|
|
305
|
+
onHint(g, batch);
|
|
306
|
+
}, (path, flags) => candidate.testEvent(path, flags));
|
|
307
|
+
assertSynchronousCallbackResult(hookResult, "afterWatchBackendCreated");
|
|
308
|
+
}
|
|
309
|
+
catch (error) {
|
|
310
|
+
if (!fallBack(error))
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
check(g);
|
|
314
|
+
}
|
|
315
|
+
const next = await scanWatch(context, g.scopes, { exclude: options.exclude, maxDirectories, maxEntries, maxPendingPaths, admitting: !snapshot, previous: snapshot }, g.abort.signal, async (name, identity, guard) => {
|
|
316
|
+
check(g);
|
|
317
|
+
const existing = registered.get(name);
|
|
318
|
+
const acquire = !existing || existing.dev !== identity.dev || existing.ino !== identity.ino;
|
|
319
|
+
await getFsSafeTestHooks()?.beforeWatchRegistration?.(guard.realPath);
|
|
320
|
+
check(g);
|
|
321
|
+
if (acquire) {
|
|
322
|
+
try {
|
|
323
|
+
backend?.add(name, identity);
|
|
324
|
+
}
|
|
325
|
+
catch (error) {
|
|
326
|
+
if (snapshot || !fallBack(error))
|
|
327
|
+
throw error;
|
|
328
|
+
await retireBackend();
|
|
329
|
+
check(g);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
await getFsSafeTestHooks()?.afterWatchRegistration?.(guard.realPath);
|
|
333
|
+
check(g);
|
|
334
|
+
if (acquire)
|
|
335
|
+
registered.set(name, identity);
|
|
336
|
+
}, retainRetirement);
|
|
337
|
+
check(g);
|
|
338
|
+
if (backend?.configure(watchStreamPaths(next, g.scopes))) {
|
|
339
|
+
// The replacement stream is live before the next guarded pass covers the handover.
|
|
340
|
+
pending = true;
|
|
341
|
+
}
|
|
342
|
+
// Retire stale inventory before the next pass; that crawl installs fresh anchors first.
|
|
343
|
+
if ([...registered.keys()].some(name => !next.directories.has(name))) {
|
|
344
|
+
refreshBackend = true;
|
|
345
|
+
}
|
|
346
|
+
observedDirectories = next.directories.size;
|
|
347
|
+
const initial = !snapshot;
|
|
348
|
+
let observed = next.overflow ? undefined : changedEntries(snapshot, next, maxPendingPaths);
|
|
349
|
+
if (observed) {
|
|
350
|
+
const changes = new Map(observed.map(change => [change.path, change]));
|
|
351
|
+
for (const name of next.structural ?? [])
|
|
352
|
+
for (const change of scopedChanges(g.scopes, { path: name, type: "structural" }))
|
|
353
|
+
changes.set(change.path, change);
|
|
354
|
+
observed = changes.size > maxPendingPaths ? undefined : [...changes.values()];
|
|
355
|
+
}
|
|
356
|
+
let admittedHints = [];
|
|
357
|
+
try {
|
|
358
|
+
if (hadHints)
|
|
359
|
+
admittedHints = await admittedNativeChanges(context, g.scopes, snapshot, next, {
|
|
360
|
+
hints: hints ? [...hints.values()] : [], overflow: !hints,
|
|
361
|
+
}, g.abort.signal, maxPendingPaths);
|
|
362
|
+
}
|
|
363
|
+
catch (error) {
|
|
364
|
+
check(g);
|
|
365
|
+
await assertRootIdentityCurrent(context);
|
|
366
|
+
if (!isWatchPathError(error))
|
|
367
|
+
throw error;
|
|
368
|
+
admittedHints = undefined;
|
|
369
|
+
}
|
|
370
|
+
check(g);
|
|
371
|
+
await assertRootIdentityCurrent(context);
|
|
372
|
+
check(g);
|
|
373
|
+
let details = hadHints
|
|
374
|
+
? guardedHintChanges(g.scopes, snapshot, next, admittedHints, observed, maxPendingPaths)
|
|
375
|
+
: observed;
|
|
376
|
+
snapshot = next;
|
|
377
|
+
if (!initial && !details && hadBackendOverflow) {
|
|
378
|
+
assertSynchronousCallbackResult(getFsSafeTestHooks()?.afterWatchBackendOverflow?.(context.rootReal, "reconciled"), "afterWatchBackendOverflow");
|
|
379
|
+
}
|
|
380
|
+
// Publish before readiness; callbacks may synchronously retire this generation.
|
|
381
|
+
if (initial || !details || details.length)
|
|
382
|
+
dirty(g, initial ? "reconcile" : !details ? "overflow" : hadHints ? "event" : "reconcile", initial ? undefined : details);
|
|
383
|
+
check(g);
|
|
384
|
+
state = "ready";
|
|
385
|
+
notifyHealth();
|
|
386
|
+
check(g);
|
|
387
|
+
};
|
|
388
|
+
const pump = () => {
|
|
389
|
+
if (active || terminal || failure !== undefined)
|
|
390
|
+
return;
|
|
391
|
+
// Enroll before any callback. There is one active pass and one coalesced request.
|
|
392
|
+
active = Promise.resolve().then(async () => {
|
|
393
|
+
do {
|
|
394
|
+
const g = current;
|
|
395
|
+
pending = false;
|
|
396
|
+
if (pendingWaiter) {
|
|
397
|
+
// A scope replacement keeps reconcile requests alive until its baseline completes.
|
|
398
|
+
const previous = runningWaiter;
|
|
399
|
+
runningWaiter = pendingWaiter;
|
|
400
|
+
pendingWaiter = undefined;
|
|
401
|
+
if (previous)
|
|
402
|
+
void runningWaiter.promise.then(() => previous.resolve(), error => previous.reject(error));
|
|
403
|
+
}
|
|
404
|
+
try {
|
|
405
|
+
await observe(g);
|
|
406
|
+
check(g);
|
|
407
|
+
g.waiter.resolve();
|
|
408
|
+
runningWaiter?.resolve();
|
|
409
|
+
runningWaiter = undefined;
|
|
410
|
+
}
|
|
411
|
+
catch (error) {
|
|
412
|
+
g.waiter.reject(error);
|
|
413
|
+
if (!g.abort.signal.aborted && current === g && !terminal)
|
|
414
|
+
lose(error);
|
|
415
|
+
try {
|
|
416
|
+
await retireBackend();
|
|
417
|
+
}
|
|
418
|
+
catch (closeError) {
|
|
419
|
+
retainRetirement(closeError);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
if (current !== g) {
|
|
423
|
+
await retireBackend();
|
|
424
|
+
snapshot = undefined;
|
|
425
|
+
pending = true;
|
|
426
|
+
}
|
|
427
|
+
} while (pending && !terminal && failure === undefined);
|
|
428
|
+
}).catch(lose).finally(() => {
|
|
429
|
+
active = undefined;
|
|
430
|
+
if (!terminal && failure === undefined) {
|
|
431
|
+
if (pending || !current.waiter.settled)
|
|
432
|
+
pump();
|
|
433
|
+
else
|
|
434
|
+
scheduleInterval();
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
};
|
|
438
|
+
const request = () => {
|
|
439
|
+
if (terminal)
|
|
440
|
+
return Promise.reject(retired());
|
|
441
|
+
if (failure !== undefined)
|
|
442
|
+
return Promise.reject(failure);
|
|
443
|
+
clearTimeout(timer);
|
|
444
|
+
timer = undefined;
|
|
445
|
+
pending = true;
|
|
446
|
+
pendingWaiter ??= deferred();
|
|
447
|
+
const result = pendingWaiter.promise;
|
|
448
|
+
pump();
|
|
449
|
+
return result;
|
|
450
|
+
};
|
|
451
|
+
const close = () => {
|
|
452
|
+
if (closing)
|
|
453
|
+
return closing;
|
|
454
|
+
terminal = true;
|
|
455
|
+
current.abort.abort(retired());
|
|
456
|
+
current.waiter.reject(retired());
|
|
457
|
+
pendingWaiter?.reject(retired());
|
|
458
|
+
runningWaiter?.reject(retired());
|
|
459
|
+
pendingWaiter = undefined;
|
|
460
|
+
runningWaiter = undefined;
|
|
461
|
+
clearTimers();
|
|
462
|
+
options.signal?.removeEventListener("abort", abort);
|
|
463
|
+
// Start physical stop immediately, without waiting behind an in-flight scan.
|
|
464
|
+
try {
|
|
465
|
+
backend?.close();
|
|
466
|
+
}
|
|
467
|
+
catch (error) {
|
|
468
|
+
retainRetirement(error);
|
|
469
|
+
}
|
|
470
|
+
closing = Promise.resolve().then(async () => {
|
|
471
|
+
await active;
|
|
472
|
+
try {
|
|
473
|
+
await retireBackend();
|
|
474
|
+
}
|
|
475
|
+
catch (error) {
|
|
476
|
+
retainRetirement(error);
|
|
477
|
+
}
|
|
478
|
+
state = "closed";
|
|
479
|
+
if (retirementFailure)
|
|
480
|
+
throw combinedFailure();
|
|
481
|
+
});
|
|
482
|
+
void closing.catch(() => { });
|
|
483
|
+
return closing;
|
|
484
|
+
};
|
|
485
|
+
const abort = () => { void close(); };
|
|
486
|
+
const subscription = {
|
|
487
|
+
ready, health, close, [Symbol.asyncDispose]: close,
|
|
488
|
+
reconcile: request,
|
|
489
|
+
setScopes(scopes) {
|
|
490
|
+
if (terminal)
|
|
491
|
+
return Promise.reject(retired());
|
|
492
|
+
if (failure !== undefined)
|
|
493
|
+
return Promise.reject(failure);
|
|
494
|
+
const previous = current;
|
|
495
|
+
const next = makeGeneration(scopes);
|
|
496
|
+
// Scope accessors may synchronously close this owner during validation.
|
|
497
|
+
if (terminal || failure !== undefined || current !== previous) {
|
|
498
|
+
next.waiter.reject(failure ?? retired());
|
|
499
|
+
return next.waiter.promise;
|
|
500
|
+
}
|
|
501
|
+
current.abort.abort(retired());
|
|
502
|
+
current.waiter.reject(retired());
|
|
503
|
+
current = next;
|
|
504
|
+
resetRequested = true;
|
|
505
|
+
try {
|
|
506
|
+
backend?.close();
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
retainRetirement(error);
|
|
510
|
+
}
|
|
511
|
+
clearTimers();
|
|
512
|
+
state = "starting";
|
|
513
|
+
// If idle, retire the old backend before the next scan can admit anything.
|
|
514
|
+
if (!active) {
|
|
515
|
+
active = retireBackend().catch(error => lose(error, "close")).finally(() => {
|
|
516
|
+
active = undefined;
|
|
517
|
+
snapshot = undefined;
|
|
518
|
+
pump();
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
return next.waiter.promise;
|
|
522
|
+
},
|
|
523
|
+
};
|
|
524
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
525
|
+
if (options.signal?.aborted)
|
|
526
|
+
abort();
|
|
527
|
+
else
|
|
528
|
+
pump();
|
|
529
|
+
return subscription;
|
|
530
|
+
}
|
package/docs/advanced.md
CHANGED
|
@@ -77,6 +77,7 @@ Operational filesystem failures such as permissions or I/O errors are rethrown.
|
|
|
77
77
|
| `overwriteFileHandle`, `OverwriteFileHandleOptions` | [in-place-write.md](in-place-write.md) | Overwrite a borrowed read/write handle with prefix-only preparation and best-effort rollback; preserves its inode, cursor, and caller-owned lifetime. |
|
|
78
78
|
| `openRootFile`, `openRootFileSync`, `canUseRootFileOpen`, `matchRootFileOpenFailure`, related types | – | Low-level root-bounded open; rejects every symlink component by default, with `symlinks: "follow-parents-within-root"` for contained parent aliases or `"follow-within-root"` for final links too. |
|
|
79
79
|
| `appendRegularFile`, `appendRegularFileSync`, `readRegularFile`, `readRegularFileSync`, `statRegularFile`, `statRegularFileSync`, `resolveRegularFileAppendFlags`, `AppendRegularFileOptions`, `RegularFileStatResult` | [regular-file.md](regular-file.md) | Type-checked regular-file I/O. |
|
|
80
|
+
| `retainFileInDirectory`, `RetainedFile`, related receipt/result types | [Retained Windows files](retained-file.md) | Existing-file native handle custody and explicit removal; local NTFS, producer authority required, no persistence guarantee. |
|
|
80
81
|
| `sameFileIdentity`, `FileIdentityStat` | – | Compare two stats for same-inode equality. |
|
|
81
82
|
| `readDirectoryIdentity`, `assertDirectoryIdentitySync`, `DirectoryIdentity` | [directory-identity.md](directory-identity.md) | Observe exact bigint directory identity and synchronously check a caller-selected path, optionally retaining its canonical path. |
|
|
82
83
|
| `pathExists`, `pathExistsSync` | – | Boolean existence check that does not throw on `ENOENT`. |
|
package/docs/archive.md
CHANGED
|
@@ -179,6 +179,12 @@ between native and JavaScript paths rather than reimplementing it in Rust.
|
|
|
179
179
|
|
|
180
180
|
ZIP extraction and bounded reads admit every physical central-directory record and its referenced local header before either decoder can normalize or collapse names. Raw names and valid Unicode Path names must pass traversal checks before stripping, filtering, or selecting a requested member; duplicate or colliding names reject with `entry-path`, even in unrelated or skipped members. Materially conflicting local/central or Unicode interpretations, malformed critical metadata, and ambiguous framing reject with `ArchiveFormatError`. Harmless internal separator and dot-component equivalence is allowed only after validation; every raw and Unicode interpretation must also agree on whether its name ends in `/` or `\`. Ordinary legacy filename decoding remains backend-selected. Native ZIP extraction groups nearby metadata reads into at most two 4 KiB read-ahead buffers per admission pass; larger records retain separately bounded reads. Buffered record views remain stable across eviction, and cached work periodically yields for deadline checks.
|
|
181
181
|
|
|
182
|
+
ZIP preflight and bounded reads apply extraction's case-insensitive NFC collision
|
|
183
|
+
policy to the complete archive, including entries that extraction would strip
|
|
184
|
+
or skip. Known UTF-8 and Unicode Path names are checked during physical admission;
|
|
185
|
+
legacy names retain backend-selected decoding and undergo the same collision
|
|
186
|
+
check before callbacks or selected bytes are returned.
|
|
187
|
+
|
|
182
188
|
ZIP end-record admission searches the bounded comment window for signatures
|
|
183
189
|
while retaining complete comment-length and ambiguity checks. Dense signature
|
|
184
190
|
sequences fall back to the bounded byte scan.
|
package/docs/atomic.md
CHANGED
|
@@ -60,6 +60,8 @@ type ReplaceFileAtomicOptions = {
|
|
|
60
60
|
syncTempFile?: boolean; // fsync(temp) before rename, or the final file after copy fallback; default false
|
|
61
61
|
syncParentDir?: boolean; // fsync(parent) after rename, POSIX only; default false
|
|
62
62
|
throwOnCleanupError?: boolean; // report temp cleanup failure; default false
|
|
63
|
+
assertBeforeMutation?: () => void;
|
|
64
|
+
onDestinationState?: (state: ReplaceFileAtomicDestinationState) => void;
|
|
63
65
|
beforeRename?: (params: { filePath: string; tempPath: string }) => Promise<void>;
|
|
64
66
|
fileSystem?: ReplaceFileAtomicFileSystem; // injectable fs for tests
|
|
65
67
|
};
|
|
@@ -108,6 +110,66 @@ descriptor is still closed, and a close failure remains reportable.
|
|
|
108
110
|
|
|
109
111
|
Identity checks and pathname rename/unlink remain separate syscalls, not atomic conditional mutations. Use an approved writable parent plus cooperative locking or OS isolation when arbitrary concurrent namespace mutation is in scope.
|
|
110
112
|
|
|
113
|
+
### Atomic write authority and destination state
|
|
114
|
+
|
|
115
|
+
Use `assertBeforeMutation` when a write depends on a lease or other revocable
|
|
116
|
+
application authority. Both atomic variants capture the callback at entry and
|
|
117
|
+
run it synchronously before directory creation or mode changes, compatibility
|
|
118
|
+
lock acquisition, staging creation and writes, each rename attempt, and fallback
|
|
119
|
+
removal, destination acquisition, truncation, and each content write. Asynchronous destination identity checks
|
|
120
|
+
finish before the final authority check; no await separates that check from the
|
|
121
|
+
write dispatch. A dispatched operation still owns its completion.
|
|
122
|
+
|
|
123
|
+
`onDestinationState` captures facts that a caller may need if the operation later
|
|
124
|
+
rejects:
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
type ReplaceFileAtomicDestinationState =
|
|
128
|
+
| Readonly<{ state: "removed"; path: string }>
|
|
129
|
+
| Readonly<{
|
|
130
|
+
state: "writing" | "published";
|
|
131
|
+
path: string;
|
|
132
|
+
dev: bigint;
|
|
133
|
+
ino: bigint;
|
|
134
|
+
}>;
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
- `removed` follows successful removal of an existing fallback destination.
|
|
138
|
+
- `writing` records the fallback's retained descriptor after exclusive creation
|
|
139
|
+
or the first successful truncation of an existing `restore-original` target.
|
|
140
|
+
Opening an existing target leaves it untouched and emits no receipt. This state
|
|
141
|
+
does not claim that all requested bytes were written.
|
|
142
|
+
- `published` follows successful rename and destination identity verification,
|
|
143
|
+
before parent synchronization or descriptor close can fail. Copy fallback
|
|
144
|
+
reports it after its content, mode, and requested file synchronization complete.
|
|
145
|
+
The explicit `verify-content-with-lock` policy first applies its existing
|
|
146
|
+
locked content verification to admit the replacement descriptor.
|
|
147
|
+
|
|
148
|
+
Receipts are frozen and use the destination spelling captured by the operation.
|
|
149
|
+
File identities come from retained descriptors, never from reopening a pathname
|
|
150
|
+
after a failed copy. Failed or indeterminate admission can leave no identity
|
|
151
|
+
receipt; a rename followed by replacement before verification also has no
|
|
152
|
+
publication receipt. Do not infer ownership from a fresh post-failure stat. A later writer
|
|
153
|
+
can replace the pathname, so compare the recorded identity with the current
|
|
154
|
+
entry and recheck application authority before compensation. Receipts do not
|
|
155
|
+
promise durable storage or authorize rollback.
|
|
156
|
+
|
|
157
|
+
Both callbacks must complete synchronously; Promise, thenable, and synchronous
|
|
158
|
+
or asynchronous generator results are rejected. Returned generators are never
|
|
159
|
+
advanced; other ordinary return values are ignored. The first callback refusal
|
|
160
|
+
is terminal, including falsy thrown values; an `EPERM`, `EEXIST`, or `EBUSY` code
|
|
161
|
+
from a callback never starts fallback or retry. Refusal during an in-place
|
|
162
|
+
fallback also stops new restoration writes. Final mode, synchronization, close,
|
|
163
|
+
and private-stage cleanup may still settle against owned identities after
|
|
164
|
+
revocation. Callback refusal does not delete a published destination or a
|
|
165
|
+
competing file at a staging name. An observer that throws must retain its receipt
|
|
166
|
+
first if recovery needs it.
|
|
167
|
+
|
|
168
|
+
`beforeRename` remains the hook for preparing backups. Effects performed inside
|
|
169
|
+
that hook remain the caller's responsibility; the authority option guards the
|
|
170
|
+
atomic writer's own effects. Omitting both new callbacks preserves existing
|
|
171
|
+
write, fallback, restoration, result, and cleanup behavior.
|
|
172
|
+
|
|
111
173
|
### FUSE, Windows exFAT/FAT32, and unstable rename identity
|
|
112
174
|
|
|
113
175
|
Strict source-to-destination identity is the default. Some FUSE mounts assign a different inode to the destination during rename even without concurrency. Set `renameIdentity: "verify-content-with-lock"` to accept that boundary only when the re-opened no-follow destination has the exact requested SHA-256 content under an exclusive hashed sidecar lock in the destination parent. The newly accepted descriptor and identity remain pinned through parent sync and final verification. The synchronous helper provides the same policy with the synchronous lock implementation.
|
|
@@ -184,6 +246,22 @@ that same descriptor, and synchronizes the result. Any write, mode, or sync
|
|
|
184
246
|
failure triggers a byte-and-mode restore and another sync through the same
|
|
185
247
|
descriptor.
|
|
186
248
|
|
|
249
|
+
With mutation callbacks enabled, an `EIO` from destination `stat`/`lstat` after
|
|
250
|
+
successful truncation also attempts restoration through that retained descriptor.
|
|
251
|
+
Each restore write still requires live application authority and fresh exact
|
|
252
|
+
descriptor identity, regular-file, and configured hardlink checks. The failed
|
|
253
|
+
pathname observation is not retried to authorize restoration; no pathname is
|
|
254
|
+
opened, removed, or replaced. A successor at that name is left untouched.
|
|
255
|
+
`details.cleanup: "restored"` means the retained original file's bytes and mode
|
|
256
|
+
were restored and synchronized, not that the pathname still names it. The existing
|
|
257
|
+
`writing` receipt identifies that file; no `published` receipt is emitted for a
|
|
258
|
+
failed replacement. Restoration I/O failures report `"restore-failed"`.
|
|
259
|
+
|
|
260
|
+
Callback refusals, detected identity/type/link changes, and other metadata errors
|
|
261
|
+
remain terminal. Failed descriptor revalidation also stops restoration. These
|
|
262
|
+
cases can leave the retained file empty or partial with only a `writing` receipt;
|
|
263
|
+
before the first successful truncation, verification failure leaves it untouched.
|
|
264
|
+
|
|
187
265
|
With `syncTempFile: false`, an exclusive-create copy fallback does not report
|
|
188
266
|
success until its new destination writer closes successfully. This includes
|
|
189
267
|
`"restore-original"` when the destination did not exist. A close rejection or throw is
|
|
@@ -214,6 +292,10 @@ through short reads and EOF, grow only as data arrives, and enforce the same
|
|
|
214
292
|
`beforeRename` callback, and `ReplaceFileAtomicSyncFileSystem`. Use it inside
|
|
215
293
|
synchronous boot paths or test setup code. It returns the same
|
|
216
294
|
`{ method: "rename" | "copy-fallback" }` receipt as the async variant.
|
|
295
|
+
Promise, thenable, and synchronous or asynchronous generator results from the
|
|
296
|
+
hook reject with `TypeError` before publication; rejected promises are consumed
|
|
297
|
+
and generators are never advanced. Other synchronous return values are ignored.
|
|
298
|
+
The replacement is not published; owned-temp cleanup follows the rules above.
|
|
217
299
|
|
|
218
300
|
## `replaceDirectoryAtomic`
|
|
219
301
|
|