@openclaw/fs-safe 0.21.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 +24 -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/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 +11 -0
- package/dist/native-pinned-write.js +8 -2
- package/dist/pinned-mutation-admission.js +4 -2
- package/dist/replace-file-copy-fallback.js +4 -4
- package/dist/replace-file-destination.d.ts +4 -0
- package/dist/replace-file-destination.js +67 -5
- package/dist/replace-file.js +2 -1
- package/dist/retained-file-types.d.ts +3 -14
- package/dist/root-directory-list.d.ts +2 -0
- package/dist/root-directory-list.js +46 -20
- package/dist/root-move-noreplace.js +3 -3
- 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/test-hooks.d.ts +1 -0
- package/dist/watch-alias.js +11 -3
- package/dist/watch-hints.d.ts +2 -1
- package/dist/watch-hints.js +17 -1
- package/dist/watch-native.d.ts +4 -0
- package/dist/watch-native.js +18 -1
- package/dist/watch-scan.d.ts +5 -1
- package/dist/watch-scan.js +37 -6
- package/dist/watch-stream.d.ts +8 -0
- package/dist/watch-stream.js +32 -0
- package/dist/watch-types.d.ts +2 -0
- package/dist/watch.js +35 -7
- package/docs/archive.md +6 -0
- package/docs/atomic.md +23 -2
- package/docs/contributing.md +69 -6
- package/docs/install.md +2 -0
- package/docs/native.md +24 -6
- package/docs/public-api.md +23 -2
- package/docs/retained-file.md +4 -2
- package/docs/root.md +19 -7
- package/docs/sidecar-lock.md +1 -1
- package/docs/staged-symlink.md +2 -1
- package/docs/testing.md +132 -10
- package/docs/watch.md +77 -10
- package/package.json +8 -8
|
@@ -37,6 +37,8 @@ export type RootDirectoryListingOptions = {
|
|
|
37
37
|
metadataBatchSize?: number;
|
|
38
38
|
/** Internal exact metadata lane, supported by streaming filesystem order. */
|
|
39
39
|
exactIdentity?: boolean;
|
|
40
|
+
/** Advisory scans may omit vanished leaves after revalidating their parent. */
|
|
41
|
+
skipVanished?: boolean;
|
|
40
42
|
/** Internal owner receives cleanup failures, including acquisition rollback. */
|
|
41
43
|
onCleanupFailure?: (error: unknown) => void;
|
|
42
44
|
admitEntry(): boolean;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { isUtf8 } from "node:buffer";
|
|
1
2
|
import fsSync from "node:fs";
|
|
2
3
|
import fs from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
@@ -14,6 +15,22 @@ import { realpathSync } from "./realpath.js";
|
|
|
14
15
|
import { inspectStatObservationSync } from "./stat-observation.js";
|
|
15
16
|
import { getFsSafeTestHooks } from "./test-hooks.js";
|
|
16
17
|
const METADATA_BATCH_SIZE = 32;
|
|
18
|
+
function directoryEntryName(bytes) {
|
|
19
|
+
if (!isUtf8(bytes)) {
|
|
20
|
+
throw new FsSafeError("invalid-path", "directory entry name is not valid UTF-8");
|
|
21
|
+
}
|
|
22
|
+
return bytes.toString("utf8");
|
|
23
|
+
}
|
|
24
|
+
async function readDirectoryEntryName(handle) {
|
|
25
|
+
const entry = await handle.read();
|
|
26
|
+
// Bun returns the raw Buffer directly; Node returns a Dirent whose name is a Buffer.
|
|
27
|
+
// Node's Dir types still declare only string names.
|
|
28
|
+
return entry === null ? undefined
|
|
29
|
+
: directoryEntryName(Buffer.isBuffer(entry) ? entry : entry.name);
|
|
30
|
+
}
|
|
31
|
+
function openDirectoryNames(directory) {
|
|
32
|
+
return fs.opendir(directory, { bufferSize: 1, encoding: "buffer" });
|
|
33
|
+
}
|
|
17
34
|
export function pathStatFromStats(stat) {
|
|
18
35
|
const mtimeMs = typeof stat.mtimeMs === "bigint"
|
|
19
36
|
? "mtimeNs" in stat && typeof stat.mtimeNs === "bigint"
|
|
@@ -202,7 +219,7 @@ async function listGuardedDirectoryPath(root, guard, withFileTypes, receipt) {
|
|
|
202
219
|
const beforeObservation = getFsSafeTestHooks()?.beforeRootListObservation;
|
|
203
220
|
if (beforeObservation)
|
|
204
221
|
await beforeObservation(guard.realPath, withFileTypes);
|
|
205
|
-
const names = (await fs.readdir(guard.realPath)).sort();
|
|
222
|
+
const names = (await fs.readdir(guard.realPath, { encoding: "buffer" })).map(directoryEntryName).sort();
|
|
206
223
|
entries = withFileTypes
|
|
207
224
|
? names.map(name => ({
|
|
208
225
|
name,
|
|
@@ -275,17 +292,17 @@ export async function openRootDirectoryListing(root, directory, options, receipt
|
|
|
275
292
|
await assertCurrent();
|
|
276
293
|
if (options.order === "filesystem") {
|
|
277
294
|
// A one-entry buffer keeps the truncation lookahead independent of width.
|
|
278
|
-
handle = await
|
|
295
|
+
handle = await openDirectoryNames(guard.realPath);
|
|
279
296
|
}
|
|
280
297
|
else if (options.snapshot) {
|
|
281
298
|
snapshot = await listGuardedDirectoryPath(root, guard, true, receipt);
|
|
282
299
|
}
|
|
283
300
|
else if (options.maxNames !== undefined) {
|
|
284
301
|
names = [];
|
|
285
|
-
handle = await
|
|
302
|
+
handle = await openDirectoryNames(guard.realPath);
|
|
286
303
|
while (true) {
|
|
287
304
|
await assertCurrent();
|
|
288
|
-
const name =
|
|
305
|
+
const name = await readDirectoryEntryName(handle);
|
|
289
306
|
await assertCurrent();
|
|
290
307
|
if (name === undefined)
|
|
291
308
|
break;
|
|
@@ -297,7 +314,7 @@ export async function openRootDirectoryListing(root, directory, options, receipt
|
|
|
297
314
|
await close();
|
|
298
315
|
}
|
|
299
316
|
else {
|
|
300
|
-
names = await fs.readdir(guard.realPath);
|
|
317
|
+
names = (await fs.readdir(guard.realPath, { encoding: "buffer" })).map(directoryEntryName);
|
|
301
318
|
}
|
|
302
319
|
await assertCurrent();
|
|
303
320
|
names?.sort();
|
|
@@ -382,21 +399,30 @@ export async function openRootDirectoryListing(root, directory, options, receipt
|
|
|
382
399
|
return { kind: "limit", name: pendingLimit };
|
|
383
400
|
return;
|
|
384
401
|
}
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
402
|
+
for (;;) {
|
|
403
|
+
await assertCurrent();
|
|
404
|
+
const name = await readDirectoryEntryName(handle);
|
|
405
|
+
await assertCurrent();
|
|
406
|
+
if (name === undefined)
|
|
407
|
+
return;
|
|
408
|
+
if (!options.admitEntry())
|
|
409
|
+
return { kind: "limit", name };
|
|
410
|
+
// The stream's post-read fence is also the pre-stat fence in this owned operation.
|
|
411
|
+
const pathname = path.join(guard.realPath, name);
|
|
412
|
+
let observed;
|
|
413
|
+
try {
|
|
414
|
+
observed = options.exactIdentity ? inspectStatObservationSync(bigint => bigint
|
|
415
|
+
? fsSync.lstatSync(pathname, { bigint: true }) : fsSync.lstatSync(pathname)) : { stat: fsSync.lstatSync(pathname) };
|
|
416
|
+
}
|
|
417
|
+
catch (error) {
|
|
418
|
+
await assertCurrent();
|
|
419
|
+
if (options.skipVanished && isNotFoundPathError(error))
|
|
420
|
+
continue;
|
|
421
|
+
throw error;
|
|
422
|
+
}
|
|
423
|
+
await assertCurrent();
|
|
424
|
+
return { kind: "entry", entry: { name, ...pathStatFromStats(observed.stat) }, identity: observed.identity };
|
|
425
|
+
}
|
|
400
426
|
}
|
|
401
427
|
catch (error) {
|
|
402
428
|
throw normalizeDirectoryError(error);
|
|
@@ -4,7 +4,7 @@ import { assertAsyncDirectoryGuard, assertSyncDirectoryGuard } from "./directory
|
|
|
4
4
|
import { FsSafeError } from "./errors.js";
|
|
5
5
|
import { assertMutationNotDenied } from "./deny-mutations.js";
|
|
6
6
|
import { openNativeParentAdmission, openNativeRootAdmission, } from "./native-parent-admission.js";
|
|
7
|
-
import {
|
|
7
|
+
import { requireNativeBinding } from "./native.js";
|
|
8
8
|
import { isNotFoundPathError } from "./path.js";
|
|
9
9
|
import { assertRootIdentityCurrent, assertRootIdentityCurrentSync } from "./root-context.js";
|
|
10
10
|
import { resolveRootPathSync } from "./root-path.js";
|
|
@@ -78,8 +78,8 @@ export async function movePathNoReplaceNative(root, params, paths) {
|
|
|
78
78
|
throw error;
|
|
79
79
|
// Advisory fast rejection only. renameNoReplace owns the collision decision.
|
|
80
80
|
}
|
|
81
|
-
const binding =
|
|
82
|
-
if (
|
|
81
|
+
const binding = requireNativeBinding();
|
|
82
|
+
if (typeof binding.renameNoReplace !== "function") {
|
|
83
83
|
throw new FsSafeError("helper-unavailable", "native no-replace move is unavailable");
|
|
84
84
|
}
|
|
85
85
|
const rootAdmission = await openNativeRootAdmission(binding, {
|
|
@@ -290,9 +290,9 @@ export async function acquireSidecarLock(options, context) {
|
|
|
290
290
|
}
|
|
291
291
|
await handle.writeFile(raw, "utf8");
|
|
292
292
|
}
|
|
293
|
-
const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd), ownershipToken };
|
|
293
|
+
const snapshot = { raw, payload, stat: fsSync.fstatSync(handle.fd, { bigint: true }), ownershipToken };
|
|
294
294
|
createdSnapshot = snapshot;
|
|
295
|
-
if (snapshot.stat.nlink ===
|
|
295
|
+
if (snapshot.stat.nlink === 0n) {
|
|
296
296
|
await handle.close();
|
|
297
297
|
handle = null;
|
|
298
298
|
await waitForRetry();
|
|
@@ -361,7 +361,7 @@ export async function acquireSidecarLock(options, context) {
|
|
|
361
361
|
if (handle) {
|
|
362
362
|
failedSnapshot ??= { payload: null };
|
|
363
363
|
try {
|
|
364
|
-
failedSnapshot.stat = fsSync.fstatSync(handle.fd);
|
|
364
|
+
failedSnapshot.stat = fsSync.fstatSync(handle.fd, { bigint: true });
|
|
365
365
|
}
|
|
366
366
|
catch {
|
|
367
367
|
// Best-effort cleanup of a failed exclusive create.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Stats } from "node:fs";
|
|
1
|
+
import { type BigIntStats, type Stats } from "node:fs";
|
|
2
2
|
import type { Root } from "./root-impl.js";
|
|
3
3
|
export type SidecarLockStaleSnapshot = {
|
|
4
4
|
lockPath: string;
|
|
@@ -9,7 +9,7 @@ export type SidecarLockStaleSnapshot = {
|
|
|
9
9
|
export type SidecarLockSnapshot = {
|
|
10
10
|
raw?: string;
|
|
11
11
|
payload: unknown;
|
|
12
|
-
stat?: Stats;
|
|
12
|
+
stat?: Stats | BigIntStats;
|
|
13
13
|
ownershipToken?: string;
|
|
14
14
|
};
|
|
15
15
|
type SidecarLockRawSnapshot = Omit<SidecarLockSnapshot, "payload"> & {
|
|
@@ -90,7 +90,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
90
90
|
const raw = (await readFileHandleBounded(opened.handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
|
|
91
91
|
return {
|
|
92
92
|
raw,
|
|
93
|
-
stat: opened.
|
|
93
|
+
stat: fsSync.fstatSync(opened.handle.fd, { bigint: true }),
|
|
94
94
|
};
|
|
95
95
|
}
|
|
96
96
|
finally {
|
|
@@ -99,7 +99,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
99
99
|
}
|
|
100
100
|
let before;
|
|
101
101
|
try {
|
|
102
|
-
before = fsSync.lstatSync(lockPath);
|
|
102
|
+
before = fsSync.lstatSync(lockPath, { bigint: true });
|
|
103
103
|
}
|
|
104
104
|
catch (error) {
|
|
105
105
|
before = missingSnapshotPath(error);
|
|
@@ -132,7 +132,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
132
132
|
options.onOpenFailure?.(error);
|
|
133
133
|
throw error;
|
|
134
134
|
}
|
|
135
|
-
const opened = fsSync.fstatSync(handle.fd);
|
|
135
|
+
const opened = fsSync.fstatSync(handle.fd, { bigint: true });
|
|
136
136
|
if (!opened.isFile()) {
|
|
137
137
|
if (options.rejectNonFile) {
|
|
138
138
|
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
|
|
@@ -144,7 +144,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
144
144
|
const raw = (await readFileHandleBounded(handle, MAX_LOCK_PAYLOAD_BYTES)).toString("utf8");
|
|
145
145
|
let after;
|
|
146
146
|
try {
|
|
147
|
-
after = fsSync.lstatSync(lockPath);
|
|
147
|
+
after = fsSync.lstatSync(lockPath, { bigint: true });
|
|
148
148
|
}
|
|
149
149
|
catch (error) {
|
|
150
150
|
after = missingSnapshotPath(error);
|
|
@@ -159,7 +159,7 @@ export async function readSidecarLockRawSnapshot(lockPath, options = {}) {
|
|
|
159
159
|
}
|
|
160
160
|
function lstatSidecarLockSync(lockPath) {
|
|
161
161
|
try {
|
|
162
|
-
return fsSync.lstatSync(lockPath);
|
|
162
|
+
return fsSync.lstatSync(lockPath, { bigint: true });
|
|
163
163
|
}
|
|
164
164
|
catch (error) {
|
|
165
165
|
return missingSnapshotPath(error);
|
|
@@ -189,7 +189,7 @@ export function readSidecarLockRawSnapshotSync(lockPath, options = {}) {
|
|
|
189
189
|
options.onOpenFailure?.(error);
|
|
190
190
|
throw error;
|
|
191
191
|
}
|
|
192
|
-
const opened = fsSync.fstatSync(fd);
|
|
192
|
+
const opened = fsSync.fstatSync(fd, { bigint: true });
|
|
193
193
|
if (!opened.isFile()) {
|
|
194
194
|
if (options.rejectNonFile) {
|
|
195
195
|
throw new FsSafeError("not-file", `sidecar lock is not a regular file: ${lockPath}`);
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -50,7 +50,7 @@ function resolveManagerState(key) {
|
|
|
50
50
|
function snapshotMatchesSync(lockPath, observed) {
|
|
51
51
|
let fd;
|
|
52
52
|
try {
|
|
53
|
-
const beforeStat = fsSync.lstatSync(lockPath);
|
|
53
|
+
const beforeStat = fsSync.lstatSync(lockPath, { bigint: true });
|
|
54
54
|
if (!beforeStat.isFile()) {
|
|
55
55
|
return false;
|
|
56
56
|
}
|
|
@@ -60,17 +60,17 @@ function snapshotMatchesSync(lockPath, observed) {
|
|
|
60
60
|
: 0) |
|
|
61
61
|
(typeof fsSync.constants.O_NONBLOCK === "number" ? fsSync.constants.O_NONBLOCK : 0);
|
|
62
62
|
fd = fsSync.openSync(lockPath, openFlags);
|
|
63
|
-
const openedStat = fsSync.fstatSync(fd);
|
|
63
|
+
const openedStat = fsSync.fstatSync(fd, { bigint: true });
|
|
64
64
|
// Token-owned files can have different descriptor/path identities on VirtioFS.
|
|
65
65
|
// Require a known descriptor identity without rejecting that supported drift.
|
|
66
66
|
if (!openedStat.isFile() || !sameFileIdentityForCleanup(openedStat, openedStat)) {
|
|
67
67
|
return false;
|
|
68
68
|
}
|
|
69
|
-
if (observed.raw !== undefined && openedStat.size !== Buffer.byteLength(observed.raw)) {
|
|
69
|
+
if (observed.raw !== undefined && openedStat.size !== BigInt(Buffer.byteLength(observed.raw))) {
|
|
70
70
|
return false;
|
|
71
71
|
}
|
|
72
72
|
const raw = fsSync.readFileSync(fd, "utf8");
|
|
73
|
-
const afterStat = fsSync.lstatSync(lockPath);
|
|
73
|
+
const afterStat = fsSync.lstatSync(lockPath, { bigint: true });
|
|
74
74
|
if (!afterStat.isFile() || !sameFileIdentityForCleanup(beforeStat, afterStat)) {
|
|
75
75
|
return false;
|
|
76
76
|
}
|
|
@@ -1,20 +1,13 @@
|
|
|
1
|
-
import type { StagedFileReceipt } from "./staged-file-types.js";
|
|
1
|
+
import type { PublishedFileReceipt, StagedFileCleanupReceipt, StagedFileReceipt } from "./staged-file-types.js";
|
|
2
2
|
/** Caller-captured admission evidence, never a same-target ownership heuristic. */
|
|
3
|
-
export type StagedSymlinkExpected = Readonly<{
|
|
4
|
-
dev: bigint;
|
|
5
|
-
ino: bigint;
|
|
6
|
-
uid: number;
|
|
7
|
-
gid: number;
|
|
8
|
-
ctimeNs: bigint;
|
|
3
|
+
export type StagedSymlinkExpected = Readonly<Pick<StagedFileReceipt["identity"], "dev" | "ino" | "uid" | "gid" | "ctimeNs"> & {
|
|
9
4
|
target: string;
|
|
10
5
|
}>;
|
|
11
6
|
export type StagedSymlinkReceipt = StagedFileReceipt & Readonly<{
|
|
12
7
|
target: string;
|
|
13
8
|
}>;
|
|
14
|
-
export type PublishedSymlinkReceipt = Readonly<{
|
|
15
|
-
status: "published";
|
|
9
|
+
export type PublishedSymlinkReceipt = Readonly<Pick<PublishedFileReceipt, "status" | "basename"> & {
|
|
16
10
|
staged: StagedSymlinkReceipt;
|
|
17
|
-
basename: string;
|
|
18
11
|
overwrite: false;
|
|
19
12
|
}>;
|
|
20
13
|
export type StagedSymlinkPublication = Readonly<{
|
|
@@ -25,11 +18,8 @@ export type StagedSymlinkPublication = Readonly<{
|
|
|
25
18
|
overwrite: false;
|
|
26
19
|
}>;
|
|
27
20
|
export type StagedSymlinkRemoval = "removed" | "name-absent" | "preserved";
|
|
28
|
-
export type StagedSymlinkCleanupReceipt = Readonly<{
|
|
29
|
-
temporaryBasename: string;
|
|
21
|
+
export type StagedSymlinkCleanupReceipt = Readonly<Pick<StagedFileCleanupReceipt, "temporaryBasename" | "status" | "resources"> & {
|
|
30
22
|
publication: StagedSymlinkPublication;
|
|
31
|
-
status: StagedSymlinkRemoval | "failed" | "not-needed";
|
|
32
|
-
resources: "closed" | "close-failed";
|
|
33
23
|
}>;
|
|
34
24
|
export type StagedSymlinkFailureDetails = Readonly<{
|
|
35
25
|
phase: "prepare" | "publish" | "remove-published" | "cleanup";
|
package/dist/test-hooks.d.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { FileIdentityStat } from "./file-identity.js";
|
|
|
3
3
|
export type FsSafeTestHooks = {
|
|
4
4
|
beforeWatchRegistration?: (path: string) => void | Promise<void>;
|
|
5
5
|
afterWatchRegistration?: (path: string) => void | Promise<void>;
|
|
6
|
+
afterWatchBackendOverflow?: (root: string, phase: "received" | "reconciled") => void;
|
|
6
7
|
afterWatchBackendCreated?: (root: string, emit: (batch: import("./watch-native.js").NativeWatchBatch) => void, nativeEvent?: (path: string, flags: number) => void) => void;
|
|
7
8
|
afterPreOpenLstat?: (filePath: string) => Promise<void> | void;
|
|
8
9
|
beforeOpen?: (filePath: string, flags: number) => Promise<void> | void;
|
package/dist/watch-alias.js
CHANGED
|
@@ -4,10 +4,10 @@ import { isNotFoundPathError } from "./path.js";
|
|
|
4
4
|
import { assertRootIdentityCurrent, resolvePathInRoot } from "./root-context.js";
|
|
5
5
|
import { createRootDirectoryObservationGuard, assertRootDirectoryObservationGuard } from "./root-directory-list.js";
|
|
6
6
|
import { lookupRootDirectoryEntry } from "./root-directory-entry.js";
|
|
7
|
-
import { nativeChanges, scopedChanges } from "./watch-hints.js";
|
|
7
|
+
import { excludedWatchPath, nativeChanges, scopedChanges } from "./watch-hints.js";
|
|
8
8
|
/** Resolve native spelling aliases without treating case folding as identity. */
|
|
9
9
|
export async function admittedNativeChanges(root, scopes, before, after, batch, signal, limit) {
|
|
10
|
-
if (!nativeChanges(scopes, before, batch, limit))
|
|
10
|
+
if (!nativeChanges(scopes, before, batch, limit, after))
|
|
11
11
|
return undefined;
|
|
12
12
|
const result = new Map();
|
|
13
13
|
const candidates = new Map([...before?.targets ?? [], ...after.targets, ...after.directories]);
|
|
@@ -22,6 +22,9 @@ export async function admittedNativeChanges(root, scopes, before, after, batch,
|
|
|
22
22
|
signal.throwIfAborted();
|
|
23
23
|
const name = hint.name; // nativeChanges rejected unknown or non-literal names.
|
|
24
24
|
let parent = hint.directory;
|
|
25
|
+
const relative = parent ? path.join(parent, name) : name;
|
|
26
|
+
if (excludedWatchPath(before, relative) || excludedWatchPath(after, relative))
|
|
27
|
+
continue;
|
|
25
28
|
let guard;
|
|
26
29
|
let expected = after.directories.get(parent);
|
|
27
30
|
if (!expected) {
|
|
@@ -44,6 +47,8 @@ export async function admittedNativeChanges(root, scopes, before, after, batch,
|
|
|
44
47
|
[parent, expected] = admitted;
|
|
45
48
|
}
|
|
46
49
|
const candidate = parent ? path.join(parent, name) : name;
|
|
50
|
+
if (excludedWatchPath(before, candidate) || excludedWatchPath(after, candidate))
|
|
51
|
+
continue;
|
|
47
52
|
const selected = scopedChanges(scopes, { path: candidate,
|
|
48
53
|
type: hint.event === "change" && before?.entries.get(candidate)?.startsWith("file:") ? "content" : "structural" });
|
|
49
54
|
if (selected.length) {
|
|
@@ -61,8 +66,11 @@ export async function admittedNativeChanges(root, scopes, before, after, batch,
|
|
|
61
66
|
}
|
|
62
67
|
const found = await lookupRootDirectoryEntry(root, guard, name);
|
|
63
68
|
signal.throwIfAborted();
|
|
69
|
+
// A missing unselected sibling is not evidence of lost selected detail.
|
|
70
|
+
// Previously observed selected deletions remain visible in the snapshot diff;
|
|
71
|
+
// an unseen, already-gone name has no identity proving a selected alias.
|
|
64
72
|
if (!found)
|
|
65
|
-
|
|
73
|
+
continue;
|
|
66
74
|
for (const [relative, identity] of candidates) {
|
|
67
75
|
if (!relative || (path.dirname(relative) === "." ? "" : path.dirname(relative)) !== parent)
|
|
68
76
|
continue;
|
package/dist/watch-hints.d.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import type { NativeWatchBatch } from "./watch-native.js";
|
|
2
2
|
import type { WatchSnapshot } from "./watch-scan.js";
|
|
3
3
|
import type { WatchChange, WatchScope } from "./watch-types.js";
|
|
4
|
+
export declare function excludedWatchPath(snapshot: WatchSnapshot | undefined, name: string): boolean;
|
|
4
5
|
export declare function scopedChanges(scopes: readonly WatchScope[], change: WatchChange): WatchChange[];
|
|
5
|
-
export declare function nativeChanges(scopes: readonly WatchScope[], snapshot: WatchSnapshot | undefined, batch: NativeWatchBatch, limit?: number): WatchChange[] | undefined;
|
|
6
|
+
export declare function nativeChanges(scopes: readonly WatchScope[], snapshot: WatchSnapshot | undefined, batch: NativeWatchBatch, limit?: number, after?: WatchSnapshot): WatchChange[] | undefined;
|
|
6
7
|
export declare function changedEntries(before: WatchSnapshot | undefined, after: WatchSnapshot, limit: number): WatchChange[] | undefined;
|
|
7
8
|
/** Backend names never establish authority to publish a pathname. */
|
|
8
9
|
export declare function guardedHintChanges(scopes: readonly WatchScope[], before: WatchSnapshot | undefined, after: WatchSnapshot, hints: readonly WatchChange[] | undefined, observed: readonly WatchChange[] | undefined, limit: number): WatchChange[] | undefined;
|
package/dist/watch-hints.js
CHANGED
|
@@ -5,6 +5,18 @@ function below(parent, child) {
|
|
|
5
5
|
function distance(parent, child) {
|
|
6
6
|
return (parent === "" ? child : child.slice(parent.length + 1)).split(path.sep).length;
|
|
7
7
|
}
|
|
8
|
+
export function excludedWatchPath(snapshot, name) {
|
|
9
|
+
const exclusions = snapshot?.excluded;
|
|
10
|
+
if (exclusions?.has(name))
|
|
11
|
+
return true;
|
|
12
|
+
while (name) {
|
|
13
|
+
const parent = path.dirname(name);
|
|
14
|
+
name = parent === "." ? "" : parent;
|
|
15
|
+
if (exclusions?.get(name) === "directory")
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
8
20
|
export function scopedChanges(scopes, change) {
|
|
9
21
|
const result = new Map();
|
|
10
22
|
for (const scope of scopes) {
|
|
@@ -18,7 +30,7 @@ export function scopedChanges(scopes, change) {
|
|
|
18
30
|
}
|
|
19
31
|
return [...result.values()];
|
|
20
32
|
}
|
|
21
|
-
export function nativeChanges(scopes, snapshot, batch, limit = 256) {
|
|
33
|
+
export function nativeChanges(scopes, snapshot, batch, limit = 256, after) {
|
|
22
34
|
if (batch.overflow)
|
|
23
35
|
return undefined;
|
|
24
36
|
const result = new Map();
|
|
@@ -28,6 +40,8 @@ export function nativeChanges(scopes, snapshot, batch, limit = 256) {
|
|
|
28
40
|
if (typeof name !== "string" || !name || name === "." || name === ".." || name.includes("\0") || name.includes("/") || (process.platform === "win32" && /[\\:]/.test(name)))
|
|
29
41
|
return undefined;
|
|
30
42
|
const relative = hint.directory ? path.join(hint.directory, name) : name;
|
|
43
|
+
if (excludedWatchPath(snapshot, relative) || excludedWatchPath(after, relative))
|
|
44
|
+
continue;
|
|
31
45
|
for (const change of scopedChanges(scopes, {
|
|
32
46
|
path: relative,
|
|
33
47
|
type: hint.event === "change" && snapshot?.entries.get(relative)?.startsWith("file:") ? "content" : "structural",
|
|
@@ -68,6 +82,8 @@ export function guardedHintChanges(scopes, before, after, hints, observed, limit
|
|
|
68
82
|
// A stale/misdirected inode watch may report outside names: erase its detail.
|
|
69
83
|
if (!before?.entries.has(hint.path) && !after.entries.has(hint.path) && !scopes.some(scope => scope.path === hint.path))
|
|
70
84
|
return undefined;
|
|
85
|
+
// Equal snapshots cannot exclude an intermediate change and restoration (ABA).
|
|
86
|
+
// Preserve admitted hints, including setup activity delivered after ready.
|
|
71
87
|
if (!result.has(hint.path) && result.size >= limit)
|
|
72
88
|
return undefined;
|
|
73
89
|
const prior = result.get(hint.path);
|
package/dist/watch-native.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type NativeBinding } from "./native.js";
|
|
2
2
|
import type { RootContext } from "./root-context.js";
|
|
3
3
|
import type { DirectoryIdentity } from "./watch-scan.js";
|
|
4
|
+
import type { WatchStreamPaths } from "./watch-stream.js";
|
|
4
5
|
export type NativeWatchHint = {
|
|
5
6
|
directory: string;
|
|
6
7
|
name: string;
|
|
@@ -16,6 +17,7 @@ export type NativeWatchWireBatch = {
|
|
|
16
17
|
directory: string;
|
|
17
18
|
name: string;
|
|
18
19
|
structural: boolean;
|
|
20
|
+
flags?: number;
|
|
19
21
|
}[];
|
|
20
22
|
overflow: boolean;
|
|
21
23
|
error?: string;
|
|
@@ -25,8 +27,10 @@ export declare class NativeWatchBackend {
|
|
|
25
27
|
private binding;
|
|
26
28
|
private root;
|
|
27
29
|
private id;
|
|
30
|
+
private streamPaths;
|
|
28
31
|
constructor(binding: NativeBinding, root: RootContext, callback: (batch: NativeWatchBatch) => void, limit: number, persistent: boolean);
|
|
29
32
|
add(name: string, identity: DirectoryIdentity): void;
|
|
30
33
|
testEvent(path: string, flags: number): void;
|
|
34
|
+
configure(paths: WatchStreamPaths): boolean;
|
|
31
35
|
close(): void;
|
|
32
36
|
}
|
package/dist/watch-native.js
CHANGED
|
@@ -6,7 +6,7 @@ export function watchBinding(mode) {
|
|
|
6
6
|
return;
|
|
7
7
|
const binding = getNativeBinding(); // Preserves require + missing-addon failure.
|
|
8
8
|
// Bun TSFN teardown remains unqualified; guarded native scans remain usable.
|
|
9
|
-
if (binding?.watchRegister && !process.versions.bun && !process.versions.deno && ["linux", "darwin", "win32"].includes(process.platform))
|
|
9
|
+
if (binding?.watchRegister && (process.platform !== "darwin" || binding.watchConfigure) && !process.versions.bun && !process.versions.deno && ["linux", "darwin", "win32"].includes(process.platform))
|
|
10
10
|
return binding;
|
|
11
11
|
if (mode === "events" || getFsSafeNativeConfig().mode === "require") {
|
|
12
12
|
throw new FsSafeError("helper-unavailable", "native watch events are unavailable", { details: { operation: "watch" } });
|
|
@@ -16,9 +16,11 @@ export class NativeWatchBackend {
|
|
|
16
16
|
binding;
|
|
17
17
|
root;
|
|
18
18
|
id;
|
|
19
|
+
streamPaths;
|
|
19
20
|
constructor(binding, root, callback, limit, persistent) {
|
|
20
21
|
this.binding = binding;
|
|
21
22
|
this.root = root;
|
|
23
|
+
this.streamPaths = JSON.stringify({ anchors: [root.rootReal], exclusions: [] });
|
|
22
24
|
try {
|
|
23
25
|
this.id = binding.watchRegister(root.rootReal, limit, batch => {
|
|
24
26
|
if (this.id !== undefined)
|
|
@@ -41,6 +43,21 @@ export class NativeWatchBackend {
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
testEvent(path, flags) { this.binding.watchTestEvent(this.id, path, flags); }
|
|
46
|
+
configure(paths) {
|
|
47
|
+
if (process.platform !== "darwin")
|
|
48
|
+
return false;
|
|
49
|
+
const key = JSON.stringify(paths);
|
|
50
|
+
if (this.streamPaths === key)
|
|
51
|
+
return false;
|
|
52
|
+
try {
|
|
53
|
+
this.binding.watchConfigure(this.id, paths.anchors, paths.exclusions);
|
|
54
|
+
}
|
|
55
|
+
catch (cause) {
|
|
56
|
+
throw watchError(cause);
|
|
57
|
+
}
|
|
58
|
+
this.streamPaths = key;
|
|
59
|
+
return true;
|
|
60
|
+
}
|
|
44
61
|
close() {
|
|
45
62
|
const id = this.id;
|
|
46
63
|
this.id = undefined; // Fence queued TSFN callbacks before synchronous native join.
|
package/dist/watch-scan.d.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { type RootContext } from "./root-context.js";
|
|
2
2
|
import { type RootDirectoryObservationGuard } from "./root-directory-list.js";
|
|
3
|
-
import type { WatchOptions, WatchScope } from "./watch-types.js";
|
|
3
|
+
import type { WatchEntry, WatchOptions, WatchScope } from "./watch-types.js";
|
|
4
4
|
export type DirectoryIdentity = Readonly<{
|
|
5
5
|
dev: bigint;
|
|
6
6
|
ino: bigint;
|
|
7
7
|
}>;
|
|
8
8
|
export type WatchSnapshot = {
|
|
9
9
|
entries: Map<string, string>;
|
|
10
|
+
excluded?: Map<string, WatchEntry["kind"]>;
|
|
11
|
+
excludedDirectories?: Map<string, string>;
|
|
12
|
+
directoryPaths?: Map<string, string>;
|
|
10
13
|
directories: Map<string, DirectoryIdentity>;
|
|
11
14
|
targets: Map<string, DirectoryIdentity>;
|
|
12
15
|
scanned: number;
|
|
@@ -21,4 +24,5 @@ export declare function scanWatch(root: RootContext, scopes: readonly WatchScope
|
|
|
21
24
|
maxDirectories: number;
|
|
22
25
|
maxPendingPaths: number;
|
|
23
26
|
admitting: boolean;
|
|
27
|
+
previous?: WatchSnapshot;
|
|
24
28
|
}, signal: AbortSignal, register: (name: string, identity: DirectoryIdentity, guard: RootDirectoryObservationGuard) => Promise<void>, onCleanupFailure?: (error: unknown) => void): Promise<WatchSnapshot>;
|
package/dist/watch-scan.js
CHANGED
|
@@ -55,7 +55,9 @@ export function isWatchPathError(error) {
|
|
|
55
55
|
return ["ENOTDIR", "EACCES", "EPERM", "EBUSY", "ESTALE", "EIO", "ELOOP"].includes(error?.code ?? "");
|
|
56
56
|
}
|
|
57
57
|
export async function scanWatch(root, scopes, options, signal, register, onCleanupFailure) {
|
|
58
|
-
const
|
|
58
|
+
const exclusions = new Map();
|
|
59
|
+
const excludedDirectories = new Map(), directoryPaths = new Map();
|
|
60
|
+
const result = { entries: new Map(), excluded: exclusions, excludedDirectories, directoryPaths, directories: new Map(), targets: new Map(), scanned: 0 };
|
|
59
61
|
const attempts = new Map();
|
|
60
62
|
const guards = new Map();
|
|
61
63
|
const walked = new Map();
|
|
@@ -68,7 +70,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
|
|
|
68
70
|
result.overflow = true;
|
|
69
71
|
// Discard partially observed names when their enclosing directory lost admission.
|
|
70
72
|
const below = (name) => name === relative || !relative || name.startsWith(relative + path.sep);
|
|
71
|
-
for (const map of [result.entries, result.targets, result.directories, guards, walked]) {
|
|
73
|
+
for (const map of [result.entries, exclusions, excludedDirectories, directoryPaths, result.targets, result.directories, guards, walked]) {
|
|
72
74
|
for (const name of map.keys())
|
|
73
75
|
if (below(name))
|
|
74
76
|
map.delete(name);
|
|
@@ -86,7 +88,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
|
|
|
86
88
|
if (++result.scanned > options.maxEntries)
|
|
87
89
|
throw new FsSafeError("too-large", "watch entry budget exceeded", { details: { operation: "scan" } });
|
|
88
90
|
};
|
|
89
|
-
const excluded = (name, entry) => {
|
|
91
|
+
const excluded = async (name, entry) => {
|
|
90
92
|
let value;
|
|
91
93
|
try {
|
|
92
94
|
value = options.exclude?.({ path: name, kind: kind(entry) });
|
|
@@ -96,6 +98,23 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
|
|
|
96
98
|
throw new FsSafeError("helper-failed", "watch exclusion callback failed", { cause, details: { operation: "callback" } });
|
|
97
99
|
}
|
|
98
100
|
signal.throwIfAborted();
|
|
101
|
+
if (value) {
|
|
102
|
+
exclusions.set(name, kind(entry));
|
|
103
|
+
if (process.platform === "darwin" && entry.isDirectory && !entry.isSymbolicLink) {
|
|
104
|
+
try {
|
|
105
|
+
const resolved = await resolvePathInRoot(root, "./" + name, { rejectSymlinks: true });
|
|
106
|
+
const guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
|
|
107
|
+
await assertRootDirectoryObservationGuard(root, guard);
|
|
108
|
+
excludedDirectories.set(name, guard.realPath);
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
signal.throwIfAborted();
|
|
112
|
+
await assertRootIdentityCurrent(root);
|
|
113
|
+
if (!isWatchPathError(error))
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
99
118
|
return value;
|
|
100
119
|
};
|
|
101
120
|
const directory = async (relative) => {
|
|
@@ -121,6 +140,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
|
|
|
121
140
|
const guard = await createRootDirectoryObservationGuard(root, resolved.resolved);
|
|
122
141
|
const identity = { dev: guard.stat.dev, ino: guard.stat.ino };
|
|
123
142
|
result.directories.set(relative, identity);
|
|
143
|
+
directoryPaths.set(relative, guard.realPath);
|
|
124
144
|
await register(relative, identity, guard);
|
|
125
145
|
signal.throwIfAborted();
|
|
126
146
|
await assertRootDirectoryObservationGuard(root, guard);
|
|
@@ -148,7 +168,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
|
|
|
148
168
|
try {
|
|
149
169
|
guard = await directory(relative);
|
|
150
170
|
listing = await openRootDirectoryListing(root, guard.realPath, {
|
|
151
|
-
order: "filesystem", snapshot: false, signal, exactIdentity: true, onCleanupFailure, admitEntry: () => { examined(); return true; },
|
|
171
|
+
order: "filesystem", snapshot: false, signal, exactIdentity: true, skipVanished: true, onCleanupFailure, admitEntry: () => { examined(); return true; },
|
|
152
172
|
});
|
|
153
173
|
// The listing has its own guard. Both identities must agree before reading names.
|
|
154
174
|
await assertRootDirectoryObservationGuard(root, guard);
|
|
@@ -176,7 +196,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
|
|
|
176
196
|
throw new FsSafeError("path-mismatch", "watch listing lacks exact identity");
|
|
177
197
|
const entry = next.entry;
|
|
178
198
|
const name = relative ? path.join(relative, entry.name) : entry.name;
|
|
179
|
-
if (excluded(name, entry))
|
|
199
|
+
if (await excluded(name, entry))
|
|
180
200
|
continue;
|
|
181
201
|
result.entries.set(name, fingerprint(entry, next.identity));
|
|
182
202
|
if (entry.isDirectory && !entry.isSymbolicLink && depth > 1)
|
|
@@ -229,7 +249,7 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
|
|
|
229
249
|
if (!found)
|
|
230
250
|
break;
|
|
231
251
|
const name = relative ? path.join(relative, segments[i]) : segments[i];
|
|
232
|
-
if (excluded(name, found.entry))
|
|
252
|
+
if (await excluded(name, found.entry))
|
|
233
253
|
break;
|
|
234
254
|
if (i === segments.length - 1) {
|
|
235
255
|
result.entries.set(name, fingerprint(found.entry, found.identity));
|
|
@@ -265,5 +285,16 @@ export async function scanWatch(root, scopes, options, signal, register, onClean
|
|
|
265
285
|
}
|
|
266
286
|
await assertRootIdentityCurrent(root);
|
|
267
287
|
signal.throwIfAborted();
|
|
288
|
+
// Native deletion hints can arrive after a later scan. Keep bounded tombstones
|
|
289
|
+
// until the name is admitted again; they never grant authority to publish it.
|
|
290
|
+
for (const [name, kind] of options.previous?.excluded ?? []) {
|
|
291
|
+
if (exclusions.size >= options.maxEntries)
|
|
292
|
+
break;
|
|
293
|
+
if (!result.entries.has(name) && !result.directories.has(name) && !exclusions.has(name))
|
|
294
|
+
exclusions.set(name, kind);
|
|
295
|
+
const priorPath = options.previous?.excludedDirectories?.get(name);
|
|
296
|
+
if (kind === "directory" && exclusions.get(name) === "directory" && priorPath && !excludedDirectories.has(name))
|
|
297
|
+
excludedDirectories.set(name, priorPath);
|
|
298
|
+
}
|
|
268
299
|
return result;
|
|
269
300
|
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { WatchSnapshot } from "./watch-scan.js";
|
|
2
|
+
import type { WatchScope } from "./watch-types.js";
|
|
3
|
+
export type WatchStreamPaths = {
|
|
4
|
+
anchors: string[];
|
|
5
|
+
exclusions: string[];
|
|
6
|
+
};
|
|
7
|
+
/** Canonical names come only from guarded directory observations, never backend hints. */
|
|
8
|
+
export declare function watchStreamPaths(snapshot: WatchSnapshot, scopes: readonly WatchScope[]): WatchStreamPaths;
|