@openclaw/fs-safe 0.20.0 → 0.21.0
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 +23 -0
- package/README.md +9 -1
- package/dist/advanced.d.ts +2 -0
- package/dist/advanced.js +1 -0
- package/dist/atomic.d.ts +1 -1
- package/dist/native-binding.d.ts +22 -0
- 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 +17 -0
- package/dist/replace-file-destination.js +61 -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 +29 -10
- package/dist/retained-file-types.d.ts +61 -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 +7 -1
- package/dist/root-directory-list.js +48 -23
- 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-walk.d.ts +19 -12
- package/dist/root-walk.js +49 -18
- 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 +3 -0
- package/dist/watch-alias.d.ts +6 -0
- package/dist/watch-alias.js +80 -0
- package/dist/watch-hints.d.ts +8 -0
- package/dist/watch-hints.js +77 -0
- package/dist/watch-native.d.ts +32 -0
- package/dist/watch-native.js +56 -0
- package/dist/watch-scan.d.ts +24 -0
- package/dist/watch-scan.js +269 -0
- package/dist/watch-types.d.ts +58 -0
- package/dist/watch-types.js +1 -0
- package/dist/watch.d.ts +5 -0
- package/dist/watch.js +502 -0
- package/docs/advanced.md +1 -0
- package/docs/atomic.md +61 -0
- package/docs/contributing.md +5 -0
- package/docs/durability.md +7 -0
- package/docs/index.md +1 -0
- package/docs/native-helper.md +9 -0
- package/docs/retained-file.md +113 -0
- package/docs/root.md +6 -1
- package/docs/temp.md +24 -4
- package/docs/testing.md +60 -0
- package/docs/types.md +6 -0
- package/docs/walk.md +22 -1
- package/docs/watch.md +184 -0
- package/package.json +12 -8
package/dist/watch.js
ADDED
|
@@ -0,0 +1,502 @@
|
|
|
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 { changedEntries, guardedHintChanges, scopedChanges } from "./watch-hints.js";
|
|
9
|
+
import { watchBinding, NativeWatchBackend } from "./watch-native.js";
|
|
10
|
+
import { getFsSafeNativeConfig } from "./native-config.js";
|
|
11
|
+
import { isWatchPathError, scanWatch, watchScopes } from "./watch-scan.js";
|
|
12
|
+
function deferred() {
|
|
13
|
+
let settled = false;
|
|
14
|
+
let resolve;
|
|
15
|
+
let reject;
|
|
16
|
+
const promise = new Promise((yes, no) => { resolve = yes; reject = no; });
|
|
17
|
+
// Ownership exists even when a consumer closes without awaiting startup.
|
|
18
|
+
void promise.catch(() => { });
|
|
19
|
+
return { promise, get settled() { return settled; },
|
|
20
|
+
resolve() { settled = true; resolve(); },
|
|
21
|
+
reject(error) { settled = true; reject(error); },
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
function budget(value, fallback, name, max = 1_000_000) {
|
|
25
|
+
const result = value ?? fallback;
|
|
26
|
+
if (!Number.isSafeInteger(result) || result < 1 || result > max)
|
|
27
|
+
throw new RangeError("invalid watch " + name);
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
const retired = () => new DOMException("Watch generation retired", "AbortError");
|
|
31
|
+
const coalesceMs = 25;
|
|
32
|
+
/** Advisory observation only. Hints never grant filesystem authority. */
|
|
33
|
+
export function watch(root, input) {
|
|
34
|
+
const context = rootHandleContext(root);
|
|
35
|
+
const options = { ...input };
|
|
36
|
+
const persistent = options.persistent !== false;
|
|
37
|
+
if (!["auto", "events", "poll"].includes(options.mode))
|
|
38
|
+
throw new TypeError("invalid watch mode");
|
|
39
|
+
let binding;
|
|
40
|
+
let selectionFailure;
|
|
41
|
+
try {
|
|
42
|
+
binding = watchBinding(options.mode);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
selectionFailure = error;
|
|
46
|
+
}
|
|
47
|
+
let mode = binding || options.mode === "events" ? "events" : "poll";
|
|
48
|
+
let intervalMs = budget(options.intervalMs, mode === "events" ? 30_000 : 1000, "intervalMs", 2_147_483_647);
|
|
49
|
+
if (intervalMs < 20)
|
|
50
|
+
throw new RangeError("watch intervalMs must be at least 20");
|
|
51
|
+
const maxDirectories = budget(options.maxDirectories, 4096, "maxDirectories");
|
|
52
|
+
const maxEntries = budget(options.maxEntries, 100_000, "maxEntries");
|
|
53
|
+
const maxPendingPaths = budget(options.maxPendingPaths, 256, "maxPendingPaths", 4096);
|
|
54
|
+
if (typeof options.onInvalidate !== "function")
|
|
55
|
+
throw new TypeError("watch requires onInvalidate");
|
|
56
|
+
const makeGeneration = (scopes) => ({
|
|
57
|
+
scopes: watchScopes(scopes), abort: new AbortController(), waiter: deferred(),
|
|
58
|
+
});
|
|
59
|
+
let current = makeGeneration(options.scopes);
|
|
60
|
+
const ready = current.waiter.promise;
|
|
61
|
+
let state = "starting";
|
|
62
|
+
let terminal = false;
|
|
63
|
+
let failure;
|
|
64
|
+
let failureInfo;
|
|
65
|
+
let retirementFailure;
|
|
66
|
+
const retirementErrors = new Set();
|
|
67
|
+
let closing;
|
|
68
|
+
let active;
|
|
69
|
+
let backend;
|
|
70
|
+
const registered = new Map();
|
|
71
|
+
let observedDirectories = 0;
|
|
72
|
+
let snapshot;
|
|
73
|
+
let resetRequested = false;
|
|
74
|
+
let refreshBackend = false;
|
|
75
|
+
let pending = false;
|
|
76
|
+
let pendingWaiter;
|
|
77
|
+
let runningWaiter;
|
|
78
|
+
let pendingHint = false;
|
|
79
|
+
let pendingChanges = new Map();
|
|
80
|
+
let timer;
|
|
81
|
+
let hintTimer;
|
|
82
|
+
const combinedFailure = () => {
|
|
83
|
+
if (!retirementFailure)
|
|
84
|
+
return failure;
|
|
85
|
+
const error = retirementFailure.error;
|
|
86
|
+
if (failure === undefined || error === failure || error?.suppressed === failure)
|
|
87
|
+
return error;
|
|
88
|
+
return createSuppressedError(error, failure, "watch observation and retirement failed");
|
|
89
|
+
};
|
|
90
|
+
const retainRetirement = (error) => {
|
|
91
|
+
if (retirementErrors.has(error))
|
|
92
|
+
return;
|
|
93
|
+
retirementErrors.add(error);
|
|
94
|
+
retirementFailure = { error: retirementFailure
|
|
95
|
+
? createSuppressedError(error, retirementFailure.error, "watch retirement failed more than once") : error };
|
|
96
|
+
};
|
|
97
|
+
const health = () => Object.freeze({
|
|
98
|
+
state, mode, directories: observedDirectories,
|
|
99
|
+
...(failure === undefined && !retirementFailure ? {} : {
|
|
100
|
+
failure: retirementFailure ? Object.freeze({ operation: "close", error: combinedFailure() }) : failureInfo,
|
|
101
|
+
}),
|
|
102
|
+
});
|
|
103
|
+
const check = (g) => {
|
|
104
|
+
g.abort.signal.throwIfAborted();
|
|
105
|
+
if (terminal || current !== g)
|
|
106
|
+
throw retired();
|
|
107
|
+
if (failure !== undefined)
|
|
108
|
+
throw failure;
|
|
109
|
+
if (retirementFailure)
|
|
110
|
+
throw retirementFailure.error;
|
|
111
|
+
};
|
|
112
|
+
const clearTimers = () => {
|
|
113
|
+
clearTimeout(timer);
|
|
114
|
+
clearTimeout(hintTimer);
|
|
115
|
+
timer = undefined;
|
|
116
|
+
hintTimer = undefined;
|
|
117
|
+
pending = false;
|
|
118
|
+
pendingHint = false;
|
|
119
|
+
pendingChanges = new Map();
|
|
120
|
+
};
|
|
121
|
+
const notifyHealth = () => {
|
|
122
|
+
try {
|
|
123
|
+
const result = options.onHealth?.(health());
|
|
124
|
+
assertSynchronousCallbackResult(result, "watch onHealth");
|
|
125
|
+
}
|
|
126
|
+
catch (cause) {
|
|
127
|
+
throw new FsSafeError("helper-failed", "watch health callback failed", { cause, details: { operation: "callback" } });
|
|
128
|
+
}
|
|
129
|
+
};
|
|
130
|
+
const dirty = (g, reason, changes) => {
|
|
131
|
+
check(g);
|
|
132
|
+
try {
|
|
133
|
+
const result = options.onInvalidate(Object.freeze({ reason, changes: changes && Object.freeze(changes) }));
|
|
134
|
+
assertSynchronousCallbackResult(result, "watch onInvalidate");
|
|
135
|
+
}
|
|
136
|
+
catch (cause) {
|
|
137
|
+
throw new FsSafeError("helper-failed", "watch dirty callback failed", { cause, details: { operation: "callback" } });
|
|
138
|
+
}
|
|
139
|
+
check(g);
|
|
140
|
+
};
|
|
141
|
+
const retain = (error) => {
|
|
142
|
+
if (failure === undefined) {
|
|
143
|
+
failure = error;
|
|
144
|
+
failureInfo = Object.freeze({ operation: "close", error });
|
|
145
|
+
}
|
|
146
|
+
else if (error !== failure)
|
|
147
|
+
failure = createSuppressedError(error, failure, "watch and retirement both failed");
|
|
148
|
+
};
|
|
149
|
+
const retireBackend = async () => {
|
|
150
|
+
const old = backend;
|
|
151
|
+
try {
|
|
152
|
+
await old?.close();
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
retainRetirement(error);
|
|
156
|
+
}
|
|
157
|
+
if (backend === old) {
|
|
158
|
+
backend = undefined;
|
|
159
|
+
registered.clear();
|
|
160
|
+
observedDirectories = 0;
|
|
161
|
+
}
|
|
162
|
+
if (retirementFailure)
|
|
163
|
+
throw retirementFailure.error;
|
|
164
|
+
};
|
|
165
|
+
const lose = (error, operation = "scan") => {
|
|
166
|
+
if (terminal || failure !== undefined)
|
|
167
|
+
return;
|
|
168
|
+
if (error === undefined)
|
|
169
|
+
error = new FsSafeError("helper-failed", "watch operation failed without an error value", { details: { operation } });
|
|
170
|
+
retain(error);
|
|
171
|
+
const details = error instanceof FsSafeError ? error.details : undefined;
|
|
172
|
+
const code = details?.code ?? error?.code;
|
|
173
|
+
failureInfo = Object.freeze({ error, operation: details?.operation === "watch" ? "watch" : details?.operation === "callback" ? "callback" : details?.operation === "close" ? "close" : operation, ...(typeof code === "string" ? { code } : {}) });
|
|
174
|
+
current.abort.abort(error);
|
|
175
|
+
current.waiter.reject(error);
|
|
176
|
+
pendingWaiter?.reject(error);
|
|
177
|
+
runningWaiter?.reject(error);
|
|
178
|
+
pendingWaiter = undefined;
|
|
179
|
+
runningWaiter = undefined;
|
|
180
|
+
clearTimers();
|
|
181
|
+
state = "unavailable";
|
|
182
|
+
// Stop backend admission before notifying. Its join remains owned by active/close.
|
|
183
|
+
try {
|
|
184
|
+
backend?.close();
|
|
185
|
+
}
|
|
186
|
+
catch (error) {
|
|
187
|
+
retainRetirement(error);
|
|
188
|
+
}
|
|
189
|
+
try {
|
|
190
|
+
notifyHealth();
|
|
191
|
+
}
|
|
192
|
+
catch (callbackError) {
|
|
193
|
+
retain(callbackError);
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const scheduleInterval = () => {
|
|
197
|
+
if (terminal || failure !== undefined)
|
|
198
|
+
return;
|
|
199
|
+
timer = setTimeout(() => { timer = undefined; void request().catch(() => { }); }, intervalMs);
|
|
200
|
+
if (!persistent)
|
|
201
|
+
timer.unref();
|
|
202
|
+
};
|
|
203
|
+
const onHint = (g, batch) => {
|
|
204
|
+
if (terminal || current !== g || g.abort.signal.aborted || failure !== undefined)
|
|
205
|
+
return;
|
|
206
|
+
if (batch.error === "ESTALE")
|
|
207
|
+
refreshBackend = true;
|
|
208
|
+
else if (batch.error) {
|
|
209
|
+
lose(new FsSafeError("helper-failed", "native watch failed", { details: { operation: "watch", code: batch.error } }));
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
if (batch.overflow)
|
|
213
|
+
pendingChanges = undefined;
|
|
214
|
+
else if (pendingChanges)
|
|
215
|
+
for (const hint of batch.hints) {
|
|
216
|
+
const key = JSON.stringify([hint.directory, hint.name]);
|
|
217
|
+
if (!pendingChanges.has(key) && pendingChanges.size >= maxPendingPaths) {
|
|
218
|
+
pendingChanges = undefined;
|
|
219
|
+
break;
|
|
220
|
+
}
|
|
221
|
+
const previous = pendingChanges.get(key);
|
|
222
|
+
pendingChanges.set(key, previous?.event === "rename" ? previous : hint);
|
|
223
|
+
}
|
|
224
|
+
pendingHint = true;
|
|
225
|
+
pending = true;
|
|
226
|
+
if (hintTimer)
|
|
227
|
+
return;
|
|
228
|
+
hintTimer = setTimeout(() => {
|
|
229
|
+
hintTimer = undefined;
|
|
230
|
+
if (terminal || current !== g || failure !== undefined)
|
|
231
|
+
return;
|
|
232
|
+
// Raw backend filenames stay private. Reconcile before publishing detail.
|
|
233
|
+
void request().catch(() => { });
|
|
234
|
+
}, coalesceMs);
|
|
235
|
+
if (!persistent)
|
|
236
|
+
hintTimer.unref();
|
|
237
|
+
};
|
|
238
|
+
const fallBack = (error) => {
|
|
239
|
+
if (options.mode !== "auto" || getFsSafeNativeConfig().mode === "require" ||
|
|
240
|
+
!(error instanceof FsSafeError) || error.code !== "helper-unavailable")
|
|
241
|
+
return false;
|
|
242
|
+
mode = "poll";
|
|
243
|
+
binding = undefined;
|
|
244
|
+
intervalMs = options.intervalMs ?? 1000;
|
|
245
|
+
return true;
|
|
246
|
+
};
|
|
247
|
+
const observe = async (g) => {
|
|
248
|
+
check(g);
|
|
249
|
+
if (selectionFailure !== undefined)
|
|
250
|
+
throw new FsSafeError("helper-unavailable", "native watch events are unavailable", { cause: selectionFailure, details: { operation: "watch" } });
|
|
251
|
+
if (refreshBackend) {
|
|
252
|
+
await retireBackend();
|
|
253
|
+
check(g);
|
|
254
|
+
refreshBackend = false;
|
|
255
|
+
}
|
|
256
|
+
if (resetRequested) {
|
|
257
|
+
await retireBackend();
|
|
258
|
+
check(g);
|
|
259
|
+
snapshot = undefined;
|
|
260
|
+
resetRequested = false;
|
|
261
|
+
}
|
|
262
|
+
state = snapshot ? "reconciling" : "starting";
|
|
263
|
+
notifyHealth();
|
|
264
|
+
check(g);
|
|
265
|
+
const started = performance.now();
|
|
266
|
+
const hadHints = pendingHint;
|
|
267
|
+
const hints = pendingChanges;
|
|
268
|
+
pendingHint = false;
|
|
269
|
+
pendingChanges = new Map();
|
|
270
|
+
clearTimeout(hintTimer);
|
|
271
|
+
hintTimer = undefined;
|
|
272
|
+
if (mode === "events" && !backend && g.scopes.length) {
|
|
273
|
+
try {
|
|
274
|
+
const candidate = new NativeWatchBackend(binding, context, batch => {
|
|
275
|
+
if (backend === candidate)
|
|
276
|
+
onHint(g, batch);
|
|
277
|
+
}, maxPendingPaths, persistent);
|
|
278
|
+
backend = candidate;
|
|
279
|
+
const hookResult = getFsSafeTestHooks()?.afterWatchBackendCreated?.(context.rootReal, batch => {
|
|
280
|
+
if (backend === candidate)
|
|
281
|
+
onHint(g, batch);
|
|
282
|
+
}, (path, flags) => candidate.testEvent(path, flags));
|
|
283
|
+
assertSynchronousCallbackResult(hookResult, "afterWatchBackendCreated");
|
|
284
|
+
}
|
|
285
|
+
catch (error) {
|
|
286
|
+
if (!fallBack(error))
|
|
287
|
+
throw error;
|
|
288
|
+
}
|
|
289
|
+
check(g);
|
|
290
|
+
}
|
|
291
|
+
const next = await scanWatch(context, g.scopes, { exclude: options.exclude, maxDirectories, maxEntries, maxPendingPaths, admitting: !snapshot }, g.abort.signal, async (name, identity, guard) => {
|
|
292
|
+
check(g);
|
|
293
|
+
const existing = registered.get(name);
|
|
294
|
+
const acquire = !existing || existing.dev !== identity.dev || existing.ino !== identity.ino;
|
|
295
|
+
await getFsSafeTestHooks()?.beforeWatchRegistration?.(guard.realPath);
|
|
296
|
+
check(g);
|
|
297
|
+
if (acquire) {
|
|
298
|
+
try {
|
|
299
|
+
backend?.add(name, identity);
|
|
300
|
+
}
|
|
301
|
+
catch (error) {
|
|
302
|
+
if (snapshot || !fallBack(error))
|
|
303
|
+
throw error;
|
|
304
|
+
await retireBackend();
|
|
305
|
+
check(g);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
await getFsSafeTestHooks()?.afterWatchRegistration?.(guard.realPath);
|
|
309
|
+
check(g);
|
|
310
|
+
if (acquire)
|
|
311
|
+
registered.set(name, identity);
|
|
312
|
+
}, retainRetirement);
|
|
313
|
+
check(g);
|
|
314
|
+
// Retire stale inventory before the next pass; that crawl installs fresh anchors first.
|
|
315
|
+
if ([...registered.keys()].some(name => !next.directories.has(name))) {
|
|
316
|
+
refreshBackend = true;
|
|
317
|
+
}
|
|
318
|
+
observedDirectories = next.directories.size;
|
|
319
|
+
const initial = !snapshot;
|
|
320
|
+
let observed = next.overflow ? undefined : changedEntries(snapshot, next, maxPendingPaths);
|
|
321
|
+
if (observed) {
|
|
322
|
+
const changes = new Map(observed.map(change => [change.path, change]));
|
|
323
|
+
for (const name of next.structural ?? [])
|
|
324
|
+
for (const change of scopedChanges(g.scopes, { path: name, type: "structural" }))
|
|
325
|
+
changes.set(change.path, change);
|
|
326
|
+
observed = changes.size > maxPendingPaths ? undefined : [...changes.values()];
|
|
327
|
+
}
|
|
328
|
+
let admittedHints = [];
|
|
329
|
+
try {
|
|
330
|
+
if (hadHints)
|
|
331
|
+
admittedHints = await admittedNativeChanges(context, g.scopes, snapshot, next, {
|
|
332
|
+
hints: hints ? [...hints.values()] : [], overflow: !hints,
|
|
333
|
+
}, g.abort.signal, maxPendingPaths);
|
|
334
|
+
}
|
|
335
|
+
catch (error) {
|
|
336
|
+
check(g);
|
|
337
|
+
await assertRootIdentityCurrent(context);
|
|
338
|
+
if (!isWatchPathError(error))
|
|
339
|
+
throw error;
|
|
340
|
+
admittedHints = undefined;
|
|
341
|
+
}
|
|
342
|
+
check(g);
|
|
343
|
+
await assertRootIdentityCurrent(context);
|
|
344
|
+
check(g);
|
|
345
|
+
let details = hadHints
|
|
346
|
+
? guardedHintChanges(g.scopes, snapshot, next, admittedHints, observed, maxPendingPaths)
|
|
347
|
+
: observed;
|
|
348
|
+
const behind = (hadHints || pendingHint) && performance.now() - started > coalesceMs;
|
|
349
|
+
if (behind)
|
|
350
|
+
details = undefined;
|
|
351
|
+
snapshot = next;
|
|
352
|
+
// Publish before readiness; callbacks may synchronously retire this generation.
|
|
353
|
+
if (initial || !details || details.length)
|
|
354
|
+
dirty(g, initial ? "reconcile" : !details ? "overflow" : hadHints ? "event" : "reconcile", initial ? undefined : details);
|
|
355
|
+
check(g);
|
|
356
|
+
state = "ready";
|
|
357
|
+
notifyHealth();
|
|
358
|
+
check(g);
|
|
359
|
+
};
|
|
360
|
+
const pump = () => {
|
|
361
|
+
if (active || terminal || failure !== undefined)
|
|
362
|
+
return;
|
|
363
|
+
// Enroll before any callback. There is one active pass and one coalesced request.
|
|
364
|
+
active = Promise.resolve().then(async () => {
|
|
365
|
+
do {
|
|
366
|
+
const g = current;
|
|
367
|
+
pending = false;
|
|
368
|
+
if (pendingWaiter) {
|
|
369
|
+
// A scope replacement keeps reconcile requests alive until its baseline completes.
|
|
370
|
+
const previous = runningWaiter;
|
|
371
|
+
runningWaiter = pendingWaiter;
|
|
372
|
+
pendingWaiter = undefined;
|
|
373
|
+
if (previous)
|
|
374
|
+
void runningWaiter.promise.then(() => previous.resolve(), error => previous.reject(error));
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
await observe(g);
|
|
378
|
+
check(g);
|
|
379
|
+
g.waiter.resolve();
|
|
380
|
+
runningWaiter?.resolve();
|
|
381
|
+
runningWaiter = undefined;
|
|
382
|
+
}
|
|
383
|
+
catch (error) {
|
|
384
|
+
g.waiter.reject(error);
|
|
385
|
+
if (!g.abort.signal.aborted && current === g && !terminal)
|
|
386
|
+
lose(error);
|
|
387
|
+
try {
|
|
388
|
+
await retireBackend();
|
|
389
|
+
}
|
|
390
|
+
catch (closeError) {
|
|
391
|
+
retainRetirement(closeError);
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
if (current !== g) {
|
|
395
|
+
await retireBackend();
|
|
396
|
+
snapshot = undefined;
|
|
397
|
+
pending = true;
|
|
398
|
+
}
|
|
399
|
+
} while (pending && !terminal && failure === undefined);
|
|
400
|
+
}).catch(lose).finally(() => {
|
|
401
|
+
active = undefined;
|
|
402
|
+
if (!terminal && failure === undefined) {
|
|
403
|
+
if (pending || !current.waiter.settled)
|
|
404
|
+
pump();
|
|
405
|
+
else
|
|
406
|
+
scheduleInterval();
|
|
407
|
+
}
|
|
408
|
+
});
|
|
409
|
+
};
|
|
410
|
+
const request = () => {
|
|
411
|
+
if (terminal)
|
|
412
|
+
return Promise.reject(retired());
|
|
413
|
+
if (failure !== undefined)
|
|
414
|
+
return Promise.reject(failure);
|
|
415
|
+
clearTimeout(timer);
|
|
416
|
+
timer = undefined;
|
|
417
|
+
pending = true;
|
|
418
|
+
pendingWaiter ??= deferred();
|
|
419
|
+
const result = pendingWaiter.promise;
|
|
420
|
+
pump();
|
|
421
|
+
return result;
|
|
422
|
+
};
|
|
423
|
+
const close = () => {
|
|
424
|
+
if (closing)
|
|
425
|
+
return closing;
|
|
426
|
+
terminal = true;
|
|
427
|
+
current.abort.abort(retired());
|
|
428
|
+
current.waiter.reject(retired());
|
|
429
|
+
pendingWaiter?.reject(retired());
|
|
430
|
+
runningWaiter?.reject(retired());
|
|
431
|
+
pendingWaiter = undefined;
|
|
432
|
+
runningWaiter = undefined;
|
|
433
|
+
clearTimers();
|
|
434
|
+
options.signal?.removeEventListener("abort", abort);
|
|
435
|
+
// Start physical stop immediately, without waiting behind an in-flight scan.
|
|
436
|
+
try {
|
|
437
|
+
backend?.close();
|
|
438
|
+
}
|
|
439
|
+
catch (error) {
|
|
440
|
+
retainRetirement(error);
|
|
441
|
+
}
|
|
442
|
+
closing = Promise.resolve().then(async () => {
|
|
443
|
+
await active;
|
|
444
|
+
try {
|
|
445
|
+
await retireBackend();
|
|
446
|
+
}
|
|
447
|
+
catch (error) {
|
|
448
|
+
retainRetirement(error);
|
|
449
|
+
}
|
|
450
|
+
state = "closed";
|
|
451
|
+
if (retirementFailure)
|
|
452
|
+
throw combinedFailure();
|
|
453
|
+
});
|
|
454
|
+
void closing.catch(() => { });
|
|
455
|
+
return closing;
|
|
456
|
+
};
|
|
457
|
+
const abort = () => { void close(); };
|
|
458
|
+
const subscription = {
|
|
459
|
+
ready, health, close, [Symbol.asyncDispose]: close,
|
|
460
|
+
reconcile: request,
|
|
461
|
+
setScopes(scopes) {
|
|
462
|
+
if (terminal)
|
|
463
|
+
return Promise.reject(retired());
|
|
464
|
+
if (failure !== undefined)
|
|
465
|
+
return Promise.reject(failure);
|
|
466
|
+
const previous = current;
|
|
467
|
+
const next = makeGeneration(scopes);
|
|
468
|
+
// Scope accessors may synchronously close this owner during validation.
|
|
469
|
+
if (terminal || failure !== undefined || current !== previous) {
|
|
470
|
+
next.waiter.reject(failure ?? retired());
|
|
471
|
+
return next.waiter.promise;
|
|
472
|
+
}
|
|
473
|
+
current.abort.abort(retired());
|
|
474
|
+
current.waiter.reject(retired());
|
|
475
|
+
current = next;
|
|
476
|
+
resetRequested = true;
|
|
477
|
+
try {
|
|
478
|
+
backend?.close();
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
retainRetirement(error);
|
|
482
|
+
}
|
|
483
|
+
clearTimers();
|
|
484
|
+
state = "starting";
|
|
485
|
+
// If idle, retire the old backend before the next scan can admit anything.
|
|
486
|
+
if (!active) {
|
|
487
|
+
active = retireBackend().catch(error => lose(error, "close")).finally(() => {
|
|
488
|
+
active = undefined;
|
|
489
|
+
snapshot = undefined;
|
|
490
|
+
pump();
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return next.waiter.promise;
|
|
494
|
+
},
|
|
495
|
+
};
|
|
496
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
497
|
+
if (options.signal?.aborted)
|
|
498
|
+
abort();
|
|
499
|
+
else
|
|
500
|
+
pump();
|
|
501
|
+
return subscription;
|
|
502
|
+
}
|
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/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,65 @@ 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 and thenable results are
|
|
158
|
+
rejected, and ordinary return values are ignored. The first callback refusal
|
|
159
|
+
is terminal, including falsy thrown values; an `EPERM`, `EEXIST`, or `EBUSY` code
|
|
160
|
+
from a callback never starts fallback or retry. Refusal during an in-place
|
|
161
|
+
fallback also stops new restoration writes. Final mode, synchronization, close,
|
|
162
|
+
and private-stage cleanup may still settle against owned identities after
|
|
163
|
+
revocation. Callback refusal does not delete a published destination or a
|
|
164
|
+
competing file at a staging name. An observer that throws must retain its receipt
|
|
165
|
+
first if recovery needs it.
|
|
166
|
+
|
|
167
|
+
`beforeRename` remains the hook for preparing backups. Effects performed inside
|
|
168
|
+
that hook remain the caller's responsibility; the authority option guards the
|
|
169
|
+
atomic writer's own effects. Omitting both new callbacks preserves existing
|
|
170
|
+
write, fallback, restoration, result, and cleanup behavior.
|
|
171
|
+
|
|
111
172
|
### FUSE, Windows exFAT/FAT32, and unstable rename identity
|
|
112
173
|
|
|
113
174
|
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.
|
package/docs/contributing.md
CHANGED
|
@@ -167,6 +167,11 @@ pnpm check
|
|
|
167
167
|
This runs the filesystem boundary checks, build, tests, and package
|
|
168
168
|
tarball/import validation.
|
|
169
169
|
|
|
170
|
+
The native watch lane requires events on Linux, macOS, and Windows and reports
|
|
171
|
+
actual edit latency. Run `FS_SAFE_TEST_SERIAL=1 pnpm check` to isolate local timing
|
|
172
|
+
checks from the other filesystem stress suites. Watch fixtures use normal OS
|
|
173
|
+
temporary storage; session scratch trees may suppress macOS filesystem events.
|
|
174
|
+
|
|
170
175
|
### Method benchmarks
|
|
171
176
|
|
|
172
177
|
`pnpm benchmark:methods` measures the callable library surface against synthetic
|
package/docs/durability.md
CHANGED
|
@@ -436,3 +436,10 @@ owning product boundary.
|
|
|
436
436
|
- [Migrating to 0.5](migrating-to-0.5.md) — choosing a publication policy during upgrade.
|
|
437
437
|
- [Native architecture](native.md) — clone/copy/hash mechanisms and fallback guarantees.
|
|
438
438
|
- [Errors](errors.md) — typed operational failure handling.
|
|
439
|
+
|
|
440
|
+
## Existing Windows file retirement
|
|
441
|
+
|
|
442
|
+
[`retainFileInDirectory`](retained-file.md) describes identity-bound native
|
|
443
|
+
disposition and resource settlement separately from persistence. Its result is
|
|
444
|
+
always `persistence: "not-proven"`; neither accepted disposition nor observed
|
|
445
|
+
namespace absence is a directory/volume barrier or an application commit.
|
package/docs/index.md
CHANGED
|
@@ -65,6 +65,7 @@ await fs.remove("notes/archive/today.txt");
|
|
|
65
65
|
| [Private file-store mode](private-file-store.md) | `fileStore({ private: true })` for private JSON/text state at 0600 under 0700 dirs. |
|
|
66
66
|
| [`tempWorkspace`](temp.md) | 0700 scratch dir with auto-cleanup. |
|
|
67
67
|
| [`readSecureFile`](secure-file.md) | Absolute file reads with fd pinning, permissions, owner, size, and timeout checks. |
|
|
68
|
+
| [`watch`](watch.md) | Guarded filesystem observation with advisory native hints and polling. |
|
|
68
69
|
| [`walkDirectory` / `Root.walk`](walk.md) | Standalone inventories plus root-bounded pruning, budgets, and partial-error reporting. |
|
|
69
70
|
| [`Root.entries`](entries.md) | Guarded nonrecursive entries, bounded name collection, and caller-owned symlink validation. |
|
|
70
71
|
| [`extractArchive`](archive.md) | Policy-driven ZIP/TAR extraction with clamp/filter, metadata/path-depth, link, count, and byte limits. |
|
package/docs/native-helper.md
CHANGED
|
@@ -182,3 +182,12 @@ consumer performs its 0.5 upgrade.
|
|
|
182
182
|
- [Durability](durability.md)
|
|
183
183
|
- [Migrating to 0.5](migrating-to-0.5.md)
|
|
184
184
|
- [Migrating to 0.6](migrating-to-0.6.md)
|
|
185
|
+
|
|
186
|
+
### Retained existing Windows file
|
|
187
|
+
|
|
188
|
+
The maintained Windows helper supports the public
|
|
189
|
+
[`retainFileInDirectory`](retained-file.md) lifecycle on fixed local NTFS.
|
|
190
|
+
Private native handles, exact identity/generation checks, writable-section
|
|
191
|
+
admission and explicit close results stay behind that public API. Older helpers
|
|
192
|
+
without this capability are unsupported; there is no pathname deletion fallback.
|
|
193
|
+
No Windows namespace persistence barrier is provided.
|