@openclaw/fs-safe 0.4.2 → 0.4.4
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 +12 -0
- package/README.md +4 -3
- package/dist/regular-file.d.ts.map +1 -1
- package/dist/regular-file.js +39 -10
- package/dist/sidecar-lock-reclaim.d.ts +26 -0
- package/dist/sidecar-lock-reclaim.d.ts.map +1 -0
- package/dist/sidecar-lock-reclaim.js +103 -0
- package/dist/sidecar-lock.d.ts +3 -11
- package/dist/sidecar-lock.d.ts.map +1 -1
- package/dist/sidecar-lock.js +161 -133
- package/docs/config.md +2 -2
- package/docs/json-store.md +2 -2
- package/docs/sidecar-lock.md +13 -9
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,17 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.4 - 2026-07-18
|
|
4
|
+
|
|
5
|
+
### Security and Correctness
|
|
6
|
+
|
|
7
|
+
- Restore opt-in stale sidecar recovery with an exclusive reclaim guard that serializes snapshot verification and unlink, preserving dead-owner recovery without allowing a competing reclaimer to delete a fresh replacement lock.
|
|
8
|
+
|
|
9
|
+
## 0.4.3 - 2026-07-18
|
|
10
|
+
|
|
11
|
+
### Compatibility
|
|
12
|
+
|
|
13
|
+
- Restore the pre-0.4.2 `readRegularFile` and `readRegularFileSync` overflow error message while retaining allocation-bounded reads and the structured `too-large` error on the new low-level bounded-read primitives.
|
|
14
|
+
|
|
3
15
|
## 0.4.2 - 2026-07-18
|
|
4
16
|
|
|
5
17
|
### Features
|
package/README.md
CHANGED
|
@@ -283,9 +283,10 @@ the common merge-into-defaults case. Standalone helpers use options bags
|
|
|
283
283
|
because they do not carry a bound root and often need multiple authority, path,
|
|
284
284
|
and policy knobs.
|
|
285
285
|
|
|
286
|
-
Sidecar locks fail closed on stale holders.
|
|
287
|
-
|
|
288
|
-
|
|
286
|
+
Sidecar locks fail closed on stale holders by default. Opt-in `remove-if-unchanged`
|
|
287
|
+
recovery requires caller approval and serializes snapshot verification and unlink
|
|
288
|
+
with an exclusive reclaim guard so a replacement lock cannot be deleted; see the
|
|
289
|
+
[file lock docs](docs/sidecar-lock.md).
|
|
289
290
|
|
|
290
291
|
Use `fileStore()` for cache/blob/media-style directories where callers
|
|
291
292
|
need safe relative paths, size limits, atomic replacement, stream writes, and
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;AAW7B,MAAM,MAAM,qBAAqB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC;AAExF,KAAK,8BAA8B,GAAG,IAAI,CACxC,OAAO,MAAM,CAAC,SAAS,EACvB,UAAU,GAAG,SAAS,GAAG,UAAU,CACpC,GACC,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAEvD,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC,CAAC;AAEF,wBAAgB,6BAA6B,CAC3C,SAAS,GAAE,8BAAiD,GAC3D,MAAM,CAQR;
|
|
1
|
+
{"version":3,"file":"regular-file.d.ts","sourceRoot":"","sources":["../src/regular-file.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,MAAM,MAAM,SAAS,CAAC;AAW7B,MAAM,MAAM,qBAAqB,GAAG;IAAE,OAAO,EAAE,IAAI,CAAA;CAAE,GAAG;IAAE,OAAO,EAAE,KAAK,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC;AAExF,KAAK,8BAA8B,GAAG,IAAI,CACxC,OAAO,MAAM,CAAC,SAAS,EACvB,UAAU,GAAG,SAAS,GAAG,UAAU,CACpC,GACC,OAAO,CAAC,IAAI,CAAC,OAAO,MAAM,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC,CAAC;AAEvD,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,OAAO,EAAE,MAAM,GAAG,UAAU,CAAC;IAC7B,QAAQ,CAAC,EAAE,cAAc,CAAC;IAC1B,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,oBAAoB,CAAC,EAAE,OAAO,CAAC;CAChC,CAAC;AAEF,wBAAgB,6BAA6B,CAC3C,SAAS,GAAE,8BAAiD,GAC3D,MAAM,CAQR;AAsBD,wBAAsB,eAAe,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,qBAAqB,CAAC,CActF;AAED,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,qBAAqB,CAc3E;AAED,wBAAsB,eAAe,CAAC,MAAM,EAAE;IAC5C,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB,GAAG,OAAO,CAAC;IAAE,MAAM,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,KAAK,CAAA;CAAE,CAAC,CAyD3C;AAoDD,wBAAgB,mBAAmB,CAAC,MAAM,EAAE;IAAE,QAAQ,EAAE,MAAM,CAAC;IAAC,QAAQ,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG;IACpF,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,KAAK,CAAC;CACb,CAqBA;AAmBD,wBAAsB,iBAAiB,CAAC,OAAO,EAAE,wBAAwB,GAAG,OAAO,CAAC,IAAI,CAAC,CAuDxF;AAED,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,wBAAwB,GAAG,IAAI,CA2D7E"}
|
package/dist/regular-file.js
CHANGED
|
@@ -20,6 +20,15 @@ function resolveRegularFileReadFlags() {
|
|
|
20
20
|
? fsSync.constants.O_NOFOLLOW
|
|
21
21
|
: 0));
|
|
22
22
|
}
|
|
23
|
+
function regularFileTooLargeError(filePath, maxBytes, cause) {
|
|
24
|
+
return new FsSafeError("too-large", `File exceeds ${maxBytes} bytes: ${filePath}`, { cause });
|
|
25
|
+
}
|
|
26
|
+
function translateBoundedReadOverflow(error, filePath, maxBytes) {
|
|
27
|
+
if (error instanceof FsSafeError && error.code === "too-large") {
|
|
28
|
+
throw regularFileTooLargeError(filePath, maxBytes, error);
|
|
29
|
+
}
|
|
30
|
+
throw error;
|
|
31
|
+
}
|
|
23
32
|
export async function statRegularFile(filePath) {
|
|
24
33
|
let stat;
|
|
25
34
|
try {
|
|
@@ -59,7 +68,7 @@ export async function readRegularFile(params) {
|
|
|
59
68
|
throw Object.assign(new Error(`File not found: ${params.filePath}`), { code: "ENOENT" });
|
|
60
69
|
}
|
|
61
70
|
if (params.maxBytes !== undefined && result.stat.size > params.maxBytes) {
|
|
62
|
-
throw
|
|
71
|
+
throw regularFileTooLargeError(params.filePath, params.maxBytes);
|
|
63
72
|
}
|
|
64
73
|
let handle;
|
|
65
74
|
try {
|
|
@@ -90,13 +99,23 @@ export async function readRegularFile(params) {
|
|
|
90
99
|
preOpenStat: result.stat,
|
|
91
100
|
});
|
|
92
101
|
if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
|
|
93
|
-
throw
|
|
102
|
+
throw regularFileTooLargeError(params.filePath, params.maxBytes);
|
|
94
103
|
}
|
|
95
104
|
// With a byte cap, avoid readFile(): a raced file growth would allocate
|
|
96
105
|
// the oversized content before the post-read check could reject it.
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
106
|
+
let buffer;
|
|
107
|
+
try {
|
|
108
|
+
buffer =
|
|
109
|
+
params.maxBytes === undefined
|
|
110
|
+
? await handle.readFile()
|
|
111
|
+
: await readFileHandleBounded(handle, params.maxBytes);
|
|
112
|
+
}
|
|
113
|
+
catch (error) {
|
|
114
|
+
if (params.maxBytes !== undefined) {
|
|
115
|
+
translateBoundedReadOverflow(error, params.filePath, params.maxBytes);
|
|
116
|
+
}
|
|
117
|
+
throw error;
|
|
118
|
+
}
|
|
100
119
|
return { buffer, stat };
|
|
101
120
|
}
|
|
102
121
|
finally {
|
|
@@ -121,13 +140,23 @@ function readOpenedRegularFileSync(params) {
|
|
|
121
140
|
preOpenStat: params.preOpenStat,
|
|
122
141
|
});
|
|
123
142
|
if (params.maxBytes !== undefined && stat.size > params.maxBytes) {
|
|
124
|
-
throw
|
|
143
|
+
throw regularFileTooLargeError(params.filePath, params.maxBytes);
|
|
125
144
|
}
|
|
126
145
|
// Keep capped sync reads incremental for the same reason as async reads:
|
|
127
146
|
// readFileSync(fd) would buffer a raced oversized file before throwing.
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
147
|
+
let buffer;
|
|
148
|
+
try {
|
|
149
|
+
buffer =
|
|
150
|
+
params.maxBytes === undefined
|
|
151
|
+
? fsSync.readFileSync(params.fd)
|
|
152
|
+
: readFileDescriptorBoundedSync(params.fd, params.maxBytes);
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
if (params.maxBytes !== undefined) {
|
|
156
|
+
translateBoundedReadOverflow(error, params.filePath, params.maxBytes);
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
131
160
|
return { buffer, stat };
|
|
132
161
|
}
|
|
133
162
|
export function readRegularFileSync(params) {
|
|
@@ -137,7 +166,7 @@ export function readRegularFileSync(params) {
|
|
|
137
166
|
throw Object.assign(new Error(`File not found: ${params.filePath}`), { code: "ENOENT" });
|
|
138
167
|
}
|
|
139
168
|
if (params.maxBytes !== undefined && result.stat.size > params.maxBytes) {
|
|
140
|
-
throw
|
|
169
|
+
throw regularFileTooLargeError(params.filePath, params.maxBytes);
|
|
141
170
|
}
|
|
142
171
|
const fd = fsSync.openSync(params.filePath, resolveRegularFileReadFlags());
|
|
143
172
|
try {
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { Stats } from "node:fs";
|
|
2
|
+
export type SidecarLockStaleSnapshot = {
|
|
3
|
+
lockPath: string;
|
|
4
|
+
normalizedTargetPath: string;
|
|
5
|
+
raw: string;
|
|
6
|
+
payload: Record<string, unknown> | null;
|
|
7
|
+
};
|
|
8
|
+
export type SidecarLockSnapshot = {
|
|
9
|
+
raw?: string;
|
|
10
|
+
payload: Record<string, unknown> | null;
|
|
11
|
+
stat?: Stats;
|
|
12
|
+
};
|
|
13
|
+
export declare function readSidecarLockSnapshot(lockPath: string): Promise<SidecarLockSnapshot | null>;
|
|
14
|
+
export declare function sidecarLockSnapshotMatches(current: SidecarLockSnapshot, observed: SidecarLockSnapshot): boolean;
|
|
15
|
+
export declare function removeSidecarLockIfUnchanged(lockPath: string, observed: SidecarLockSnapshot | null): Promise<boolean>;
|
|
16
|
+
export declare function sidecarLockSnapshotStillPresent(lockPath: string, observed: SidecarLockSnapshot | null): Promise<boolean>;
|
|
17
|
+
export declare function sidecarReclaimGuardExists(pathname: string): Promise<boolean>;
|
|
18
|
+
export declare function tryAcquireSidecarReclaimGuard(reclaimGuards: Set<string>, reclaimGuardPath: string): Promise<boolean>;
|
|
19
|
+
export declare function releaseSidecarReclaimGuard(reclaimGuards: Set<string>, reclaimGuardPath: string): Promise<void>;
|
|
20
|
+
export declare function removeStaleSidecarLockIfAllowed(params: {
|
|
21
|
+
lockPath: string;
|
|
22
|
+
normalizedTargetPath: string;
|
|
23
|
+
snapshot: SidecarLockSnapshot;
|
|
24
|
+
shouldRemoveStaleLock?: (snapshot: SidecarLockStaleSnapshot) => boolean | Promise<boolean>;
|
|
25
|
+
}): Promise<"removed" | "changed" | "not-approved">;
|
|
26
|
+
//# sourceMappingURL=sidecar-lock-reclaim.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sidecar-lock-reclaim.d.ts","sourceRoot":"","sources":["../src/sidecar-lock-reclaim.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,SAAS,CAAC;AAIrC,MAAM,MAAM,wBAAwB,GAAG;IACrC,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;CACzC,CAAC;AAEF,MAAM,MAAM,mBAAmB,GAAG;IAChC,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,IAAI,CAAC;IACxC,IAAI,CAAC,EAAE,KAAK,CAAC;CACd,CAAC;AAEF,wBAAsB,uBAAuB,CAC3C,QAAQ,EAAE,MAAM,GACf,OAAO,CAAC,mBAAmB,GAAG,IAAI,CAAC,CAoBrC;AAED,wBAAgB,0BAA0B,CACxC,OAAO,EAAE,mBAAmB,EAC5B,QAAQ,EAAE,mBAAmB,GAC5B,OAAO,CAQT;AAED,wBAAsB,4BAA4B,CAChD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAOlB;AAED,wBAAsB,+BAA+B,CACnD,QAAQ,EAAE,MAAM,EAChB,QAAQ,EAAE,mBAAmB,GAAG,IAAI,GACnC,OAAO,CAAC,OAAO,CAAC,CAGlB;AAED,wBAAsB,yBAAyB,CAAC,QAAQ,EAAE,MAAM,GAAG,OAAO,CAAC,OAAO,CAAC,CAUlF;AAED,wBAAsB,6BAA6B,CACjD,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED,wBAAsB,0BAA0B,CAC9C,aAAa,EAAE,GAAG,CAAC,MAAM,CAAC,EAC1B,gBAAgB,EAAE,MAAM,GACvB,OAAO,CAAC,IAAI,CAAC,CAGf;AAED,wBAAsB,+BAA+B,CAAC,MAAM,EAAE;IAC5D,QAAQ,EAAE,MAAM,CAAC;IACjB,oBAAoB,EAAE,MAAM,CAAC;IAC7B,QAAQ,EAAE,mBAAmB,CAAC;IAC9B,qBAAqB,CAAC,EAAE,CAAC,QAAQ,EAAE,wBAAwB,KAAK,OAAO,GAAG,OAAO,CAAC,OAAO,CAAC,CAAC;CAC5F,GAAG,OAAO,CAAC,SAAS,GAAG,SAAS,GAAG,cAAc,CAAC,CA6BlD"}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import { sameFileIdentity } from "./file-identity.js";
|
|
3
|
+
export async function readSidecarLockSnapshot(lockPath) {
|
|
4
|
+
try {
|
|
5
|
+
const stat = await fs.lstat(lockPath);
|
|
6
|
+
const raw = await fs.readFile(lockPath, "utf8");
|
|
7
|
+
try {
|
|
8
|
+
const parsed = JSON.parse(raw);
|
|
9
|
+
const payload = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
10
|
+
? parsed
|
|
11
|
+
: null;
|
|
12
|
+
return { raw, payload, stat };
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return { raw, payload: null, stat };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
catch (err) {
|
|
19
|
+
if (err.code === "ENOENT") {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
throw err;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
export function sidecarLockSnapshotMatches(current, observed) {
|
|
26
|
+
if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
if (observed.raw !== undefined) {
|
|
30
|
+
return current.raw === observed.raw;
|
|
31
|
+
}
|
|
32
|
+
return observed.stat !== undefined && current.stat !== undefined;
|
|
33
|
+
}
|
|
34
|
+
export async function removeSidecarLockIfUnchanged(lockPath, observed) {
|
|
35
|
+
const current = await readSidecarLockSnapshot(lockPath);
|
|
36
|
+
if (!current || !observed || !sidecarLockSnapshotMatches(current, observed)) {
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
40
|
+
return true;
|
|
41
|
+
}
|
|
42
|
+
export async function sidecarLockSnapshotStillPresent(lockPath, observed) {
|
|
43
|
+
const current = await readSidecarLockSnapshot(lockPath);
|
|
44
|
+
return !!current && !!observed && sidecarLockSnapshotMatches(current, observed);
|
|
45
|
+
}
|
|
46
|
+
export async function sidecarReclaimGuardExists(pathname) {
|
|
47
|
+
try {
|
|
48
|
+
await fs.lstat(pathname);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
if (err.code === "ENOENT") {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
throw err;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
export async function tryAcquireSidecarReclaimGuard(reclaimGuards, reclaimGuardPath) {
|
|
59
|
+
try {
|
|
60
|
+
await fs.mkdir(reclaimGuardPath);
|
|
61
|
+
reclaimGuards.add(reclaimGuardPath);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch (err) {
|
|
65
|
+
if (err.code === "EEXIST") {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
throw err;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
export async function releaseSidecarReclaimGuard(reclaimGuards, reclaimGuardPath) {
|
|
72
|
+
await fs.rmdir(reclaimGuardPath);
|
|
73
|
+
reclaimGuards.delete(reclaimGuardPath);
|
|
74
|
+
}
|
|
75
|
+
export async function removeStaleSidecarLockIfAllowed(params) {
|
|
76
|
+
if (!params.shouldRemoveStaleLock || params.snapshot.raw === undefined) {
|
|
77
|
+
return "not-approved";
|
|
78
|
+
}
|
|
79
|
+
if (!(await sidecarLockSnapshotStillPresent(params.lockPath, params.snapshot))) {
|
|
80
|
+
return "changed";
|
|
81
|
+
}
|
|
82
|
+
if (!(await params.shouldRemoveStaleLock({
|
|
83
|
+
lockPath: params.lockPath,
|
|
84
|
+
normalizedTargetPath: params.normalizedTargetPath,
|
|
85
|
+
raw: params.snapshot.raw,
|
|
86
|
+
payload: params.snapshot.payload,
|
|
87
|
+
}))) {
|
|
88
|
+
return "not-approved";
|
|
89
|
+
}
|
|
90
|
+
if (!(await sidecarLockSnapshotStillPresent(params.lockPath, params.snapshot))) {
|
|
91
|
+
return "changed";
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
await fs.rm(params.lockPath);
|
|
95
|
+
return "removed";
|
|
96
|
+
}
|
|
97
|
+
catch (err) {
|
|
98
|
+
if (err.code === "ENOENT") {
|
|
99
|
+
return "changed";
|
|
100
|
+
}
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
}
|
package/dist/sidecar-lock.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { type SidecarLockStaleSnapshot } from "./sidecar-lock-reclaim.js";
|
|
2
|
+
export type { SidecarLockStaleSnapshot } from "./sidecar-lock-reclaim.js";
|
|
1
3
|
export type SidecarLockRetryOptions = {
|
|
2
4
|
retries?: number;
|
|
3
5
|
factor?: number;
|
|
@@ -5,16 +7,7 @@ export type SidecarLockRetryOptions = {
|
|
|
5
7
|
maxTimeout?: number;
|
|
6
8
|
randomize?: boolean;
|
|
7
9
|
};
|
|
8
|
-
export type SidecarLockStaleRecovery = "fail-closed"
|
|
9
|
-
/** @deprecated Stale locks now always fail closed. */
|
|
10
|
-
| "remove-if-unchanged";
|
|
11
|
-
/** @deprecated Stale-removal callbacks are retained for source compatibility but are not invoked. */
|
|
12
|
-
export type SidecarLockStaleSnapshot = {
|
|
13
|
-
lockPath: string;
|
|
14
|
-
normalizedTargetPath: string;
|
|
15
|
-
raw: string;
|
|
16
|
-
payload: Record<string, unknown> | null;
|
|
17
|
-
};
|
|
10
|
+
export type SidecarLockStaleRecovery = "fail-closed" | "remove-if-unchanged";
|
|
18
11
|
export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
19
12
|
targetPath: string;
|
|
20
13
|
lockPath?: string;
|
|
@@ -32,7 +25,6 @@ export type SidecarLockAcquireOptions<TPayload extends Record<string, unknown>>
|
|
|
32
25
|
nowMs: number;
|
|
33
26
|
heldByThisProcess: boolean;
|
|
34
27
|
}) => boolean | Promise<boolean>;
|
|
35
|
-
/** @deprecated Stale locks now always fail closed; this callback is not invoked. */
|
|
36
28
|
shouldRemoveStaleLock?: (snapshot: SidecarLockStaleSnapshot) => boolean | Promise<boolean>;
|
|
37
29
|
metadata?: Record<string, unknown>;
|
|
38
30
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sidecar-lock.d.ts","sourceRoot":"","sources":["../src/sidecar-lock.ts"],"names":[],"mappings":"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"}
|
package/dist/sidecar-lock.js
CHANGED
|
@@ -2,6 +2,7 @@ import fsSync from "node:fs";
|
|
|
2
2
|
import fs from "node:fs/promises";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { sameFileIdentity } from "./file-identity.js";
|
|
5
|
+
import { readSidecarLockSnapshot, releaseSidecarReclaimGuard, removeSidecarLockIfUnchanged, removeStaleSidecarLockIfAllowed, sidecarLockSnapshotStillPresent, sidecarReclaimGuardExists, tryAcquireSidecarReclaimGuard, } from "./sidecar-lock-reclaim.js";
|
|
5
6
|
const GLOBAL_STATE_KEY = Symbol.for("fsSafe.sidecarLockManagers");
|
|
6
7
|
function getGlobalManagers() {
|
|
7
8
|
const globalWithState = globalThis;
|
|
@@ -14,58 +15,21 @@ function resolveManagerState(key) {
|
|
|
14
15
|
const managers = getGlobalManagers();
|
|
15
16
|
let state = managers.get(key);
|
|
16
17
|
if (!state) {
|
|
17
|
-
state = {
|
|
18
|
+
state = {
|
|
19
|
+
cleanupRegistered: false,
|
|
20
|
+
held: new Map(),
|
|
21
|
+
reclaimCleanupRegistered: false,
|
|
22
|
+
reclaimGuards: new Set(),
|
|
23
|
+
};
|
|
18
24
|
managers.set(key, state);
|
|
19
25
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const raw = await fs.readFile(lockPath, "utf8");
|
|
26
|
-
try {
|
|
27
|
-
const parsed = JSON.parse(raw);
|
|
28
|
-
const payload = parsed && typeof parsed === "object" && !Array.isArray(parsed)
|
|
29
|
-
? parsed
|
|
30
|
-
: null;
|
|
31
|
-
return { raw, payload, stat };
|
|
32
|
-
}
|
|
33
|
-
catch {
|
|
34
|
-
return { raw, payload: null, stat };
|
|
35
|
-
}
|
|
36
|
-
}
|
|
37
|
-
catch (err) {
|
|
38
|
-
if (err.code === "ENOENT") {
|
|
39
|
-
return null;
|
|
40
|
-
}
|
|
41
|
-
throw err;
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
function snapshotMatches(current, observed) {
|
|
45
|
-
if (observed.stat && current.stat && !sameFileIdentity(observed.stat, current.stat)) {
|
|
46
|
-
return false;
|
|
47
|
-
}
|
|
48
|
-
if (observed.raw !== undefined) {
|
|
49
|
-
return current.raw === observed.raw;
|
|
50
|
-
}
|
|
51
|
-
return observed.stat !== undefined && current.stat !== undefined;
|
|
52
|
-
}
|
|
53
|
-
async function removeLockIfUnchanged(lockPath, observed) {
|
|
54
|
-
const current = await readLockSnapshot(lockPath);
|
|
55
|
-
if (!current || !observed) {
|
|
56
|
-
return false;
|
|
57
|
-
}
|
|
58
|
-
if (!snapshotMatches(current, observed)) {
|
|
59
|
-
// The lock changed after we decided it was stale. Leave the fresh holder's
|
|
60
|
-
// file alone; deleting by path here would break mutual exclusion.
|
|
61
|
-
return false;
|
|
26
|
+
else {
|
|
27
|
+
// The global manager symbol is shared across package copies and hot reloads.
|
|
28
|
+
// Backfill state created by fs-safe versions that predate reclaim guards.
|
|
29
|
+
state.reclaimCleanupRegistered ??= false;
|
|
30
|
+
state.reclaimGuards ??= new Set();
|
|
62
31
|
}
|
|
63
|
-
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
async function lockSnapshotStillPresent(lockPath, observed) {
|
|
67
|
-
const current = await readLockSnapshot(lockPath);
|
|
68
|
-
return !!current && !!observed && snapshotMatches(current, observed);
|
|
32
|
+
return state;
|
|
69
33
|
}
|
|
70
34
|
function snapshotMatchesSync(lockPath, observed) {
|
|
71
35
|
try {
|
|
@@ -112,6 +76,17 @@ async function defaultShouldReclaim(params) {
|
|
|
112
76
|
return true;
|
|
113
77
|
}
|
|
114
78
|
}
|
|
79
|
+
function releaseAllReclaimGuardsSync(state) {
|
|
80
|
+
for (const reclaimGuardPath of state.reclaimGuards) {
|
|
81
|
+
try {
|
|
82
|
+
fsSync.rmdirSync(reclaimGuardPath);
|
|
83
|
+
state.reclaimGuards.delete(reclaimGuardPath);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Best-effort process-exit cleanup. A surviving guard fails closed.
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
}
|
|
115
90
|
function releaseAllLocksSync(state) {
|
|
116
91
|
for (const [normalizedTargetPath, held] of state.held) {
|
|
117
92
|
void held.handle.close().catch(() => undefined);
|
|
@@ -125,6 +100,7 @@ function releaseAllLocksSync(state) {
|
|
|
125
100
|
}
|
|
126
101
|
state.held.delete(normalizedTargetPath);
|
|
127
102
|
}
|
|
103
|
+
releaseAllReclaimGuardsSync(state);
|
|
128
104
|
}
|
|
129
105
|
async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
|
|
130
106
|
const current = state.held.get(normalizedTargetPath);
|
|
@@ -147,7 +123,7 @@ async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
|
|
|
147
123
|
state.held.delete(normalizedTargetPath);
|
|
148
124
|
held.releasePromise = (async () => {
|
|
149
125
|
await held.handle.close().catch(() => undefined);
|
|
150
|
-
await
|
|
126
|
+
await removeSidecarLockIfUnchanged(held.lockPath, held.snapshot);
|
|
151
127
|
})();
|
|
152
128
|
try {
|
|
153
129
|
await held.releasePromise;
|
|
@@ -160,11 +136,16 @@ async function releaseHeldLock(state, normalizedTargetPath, held, opts = {}) {
|
|
|
160
136
|
export function createSidecarLockManager(key) {
|
|
161
137
|
const state = resolveManagerState(key);
|
|
162
138
|
function ensureExitCleanupRegistered() {
|
|
163
|
-
if (state.cleanupRegistered) {
|
|
139
|
+
if (!state.cleanupRegistered) {
|
|
140
|
+
state.cleanupRegistered = true;
|
|
141
|
+
state.reclaimCleanupRegistered = true;
|
|
142
|
+
process.on("exit", () => releaseAllLocksSync(state));
|
|
164
143
|
return;
|
|
165
144
|
}
|
|
166
|
-
state.
|
|
167
|
-
|
|
145
|
+
if (!state.reclaimCleanupRegistered) {
|
|
146
|
+
state.reclaimCleanupRegistered = true;
|
|
147
|
+
process.on("exit", () => releaseAllReclaimGuardsSync(state));
|
|
148
|
+
}
|
|
168
149
|
}
|
|
169
150
|
async function acquire(options) {
|
|
170
151
|
ensureExitCleanupRegistered();
|
|
@@ -184,99 +165,146 @@ export function createSidecarLockManager(key) {
|
|
|
184
165
|
const startedAt = Date.now();
|
|
185
166
|
const retry = options.retry ?? {};
|
|
186
167
|
const maxRetries = options.timeoutMs === Number.POSITIVE_INFINITY ? undefined : retry.retries;
|
|
168
|
+
const reclaimGuardPath = `${lockPath}.reclaim`;
|
|
169
|
+
let ownsReclaimGuard = false;
|
|
187
170
|
let attempt = 0;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
const createdHeld = {
|
|
197
|
-
count: 1,
|
|
198
|
-
handle,
|
|
199
|
-
lockPath,
|
|
200
|
-
snapshot,
|
|
201
|
-
acquiredAt: Date.now(),
|
|
202
|
-
metadata: options.metadata ?? {},
|
|
203
|
-
};
|
|
204
|
-
state.held.set(normalizedTargetPath, createdHeld);
|
|
205
|
-
const release = () => releaseHeldLock(state, normalizedTargetPath, createdHeld).then(() => undefined);
|
|
206
|
-
return {
|
|
171
|
+
const waitForRetry = async () => {
|
|
172
|
+
const elapsed = Date.now() - startedAt;
|
|
173
|
+
if ((options.timeoutMs !== undefined &&
|
|
174
|
+
options.timeoutMs !== Number.POSITIVE_INFINITY &&
|
|
175
|
+
elapsed >= options.timeoutMs) ||
|
|
176
|
+
(maxRetries !== undefined && attempt >= maxRetries)) {
|
|
177
|
+
throw Object.assign(new Error(`file lock timeout for ${normalizedTargetPath}`), {
|
|
178
|
+
code: "file_lock_timeout",
|
|
207
179
|
lockPath,
|
|
208
180
|
normalizedTargetPath,
|
|
209
|
-
|
|
210
|
-
[Symbol.asyncDispose]: release,
|
|
211
|
-
};
|
|
181
|
+
});
|
|
212
182
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
state.held.delete(normalizedTargetPath);
|
|
225
|
-
}
|
|
226
|
-
// If payload serialization/write fails, the file may be empty or
|
|
227
|
-
// partial JSON, so remove while our exclusive handle is still open.
|
|
228
|
-
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
229
|
-
await handle.close().catch(() => undefined);
|
|
230
|
-
// Windows can refuse removing an open file; retry after close but
|
|
231
|
-
// only if the path still points at the file identity we created.
|
|
232
|
-
await removeLockIfUnchanged(lockPath, failedSnapshot);
|
|
233
|
-
}
|
|
234
|
-
if (err.code !== "EEXIST") {
|
|
235
|
-
throw err;
|
|
236
|
-
}
|
|
237
|
-
const nowMs = Date.now();
|
|
238
|
-
const snapshot = await readLockSnapshot(lockPath);
|
|
239
|
-
if (!snapshot) {
|
|
183
|
+
const remaining = options.timeoutMs === undefined || options.timeoutMs === Number.POSITIVE_INFINITY
|
|
184
|
+
? Number.POSITIVE_INFINITY
|
|
185
|
+
: Math.max(0, options.timeoutMs - elapsed);
|
|
186
|
+
const delay = Math.min(computeDelayMs(retry, attempt), remaining);
|
|
187
|
+
attempt += 1;
|
|
188
|
+
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
189
|
+
};
|
|
190
|
+
try {
|
|
191
|
+
while (true) {
|
|
192
|
+
if (!ownsReclaimGuard && (await sidecarReclaimGuardExists(reclaimGuardPath))) {
|
|
193
|
+
await waitForRetry();
|
|
240
194
|
continue;
|
|
241
195
|
}
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
lockPath,
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
196
|
+
let handle = null;
|
|
197
|
+
try {
|
|
198
|
+
handle = await fs.open(lockPath, "wx");
|
|
199
|
+
const payload = await options.payload();
|
|
200
|
+
const raw = `${JSON.stringify(payload, null, 2)}\n`;
|
|
201
|
+
await handle.writeFile(raw, "utf8");
|
|
202
|
+
const snapshot = { raw, payload, stat: await handle.stat() };
|
|
203
|
+
const createdHeld = {
|
|
204
|
+
count: 1,
|
|
205
|
+
handle,
|
|
206
|
+
lockPath,
|
|
207
|
+
snapshot,
|
|
208
|
+
acquiredAt: Date.now(),
|
|
209
|
+
metadata: options.metadata ?? {},
|
|
210
|
+
};
|
|
211
|
+
state.held.set(normalizedTargetPath, createdHeld);
|
|
212
|
+
if (ownsReclaimGuard) {
|
|
213
|
+
try {
|
|
214
|
+
await releaseSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath);
|
|
215
|
+
ownsReclaimGuard = false;
|
|
216
|
+
}
|
|
217
|
+
catch (err) {
|
|
218
|
+
await releaseHeldLock(state, normalizedTargetPath, createdHeld, { force: true });
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
253
221
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
// remain accepted, but third-party stale locks always fail closed.
|
|
257
|
-
throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
|
|
258
|
-
code: "file_lock_stale",
|
|
222
|
+
const release = () => releaseHeldLock(state, normalizedTargetPath, createdHeld).then(() => undefined);
|
|
223
|
+
return {
|
|
259
224
|
lockPath,
|
|
260
225
|
normalizedTargetPath,
|
|
261
|
-
|
|
226
|
+
release,
|
|
227
|
+
[Symbol.asyncDispose]: release,
|
|
228
|
+
};
|
|
262
229
|
}
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
230
|
+
catch (err) {
|
|
231
|
+
if (handle) {
|
|
232
|
+
const failedSnapshot = { payload: null };
|
|
233
|
+
try {
|
|
234
|
+
failedSnapshot.stat = await handle.stat();
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// Best-effort cleanup of a failed exclusive create.
|
|
238
|
+
}
|
|
239
|
+
const current = state.held.get(normalizedTargetPath);
|
|
240
|
+
if (current?.handle === handle) {
|
|
241
|
+
state.held.delete(normalizedTargetPath);
|
|
242
|
+
}
|
|
243
|
+
// If payload serialization/write fails, the file may be empty or
|
|
244
|
+
// partial JSON, so remove while our exclusive handle is still open.
|
|
245
|
+
await fs.rm(lockPath, { force: true }).catch(() => undefined);
|
|
246
|
+
await handle.close().catch(() => undefined);
|
|
247
|
+
// Windows can refuse removing an open file; retry after close but
|
|
248
|
+
// only if the path still points at the file identity we created.
|
|
249
|
+
await removeSidecarLockIfUnchanged(lockPath, failedSnapshot);
|
|
250
|
+
}
|
|
251
|
+
if (err.code !== "EEXIST") {
|
|
252
|
+
throw err;
|
|
253
|
+
}
|
|
254
|
+
if (ownsReclaimGuard) {
|
|
255
|
+
await releaseSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath);
|
|
256
|
+
ownsReclaimGuard = false;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
const nowMs = Date.now();
|
|
260
|
+
const snapshot = await readSidecarLockSnapshot(lockPath);
|
|
261
|
+
if (!snapshot) {
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const shouldReclaim = options.shouldReclaim ?? defaultShouldReclaim;
|
|
265
|
+
if (await shouldReclaim({
|
|
270
266
|
lockPath,
|
|
271
267
|
normalizedTargetPath,
|
|
272
|
-
|
|
268
|
+
payload: snapshot?.payload ?? null,
|
|
269
|
+
staleMs: options.staleMs,
|
|
270
|
+
nowMs,
|
|
271
|
+
heldByThisProcess: state.held.has(normalizedTargetPath),
|
|
272
|
+
})) {
|
|
273
|
+
if (!(await sidecarLockSnapshotStillPresent(lockPath, snapshot))) {
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
const staleRecovery = options.staleRecovery ?? "fail-closed";
|
|
277
|
+
if (staleRecovery === "remove-if-unchanged") {
|
|
278
|
+
if (!(await tryAcquireSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath))) {
|
|
279
|
+
await waitForRetry();
|
|
280
|
+
continue;
|
|
281
|
+
}
|
|
282
|
+
ownsReclaimGuard = true;
|
|
283
|
+
const removal = await removeStaleSidecarLockIfAllowed({
|
|
284
|
+
lockPath,
|
|
285
|
+
normalizedTargetPath,
|
|
286
|
+
snapshot,
|
|
287
|
+
shouldRemoveStaleLock: options.shouldRemoveStaleLock,
|
|
288
|
+
});
|
|
289
|
+
if (removal === "removed" || removal === "changed") {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
await releaseSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath);
|
|
293
|
+
ownsReclaimGuard = false;
|
|
294
|
+
}
|
|
295
|
+
throw Object.assign(new Error(`file lock stale for ${normalizedTargetPath}`), {
|
|
296
|
+
code: "file_lock_stale",
|
|
297
|
+
lockPath,
|
|
298
|
+
normalizedTargetPath,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
await waitForRetry();
|
|
273
302
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
finally {
|
|
306
|
+
if (ownsReclaimGuard) {
|
|
307
|
+
await releaseSidecarReclaimGuard(state.reclaimGuards, reclaimGuardPath).catch(() => undefined);
|
|
280
308
|
}
|
|
281
309
|
}
|
|
282
310
|
}
|
package/docs/config.md
CHANGED
|
@@ -56,7 +56,7 @@ Return the effective configuration: programmatic overrides win, then env vars, t
|
|
|
56
56
|
function configureFsSafeLocks(config: Partial<FsSafeLockConfig>): void;
|
|
57
57
|
|
|
58
58
|
type FsSafeLockConfig = {
|
|
59
|
-
staleRecovery: "fail-closed" | "remove-if-unchanged";
|
|
59
|
+
staleRecovery: "fail-closed" | "remove-if-unchanged";
|
|
60
60
|
staleMs?: number;
|
|
61
61
|
timeoutMs?: number;
|
|
62
62
|
retry?: FileLockRetryOptions;
|
|
@@ -65,7 +65,7 @@ type FsSafeLockConfig = {
|
|
|
65
65
|
|
|
66
66
|
Set process-wide defaults for sidecar lock options. This does **not** turn locking on globally; callers still need to pass `lock: true` or a lock options object for the specific JSON store/resource that needs cross-process coordination.
|
|
67
67
|
|
|
68
|
-
`staleRecovery` defaults to `"fail-closed"`. The
|
|
68
|
+
`staleRecovery` defaults to `"fail-closed"`. The opt-in `"remove-if-unchanged"` mode requires caller approval and serializes the final snapshot check and unlink with an exclusive `.reclaim` guard. A reclaim guard left by a killed reclaimer fails closed and requires externally coordinated cleanup.
|
|
69
69
|
|
|
70
70
|
## `getFsSafeLockConfig()`
|
|
71
71
|
|
package/docs/json-store.md
CHANGED
|
@@ -53,7 +53,7 @@ type JsonStoreLockOptions = {
|
|
|
53
53
|
staleMs?: number; // default 30_000
|
|
54
54
|
timeoutMs?: number; // default 30_000
|
|
55
55
|
retry?: FileLockRetryOptions;
|
|
56
|
-
staleRecovery?: "fail-closed" | "remove-if-unchanged";
|
|
56
|
+
staleRecovery?: "fail-closed" | "remove-if-unchanged";
|
|
57
57
|
managerKey?: string; // default `fs-safe.json-store:<filePath>`
|
|
58
58
|
};
|
|
59
59
|
|
|
@@ -141,7 +141,7 @@ When `lock` is falsy, `read` / `write` / `update` are unlocked. The `update` sha
|
|
|
141
141
|
|
|
142
142
|
Process-wide lock defaults from `configureFsSafeLocks()` apply only after locking is explicitly enabled. They do not make JSON stores lock by default.
|
|
143
143
|
|
|
144
|
-
JSON store locks
|
|
144
|
+
JSON store locks fail closed on stale sidecars by default. Opt-in `staleRecovery: "remove-if-unchanged"` requires caller approval and uses the same exclusive reclaim guard as the low-level sidecar-lock API.
|
|
145
145
|
|
|
146
146
|
The default `managerKey` namespaces the in-process `FileLockManager` per absolute file path, so two `jsonStore` calls on the same file share lock state automatically.
|
|
147
147
|
|
package/docs/sidecar-lock.md
CHANGED
|
@@ -21,7 +21,7 @@ try {
|
|
|
21
21
|
|
|
22
22
|
The lock file sits next to the protected resource. If a process crashes mid-lock, the next acquirer notices the held entry, inspects its payload (PID, host, acquired-at timestamp), and decides — via `shouldReclaim` (defaulting to "is the lock older than `staleMs`?") — whether it should keep waiting or fail.
|
|
23
23
|
|
|
24
|
-
The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind;
|
|
24
|
+
The library installs a `process.on("exit")` handler that releases all currently-held locks synchronously, so well-behaved exits leave no stale sidecars. Crashed holders leave their sidecar behind; recover only after an application-owned liveness policy proves the holder cannot still be writing.
|
|
25
25
|
|
|
26
26
|
## API
|
|
27
27
|
|
|
@@ -51,7 +51,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
|
51
51
|
staleMs?: number; // default 30_000
|
|
52
52
|
timeoutMs?: number; // overall acquire deadline; default unbounded
|
|
53
53
|
retry?: FileLockRetryOptions;
|
|
54
|
-
staleRecovery?: "fail-closed" | "remove-if-unchanged"; //
|
|
54
|
+
staleRecovery?: "fail-closed" | "remove-if-unchanged"; // default "fail-closed"
|
|
55
55
|
allowReentrant?: boolean; // if this process already holds it, increment a count instead of failing
|
|
56
56
|
payload: () => TPayload | Promise<TPayload>;
|
|
57
57
|
shouldReclaim?: (params: {
|
|
@@ -62,7 +62,7 @@ type FileLockAcquireOptions<TPayload extends Record<string, unknown>> = {
|
|
|
62
62
|
nowMs: number;
|
|
63
63
|
heldByThisProcess: boolean;
|
|
64
64
|
}) => boolean | Promise<boolean>;
|
|
65
|
-
shouldRemoveStaleLock?: (snapshot: {
|
|
65
|
+
shouldRemoveStaleLock?: (snapshot: {
|
|
66
66
|
lockPath: string;
|
|
67
67
|
normalizedTargetPath: string;
|
|
68
68
|
raw: string;
|
|
@@ -170,24 +170,28 @@ const handle = await acquireFileLock(targetPath, {
|
|
|
170
170
|
});
|
|
171
171
|
```
|
|
172
172
|
|
|
173
|
-
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result marks the observed sidecar as stale
|
|
173
|
+
`heldByThisProcess` is true when this manager already holds the lock (relevant for the reentrant case). A `true` result marks the observed sidecar as stale; `staleRecovery` then decides whether acquisition fails closed or attempts caller-approved removal.
|
|
174
174
|
|
|
175
|
-
## Stale recovery
|
|
175
|
+
## Stale recovery: guarded `remove-if-unchanged`
|
|
176
176
|
|
|
177
|
-
|
|
177
|
+
The default `staleRecovery: "fail-closed"` never removes third-party sidecars. Use `staleRecovery: "remove-if-unchanged"` only when your app has a reliable owner-liveness policy and can prove a stale owner cannot still be writing.
|
|
178
178
|
|
|
179
|
-
|
|
179
|
+
Opt-in recovery creates an exclusive `<lockPath>.reclaim` directory before the final snapshot check and unlink. Every compliant acquirer waits while that guard exists, so two reclaimers cannot perform the check/unlink race that could delete a fresh replacement lock. If another acquirer creates the replacement during the handoff, its exclusive create wins and the reclaimer leaves it untouched.
|
|
180
|
+
|
|
181
|
+
`shouldRemoveStaleLock` receives the exact lock snapshot that fs-safe inspected. The callback must approve that owner as definitely stale. If the callback is missing, returns false, or the file changed, acquisition fails closed or keeps retrying according to the normal retry policy.
|
|
182
|
+
|
|
183
|
+
A process killed during the short reclaim section can leave the empty `.reclaim` directory behind. That ambiguous state intentionally fails closed; remove the guard only under external authority that excludes every competing lock acquirer.
|
|
180
184
|
|
|
181
185
|
## What sidecar locks defend against
|
|
182
186
|
|
|
183
187
|
- **Two processes writing the same file at once.** `acquire` serializes the critical section.
|
|
184
|
-
- **Accidentally deleting a fresh lock during stale recovery.**
|
|
188
|
+
- **Accidentally deleting a fresh lock during stale recovery.** Opt-in removal is serialized by the reclaim guard and rechecks the approved snapshot before unlinking.
|
|
185
189
|
- **Race between simultaneous acquire attempts.** `O_CREAT | O_EXCL` ensures one wins.
|
|
186
190
|
|
|
187
191
|
## What they do **not** defend against
|
|
188
192
|
|
|
189
193
|
- **Misbehaving holders that ignore the lock.** Locks are advisory — only callers that go through `acquire` are bound.
|
|
190
|
-
- **
|
|
194
|
+
- **Unapproved stale lock deletion.** If a process crashes, use the payload and your own liveness policy before opting into guarded recovery.
|
|
191
195
|
- **Multi-host coordination over network filesystems.** Behavior depends on the underlying filesystem's `O_EXCL` semantics; treat as best-effort.
|
|
192
196
|
|
|
193
197
|
## Common patterns
|