@remnic/core 9.3.679 → 9.3.680
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-cli.js +4 -4
- package/dist/access-http.js +7 -7
- package/dist/access-mcp.js +6 -6
- package/dist/access-schema.js +3 -3
- package/dist/access-service.js +4 -4
- package/dist/{capsule-crypto-7FJQINUR.js → capsule-crypto-YO5QJ6L3.js} +2 -2
- package/dist/{chunk-K2JYO6QV.js → chunk-5TEYIXMP.js} +3 -3
- package/dist/{chunk-2NLLXCJG.js → chunk-BXLOS5AJ.js} +2 -2
- package/dist/{chunk-ARV3AUOM.js → chunk-DL6H3D7S.js} +2 -2
- package/dist/{chunk-X7Y7WX73.js → chunk-DQEMWVMT.js} +1 -1
- package/dist/{chunk-UNZLU2MX.js → chunk-DWQPM67F.js} +4 -4
- package/dist/{chunk-UDJLF3BO.js → chunk-JI6HWBYL.js} +2 -2
- package/dist/{chunk-4PPMUNV5.js → chunk-OBM7EVFU.js} +3 -3
- package/dist/{chunk-KQAFEZQX.js → chunk-VDX2J7OX.js} +2 -2
- package/dist/{chunk-PCGCQTU6.js → chunk-W67ZZDHO.js} +10 -10
- package/dist/cli.js +11 -11
- package/dist/contradiction/index.js +4 -4
- package/dist/index.js +15 -15
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +2 -2
- package/dist/transfer/capsule-import.js +2 -2
- package/dist/transfer/types.d.ts +6 -6
- package/dist/utils/serialize-mutations.d.ts +122 -0
- package/dist/utils/serialize-mutations.js +287 -0
- package/dist/utils/serialize-mutations.js.map +1 -0
- package/package.json +12 -2
- package/src/utils/serialize-mutations.test.ts +1047 -0
- package/src/utils/serialize-mutations.ts +679 -0
- /package/dist/{capsule-crypto-7FJQINUR.js.map → capsule-crypto-YO5QJ6L3.js.map} +0 -0
- /package/dist/{chunk-K2JYO6QV.js.map → chunk-5TEYIXMP.js.map} +0 -0
- /package/dist/{chunk-2NLLXCJG.js.map → chunk-BXLOS5AJ.js.map} +0 -0
- /package/dist/{chunk-ARV3AUOM.js.map → chunk-DL6H3D7S.js.map} +0 -0
- /package/dist/{chunk-X7Y7WX73.js.map → chunk-DQEMWVMT.js.map} +0 -0
- /package/dist/{chunk-UNZLU2MX.js.map → chunk-DWQPM67F.js.map} +0 -0
- /package/dist/{chunk-UDJLF3BO.js.map → chunk-JI6HWBYL.js.map} +0 -0
- /package/dist/{chunk-4PPMUNV5.js.map → chunk-OBM7EVFU.js.map} +0 -0
- /package/dist/{chunk-KQAFEZQX.js.map → chunk-VDX2J7OX.js.map} +0 -0
- /package/dist/{chunk-PCGCQTU6.js.map → chunk-W67ZZDHO.js.map} +0 -0
|
@@ -0,0 +1,1047 @@
|
|
|
1
|
+
// Tests for the shared serialized-mutation utility (issue #1524 utility PR).
|
|
2
|
+
//
|
|
3
|
+
// Every semantic the issue's "Done when" enumerates has a dedicated test:
|
|
4
|
+
// - rejection recovery (a rejected task never poisons the chain);
|
|
5
|
+
// - no unbounded growth (per-key entry deleted when the last task settles);
|
|
6
|
+
// - replacement-safe stale breaking (NG7Bg);
|
|
7
|
+
// - unchanged-stale still broken (NG7Bg baseline);
|
|
8
|
+
// - best-effort on acquisition timeout;
|
|
9
|
+
// - ownership-checked release;
|
|
10
|
+
// - mutual exclusion across concurrent tasks.
|
|
11
|
+
//
|
|
12
|
+
// Prove-fail-before: the rejection-recovery defect class (a bare `.then(fn)`
|
|
13
|
+
// chain that dies after the first rejection) is reproduced inline via a NAIVE
|
|
14
|
+
// poison-chain helper and asserted to FAIL to recover, before the real
|
|
15
|
+
// `serializeMutations` is asserted to recover. That documents the exact bug
|
|
16
|
+
// this utility exists to eliminate.
|
|
17
|
+
//
|
|
18
|
+
// NOTE on real timers: the serializeMutations tests below use NO wall-clock
|
|
19
|
+
// delays — they drive scheduling deterministically via gates and microtask
|
|
20
|
+
// yields. The withHeldFileLock tests DO use small real delays, because they
|
|
21
|
+
// exercise real filesystem `mtime` (the platform clock for stale detection)
|
|
22
|
+
// and real `setInterval` heartbeats — there is no fake-timer analog for
|
|
23
|
+
// `fs.stat().mtimeMs`. Per the test-timer rule's exception, each such test
|
|
24
|
+
// names why deterministic time control will not work.
|
|
25
|
+
|
|
26
|
+
import assert from "node:assert/strict";
|
|
27
|
+
import { mkdtemp, readFile, readdir, rm, stat, utimes, writeFile } from "node:fs/promises";
|
|
28
|
+
import { tmpdir } from "node:os";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
import test from "node:test";
|
|
31
|
+
|
|
32
|
+
import {
|
|
33
|
+
MutationSerializer,
|
|
34
|
+
serializeMutations,
|
|
35
|
+
withHeldFileLock,
|
|
36
|
+
} from "./serialize-mutations.js";
|
|
37
|
+
|
|
38
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
39
|
+
// Helpers
|
|
40
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
|
|
42
|
+
async function mkTmpDir(): Promise<string> {
|
|
43
|
+
return mkdtemp(path.join(tmpdir(), "remnic-serialize-mutations-"));
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Yield to the microtask queue so chained `.then` cleanup callbacks run. */
|
|
47
|
+
async function microtick(): Promise<void> {
|
|
48
|
+
// Two awaits guarantee the self-cleaning `.then` (chained off the recovered
|
|
49
|
+
// tail) has drained — it is itself one microtask behind the caller's await.
|
|
50
|
+
await Promise.resolve();
|
|
51
|
+
await Promise.resolve();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Resolve after `ms`. Used ONLY by the withHeldFileLock integration tests.
|
|
55
|
+
* Intentionally NOT unref'd: the delay IS the work the test is awaiting, so it
|
|
56
|
+
* must keep the event loop alive until it fires. */
|
|
57
|
+
function delay(ms: number): Promise<void> {
|
|
58
|
+
const { promise, resolve } = Promise.withResolvers<void>();
|
|
59
|
+
setTimeout(resolve, ms);
|
|
60
|
+
return promise;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* NAIVE poison chain — the defect class this utility replaces. A bare
|
|
65
|
+
* `.then(fn)` with NO rejection recovery: once one task rejects, the chain's
|
|
66
|
+
* tail rejects permanently and every subsequent task is SKIPPED. Used by the
|
|
67
|
+
* prove-fail-before test to show the exact bug.
|
|
68
|
+
*/
|
|
69
|
+
function naivePoisonChain<T>(key: string, task: () => Promise<T>): Promise<T> {
|
|
70
|
+
const prior = naivePoisonMap.get(key) ?? Promise.resolve();
|
|
71
|
+
// BUG: bare `.then(task)` — a rejection in `task` rejects the chain, so the
|
|
72
|
+
// next queued task's `.then(task)` never runs its callback (it propagates the
|
|
73
|
+
// rejection instead). This is what rule #40 / this utility fix.
|
|
74
|
+
const next = prior.then(task);
|
|
75
|
+
naivePoisonMap.set(key, next);
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
const naivePoisonMap = new Map<string, Promise<unknown>>();
|
|
79
|
+
|
|
80
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
81
|
+
// serializeMutations — ordering
|
|
82
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
test("serializeMutations runs same-key tasks in submission order", async () => {
|
|
85
|
+
const s = new MutationSerializer();
|
|
86
|
+
const order: string[] = [];
|
|
87
|
+
// Deterministic gate: A stays in-flight until we release it AFTER B and C
|
|
88
|
+
// are queued, so the test proves B/C wait for A regardless of scheduling.
|
|
89
|
+
const { promise: aGate, resolve: releaseA } = Promise.withResolvers<void>();
|
|
90
|
+
|
|
91
|
+
const a = s.serialize("k", async () => {
|
|
92
|
+
await aGate;
|
|
93
|
+
order.push("a");
|
|
94
|
+
});
|
|
95
|
+
const b = s.serialize("k", async () => {
|
|
96
|
+
order.push("b");
|
|
97
|
+
});
|
|
98
|
+
const c = s.serialize("k", async () => {
|
|
99
|
+
order.push("c");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
releaseA();
|
|
103
|
+
await Promise.all([a, b, c]);
|
|
104
|
+
assert.deepEqual(order, ["a", "b", "c"], "tasks run in submission order regardless of timing");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("serializeMutations runs different-key tasks concurrently (no cross-key blocking)", async () => {
|
|
108
|
+
const s = new MutationSerializer();
|
|
109
|
+
const { promise: allowKey1, resolve: releaseKey1 } = Promise.withResolvers<void>();
|
|
110
|
+
|
|
111
|
+
// Key 1 task blocks until released; key 2 task should NOT wait on it.
|
|
112
|
+
const key1 = s.serialize("k1", async () => {
|
|
113
|
+
await allowKey1;
|
|
114
|
+
});
|
|
115
|
+
let key2Ran = false;
|
|
116
|
+
const key2 = s.serialize("k2", async () => {
|
|
117
|
+
key2Ran = true;
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
await key2;
|
|
121
|
+
assert.equal(key2Ran, true, "different-key task must not block on an unrelated key's chain");
|
|
122
|
+
|
|
123
|
+
releaseKey1();
|
|
124
|
+
await key1;
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("serializeMutations surfaces the task's resolved value to its caller", async () => {
|
|
128
|
+
const s = new MutationSerializer();
|
|
129
|
+
const result = await s.serialize("k", async () => 42);
|
|
130
|
+
assert.equal(result, 42);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
134
|
+
// serializeMutations — rejection recovery (the core invariant)
|
|
135
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
test("PROVE-FAIL: a naive bare-.then(fn) chain is poisoned by the first rejection", async () => {
|
|
138
|
+
// This is the defect class. The naive helper skips task B after task A
|
|
139
|
+
// rejects. Asserting the BUG here makes the fix in serializeMutations
|
|
140
|
+
// concrete: if someone ever reverts to a bare `.then(fn)`, the real-utility
|
|
141
|
+
// test below would start failing while this one stays green.
|
|
142
|
+
let bRan = false;
|
|
143
|
+
const a = naivePoisonChain("k", async () => {
|
|
144
|
+
throw new Error("A failed");
|
|
145
|
+
});
|
|
146
|
+
const b = naivePoisonChain("k", async () => {
|
|
147
|
+
bRan = true;
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
await a.catch(() => undefined);
|
|
151
|
+
await b.catch(() => undefined);
|
|
152
|
+
assert.equal(bRan, false, "naive chain skips task B after task A rejects (the bug)");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("serializeMutations: a rejected task never poisons the chain (B runs after A rejects)", async () => {
|
|
156
|
+
const s = new MutationSerializer();
|
|
157
|
+
const order: string[] = [];
|
|
158
|
+
const { promise: aGate, resolve: releaseA } = Promise.withResolvers<void>();
|
|
159
|
+
|
|
160
|
+
const a = s.serialize("k", async () => {
|
|
161
|
+
order.push("a-start");
|
|
162
|
+
await aGate; // keep A in-flight so B is provably queued behind a live A
|
|
163
|
+
order.push("a-throw");
|
|
164
|
+
throw new Error("A failed");
|
|
165
|
+
});
|
|
166
|
+
let bRan = false;
|
|
167
|
+
const b = s.serialize("k", async () => {
|
|
168
|
+
bRan = true;
|
|
169
|
+
order.push("b");
|
|
170
|
+
return "b-done";
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
releaseA();
|
|
174
|
+
// A's caller sees A's rejection.
|
|
175
|
+
await assert.rejects(() => a, /A failed/);
|
|
176
|
+
// B STILL RUNS and resolves.
|
|
177
|
+
const bResult = await b;
|
|
178
|
+
assert.equal(bRan, true, "task B must run even though task A rejected");
|
|
179
|
+
assert.equal(bResult, "b-done");
|
|
180
|
+
assert.deepEqual(order, ["a-start", "a-throw", "b"]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("serializeMutations: an early rejection does not stop many later tasks", async () => {
|
|
184
|
+
const s = new MutationSerializer();
|
|
185
|
+
const { promise: aGate, resolve: releaseA } = Promise.withResolvers<void>();
|
|
186
|
+
const results: number[] = [];
|
|
187
|
+
|
|
188
|
+
const rejected = s.serialize("k", async () => {
|
|
189
|
+
await aGate;
|
|
190
|
+
throw new Error("boom");
|
|
191
|
+
});
|
|
192
|
+
const tasks = Array.from({ length: 10 }, (_, i) =>
|
|
193
|
+
s.serialize("k", async () => {
|
|
194
|
+
results.push(i);
|
|
195
|
+
return i;
|
|
196
|
+
}),
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
releaseA();
|
|
200
|
+
// allSettled (not all) so a rejecting chain does not short-circuit before we
|
|
201
|
+
// assert the observable behavior. With recovery every task runs and resolves;
|
|
202
|
+
// with a bare-.then chain, the rejection poisons the chain and results stays
|
|
203
|
+
// empty — a clean behavioral failure rather than an unhandled-rejection tangle.
|
|
204
|
+
await rejected.catch(() => undefined);
|
|
205
|
+
const settled = await Promise.allSettled(tasks);
|
|
206
|
+
assert.deepEqual(results, [0, 1, 2, 3, 4, 5, 6, 7, 8, 9]);
|
|
207
|
+
assert.ok(
|
|
208
|
+
settled.every((r) => r.status === "fulfilled"),
|
|
209
|
+
"every later task fulfilled (chain was not poisoned)",
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
test("serializeMutations: a rejection in one key does not affect another key", async () => {
|
|
214
|
+
const s = new MutationSerializer();
|
|
215
|
+
const { promise: aGate, resolve: releaseA } = Promise.withResolvers<void>();
|
|
216
|
+
|
|
217
|
+
const a = s.serialize("k1", async () => {
|
|
218
|
+
await aGate;
|
|
219
|
+
throw new Error("k1 fail");
|
|
220
|
+
});
|
|
221
|
+
const b = s.serialize("k2", async () => "k2-ok");
|
|
222
|
+
|
|
223
|
+
releaseA();
|
|
224
|
+
await assert.rejects(() => a, /k1 fail/);
|
|
225
|
+
assert.equal(await b, "k2-ok");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
229
|
+
// serializeMutations — no unbounded growth
|
|
230
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
test("serializeMutations deletes the per-key entry after the last task settles", async () => {
|
|
233
|
+
const s = new MutationSerializer();
|
|
234
|
+
assert.equal(s.pendingKeysForTest(), 0, "starts empty");
|
|
235
|
+
|
|
236
|
+
await s.serialize("k", async () => 1);
|
|
237
|
+
// Let the self-cleaning microtask (chained off the recovered tail) drain.
|
|
238
|
+
await microtick();
|
|
239
|
+
assert.equal(s.pendingKeysForTest(), 0, "entry removed after a single task settles");
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test("serializeMutations keeps the entry while tasks are in flight, then removes it", async () => {
|
|
243
|
+
const s = new MutationSerializer();
|
|
244
|
+
const { promise: gate, resolve: release } = Promise.withResolvers<void>();
|
|
245
|
+
|
|
246
|
+
const a = s.serialize("k", async () => {
|
|
247
|
+
await gate;
|
|
248
|
+
});
|
|
249
|
+
const b = s.serialize("k", async () => "done");
|
|
250
|
+
|
|
251
|
+
// While A is parked, the entry must still exist (B is queued behind it).
|
|
252
|
+
assert.equal(s.pendingKeysForTest(), 1, "entry present while tasks are in flight");
|
|
253
|
+
release();
|
|
254
|
+
await Promise.all([a, b]);
|
|
255
|
+
await microtick();
|
|
256
|
+
assert.equal(s.pendingKeysForTest(), 0, "entry removed after the chain drains");
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
test("serializeMutations does not accumulate entries across many sequential tasks", async () => {
|
|
260
|
+
const s = new MutationSerializer();
|
|
261
|
+
for (let i = 0; i < 50; i++) {
|
|
262
|
+
// Sequential: each settles and cleans up before the next is queued (the
|
|
263
|
+
// common hot-path shape). The map must never grow past 1.
|
|
264
|
+
await s.serialize("hot", async () => i);
|
|
265
|
+
}
|
|
266
|
+
await microtick();
|
|
267
|
+
assert.equal(s.pendingKeysForTest(), 0, "no leftover entries after sequential drains");
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
271
|
+
// serializeMutations — input validation & free-function export
|
|
272
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
test("serializeMutations rejects an empty key", () => {
|
|
275
|
+
const s = new MutationSerializer();
|
|
276
|
+
assert.throws(() => s.serialize("", async () => 1), /non-empty string/);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("serializeMutations rejects a non-function task", () => {
|
|
280
|
+
const s = new MutationSerializer();
|
|
281
|
+
assert.throws(
|
|
282
|
+
() => s.serialize("k", "not-a-function" as unknown as () => Promise<void>),
|
|
283
|
+
/function returning a promise/,
|
|
284
|
+
);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test("the free serializeMutations export serializes across independent calls", async () => {
|
|
288
|
+
const order: string[] = [];
|
|
289
|
+
const { promise: aGate, resolve: releaseA } = Promise.withResolvers<void>();
|
|
290
|
+
const a = serializeMutations("free-fn-shared-key", async () => {
|
|
291
|
+
await aGate;
|
|
292
|
+
order.push("a");
|
|
293
|
+
});
|
|
294
|
+
const b = serializeMutations("free-fn-shared-key", async () => {
|
|
295
|
+
order.push("b");
|
|
296
|
+
});
|
|
297
|
+
releaseA();
|
|
298
|
+
await Promise.all([a, b]);
|
|
299
|
+
assert.deepEqual(order, ["a", "b"], "free function shares one process-wide serializer");
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
303
|
+
// withHeldFileLock — mutual exclusion & lifecycle
|
|
304
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
305
|
+
|
|
306
|
+
test("withHeldFileLock runs the task with acquired=true and removes the lock on completion", async () => {
|
|
307
|
+
const dir = await mkTmpDir();
|
|
308
|
+
try {
|
|
309
|
+
const lockPath = path.join(dir, "test.lock");
|
|
310
|
+
let observedAcquired = false;
|
|
311
|
+
const result = await withHeldFileLock(lockPath, { staleMs: 5_000 }, async (acquired) => {
|
|
312
|
+
observedAcquired = acquired;
|
|
313
|
+
// While we hold the lock, the file exists with our owner id.
|
|
314
|
+
const content = await readFile(lockPath, "utf8");
|
|
315
|
+
const parts = content.trim().split(/\s+/);
|
|
316
|
+
assert.equal(parts.length, 3, "lock content is '<pid> <owner-uuid> <iso>'");
|
|
317
|
+
return "ok";
|
|
318
|
+
});
|
|
319
|
+
assert.equal(observedAcquired, true);
|
|
320
|
+
assert.equal(result, "ok");
|
|
321
|
+
const exists = await readFile(lockPath, "utf8").then(
|
|
322
|
+
() => true,
|
|
323
|
+
() => false,
|
|
324
|
+
);
|
|
325
|
+
assert.equal(exists, false, "lock file removed after the task completes");
|
|
326
|
+
} finally {
|
|
327
|
+
await rm(dir, { recursive: true, force: true });
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("withHeldFileLock serializes concurrent tasks on the same lock path (no overlap)", async () => {
|
|
332
|
+
// Integration test of real filesystem mutex behavior: `open(path,'wx')` is
|
|
333
|
+
// atomic at the OS level, so critical sections cannot overlap. The short
|
|
334
|
+
// real delay inside each section makes overlap OBSERVABLE if serialization
|
|
335
|
+
// were ever bypassed; deterministic time control cannot substitute because
|
|
336
|
+
// the guarantee is the OS exclusive-create, not a timer.
|
|
337
|
+
const dir = await mkTmpDir();
|
|
338
|
+
try {
|
|
339
|
+
const lockPath = path.join(dir, "mutex.lock");
|
|
340
|
+
let active = 0;
|
|
341
|
+
let maxOverlap = 0;
|
|
342
|
+
|
|
343
|
+
async function worker(): Promise<void> {
|
|
344
|
+
await withHeldFileLock(lockPath, { staleMs: 5_000 }, async () => {
|
|
345
|
+
active += 1;
|
|
346
|
+
maxOverlap = Math.max(maxOverlap, active);
|
|
347
|
+
await delay(8);
|
|
348
|
+
active -= 1;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
await Promise.all(Array.from({ length: 5 }, () => worker()));
|
|
353
|
+
assert.equal(maxOverlap, 1, "critical sections never overlapped (mutual exclusion held)");
|
|
354
|
+
} finally {
|
|
355
|
+
await rm(dir, { recursive: true, force: true });
|
|
356
|
+
}
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
360
|
+
// withHeldFileLock — stale breaking (mtime is the platform clock; utimes seeds
|
|
361
|
+
// it deterministically, so these tests need NO real delay for staleness itself)
|
|
362
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
363
|
+
|
|
364
|
+
test("withHeldFileLock breaks a genuinely stale lock and acquires (baseline)", async () => {
|
|
365
|
+
const dir = await mkTmpDir();
|
|
366
|
+
try {
|
|
367
|
+
const lockPath = path.join(dir, "stale.lock");
|
|
368
|
+
// Seed a stale lock (old mtime via utimes — deterministic, no real delay).
|
|
369
|
+
const staleContent = `${999999} aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa ${new Date(
|
|
370
|
+
Date.now() - 60_000,
|
|
371
|
+
).toISOString()}\n`;
|
|
372
|
+
await writeFile(lockPath, staleContent, "utf8");
|
|
373
|
+
const old = new Date(Date.now() - 60_000);
|
|
374
|
+
await utimes(lockPath, old, old);
|
|
375
|
+
|
|
376
|
+
let acquired = false;
|
|
377
|
+
await withHeldFileLock(
|
|
378
|
+
lockPath,
|
|
379
|
+
{ staleMs: 5_000 },
|
|
380
|
+
async (a) => {
|
|
381
|
+
acquired = a;
|
|
382
|
+
},
|
|
383
|
+
);
|
|
384
|
+
assert.equal(acquired, true, "stale lock was broken and we acquired");
|
|
385
|
+
|
|
386
|
+
const exists = await readFile(lockPath, "utf8").then(
|
|
387
|
+
() => true,
|
|
388
|
+
() => false,
|
|
389
|
+
);
|
|
390
|
+
assert.equal(exists, false, "stale lock replaced then released");
|
|
391
|
+
} finally {
|
|
392
|
+
await rm(dir, { recursive: true, force: true });
|
|
393
|
+
}
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("withHeldFileLock does NOT delete a REPLACEMENT lock created in the race window (NG7Bg)", async () => {
|
|
397
|
+
// Focused NG7Bg invariant: seed a stale lock, fire the seam to swap in a
|
|
398
|
+
// fresh replacement during the break's race window, use a SHORT maxWait so
|
|
399
|
+
// the breaker must decide on the replacement (not wait it out), and assert
|
|
400
|
+
// the replacement (owner D) survives unmodified — the break saw different
|
|
401
|
+
// content and refused to unlink.
|
|
402
|
+
const dir = await mkTmpDir();
|
|
403
|
+
try {
|
|
404
|
+
const lockPath = path.join(dir, "race.lock");
|
|
405
|
+
const staleContent = `${333333} cccccccc-cccc-4ccc-8ccc-cccccccccccc ${new Date(
|
|
406
|
+
Date.now() - 60_000,
|
|
407
|
+
).toISOString()}\n`;
|
|
408
|
+
await writeFile(lockPath, staleContent, "utf8");
|
|
409
|
+
await utimes(lockPath, new Date(Date.now() - 60_000), new Date(Date.now() - 60_000));
|
|
410
|
+
|
|
411
|
+
const replacementContent = `${444444} dddddddd-dddd-4ddd-8ddd-dddddddddddd ${new Date().toISOString()}\n`;
|
|
412
|
+
let seamFired = false;
|
|
413
|
+
await withHeldFileLock(
|
|
414
|
+
lockPath,
|
|
415
|
+
{
|
|
416
|
+
staleMs: 5_000,
|
|
417
|
+
maxWaitMs: 200,
|
|
418
|
+
pollMs: 20,
|
|
419
|
+
onBeforeBreakStaleUnlinkForTest: async () => {
|
|
420
|
+
seamFired = true;
|
|
421
|
+
await writeFile(lockPath, replacementContent, "utf8");
|
|
422
|
+
await utimes(lockPath, new Date(), new Date());
|
|
423
|
+
},
|
|
424
|
+
},
|
|
425
|
+
async (acquired) => {
|
|
426
|
+
void acquired;
|
|
427
|
+
},
|
|
428
|
+
);
|
|
429
|
+
assert.equal(seamFired, true, "the race-window seam fired");
|
|
430
|
+
|
|
431
|
+
const after = await readFile(lockPath, "utf8");
|
|
432
|
+
assert.equal(
|
|
433
|
+
after,
|
|
434
|
+
replacementContent,
|
|
435
|
+
"replacement lock (owner D) survived the stale break unchanged",
|
|
436
|
+
);
|
|
437
|
+
} finally {
|
|
438
|
+
await rm(dir, { recursive: true, force: true });
|
|
439
|
+
}
|
|
440
|
+
});
|
|
441
|
+
|
|
442
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
443
|
+
// withHeldFileLock — best-effort on timeout
|
|
444
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
445
|
+
|
|
446
|
+
test("withHeldFileLock invokes task(false) when a busy lock cannot be acquired in time", async () => {
|
|
447
|
+
const dir = await mkTmpDir();
|
|
448
|
+
try {
|
|
449
|
+
const lockPath = path.join(dir, "busy.lock");
|
|
450
|
+
// Pre-create a FRESH lock (recent mtime) so the breaker will NOT break it
|
|
451
|
+
// (not stale) and our acquire times out within maxWaitMs.
|
|
452
|
+
const fresh = `${555555} eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee ${new Date().toISOString()}\n`;
|
|
453
|
+
await writeFile(lockPath, fresh, "utf8");
|
|
454
|
+
await utimes(lockPath, new Date(), new Date());
|
|
455
|
+
|
|
456
|
+
let observedAcquired = true; // expect false
|
|
457
|
+
const started = Date.now();
|
|
458
|
+
await withHeldFileLock(
|
|
459
|
+
lockPath,
|
|
460
|
+
{ staleMs: 5_000, maxWaitMs: 150 },
|
|
461
|
+
async (acquired) => {
|
|
462
|
+
observedAcquired = acquired;
|
|
463
|
+
},
|
|
464
|
+
);
|
|
465
|
+
const waited = Date.now() - started;
|
|
466
|
+
|
|
467
|
+
assert.equal(observedAcquired, false, "task ran best-effort with acquired=false on timeout");
|
|
468
|
+
assert.ok(waited < 2_000, `did not hang (waited ~${waited}ms)`);
|
|
469
|
+
|
|
470
|
+
const after = await readFile(lockPath, "utf8");
|
|
471
|
+
assert.equal(after, fresh, "fresh holder's lock left intact");
|
|
472
|
+
} finally {
|
|
473
|
+
await rm(dir, { recursive: true, force: true });
|
|
474
|
+
}
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
478
|
+
// withHeldFileLock — ownership-checked release (NCzT6)
|
|
479
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
480
|
+
|
|
481
|
+
test("withHeldFileLock does not unlink a replacement lock on release (ownership-checked)", async () => {
|
|
482
|
+
const dir = await mkTmpDir();
|
|
483
|
+
try {
|
|
484
|
+
const lockPath = path.join(dir, "release-race.lock");
|
|
485
|
+
let ourOwnerId = "";
|
|
486
|
+
|
|
487
|
+
// While we hold the lock, swap it for a replacement owned by someone else.
|
|
488
|
+
// The release must detect it is no longer ours and leave the replacement.
|
|
489
|
+
await withHeldFileLock(
|
|
490
|
+
lockPath,
|
|
491
|
+
{ staleMs: 5_000 },
|
|
492
|
+
async () => {
|
|
493
|
+
const content = await readFile(lockPath, "utf8");
|
|
494
|
+
const parts = content.trim().split(/\s+/);
|
|
495
|
+
ourOwnerId = parts[1] ?? "";
|
|
496
|
+
const replacement = `${666666} ffffffff-ffff-4fff-8fff-ffffffffffff ${new Date().toISOString()}\n`;
|
|
497
|
+
await writeFile(lockPath, replacement, "utf8");
|
|
498
|
+
await utimes(lockPath, new Date(), new Date());
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
assert.ok(ourOwnerId.length > 0, "captured our owner id while we held the lock");
|
|
503
|
+
const after = await readFile(lockPath, "utf8");
|
|
504
|
+
assert.equal(
|
|
505
|
+
after.includes("ffffffff-ffff-4fff-8fff-ffffffffffff"),
|
|
506
|
+
true,
|
|
507
|
+
"replacement lock was NOT unlinked by our release",
|
|
508
|
+
);
|
|
509
|
+
assert.notEqual(
|
|
510
|
+
after.trim().split(/\s+/)[1],
|
|
511
|
+
ourOwnerId,
|
|
512
|
+
"the lock on disk is NOT ours after release",
|
|
513
|
+
);
|
|
514
|
+
} finally {
|
|
515
|
+
await rm(dir, { recursive: true, force: true });
|
|
516
|
+
}
|
|
517
|
+
});
|
|
518
|
+
|
|
519
|
+
test("release does NOT rename a replacement lock out of lockPath (pre-check, codex P2)", async () => {
|
|
520
|
+
// PROVE-FAIL: without the pre-check, releaseLock renames the replacement at
|
|
521
|
+
// lockPath to a trash path, leaving lockPath empty. The test seam simulates a
|
|
522
|
+
// third contender acquiring in that window. With the old code the link-restore
|
|
523
|
+
// fails (EEXIST) and the replacement is orphaned in trash; with the pre-check
|
|
524
|
+
// the rename never happens, so the seam never fires and no trash is created.
|
|
525
|
+
const dir = await mkTmpDir();
|
|
526
|
+
try {
|
|
527
|
+
const lockPath = path.join(dir, "release-precheck.lock");
|
|
528
|
+
const replacementOwner = "replacement-deadbeef-0000-4000-8000-000000000001";
|
|
529
|
+
const contenderOwner = "contender-cafebabe-0000-4000-8000-000000000002";
|
|
530
|
+
let seamFired = false;
|
|
531
|
+
|
|
532
|
+
await withHeldFileLock(
|
|
533
|
+
lockPath,
|
|
534
|
+
{
|
|
535
|
+
staleMs: 5_000,
|
|
536
|
+
onAfterReleaseRenameForTest: async () => {
|
|
537
|
+
// Simulate a third contender acquiring the now-empty lockPath.
|
|
538
|
+
seamFired = true;
|
|
539
|
+
const contender = `${888888} ${contenderOwner} ${new Date().toISOString()}\n`;
|
|
540
|
+
await writeFile(lockPath, contender, "utf8");
|
|
541
|
+
await utimes(lockPath, new Date(), new Date());
|
|
542
|
+
},
|
|
543
|
+
},
|
|
544
|
+
async () => {
|
|
545
|
+
// Simulate a stale break + replacement: overwrite with a different owner.
|
|
546
|
+
const replacement = `${777777} ${replacementOwner} ${new Date().toISOString()}\n`;
|
|
547
|
+
await writeFile(lockPath, replacement, "utf8");
|
|
548
|
+
await utimes(lockPath, new Date(), new Date());
|
|
549
|
+
},
|
|
550
|
+
);
|
|
551
|
+
|
|
552
|
+
// The pre-check must return BEFORE the rename, so the seam never fires.
|
|
553
|
+
assert.equal(seamFired, false, "pre-check returned before rename — seam never fired");
|
|
554
|
+
|
|
555
|
+
// The replacement lock must still be at lockPath, unchanged.
|
|
556
|
+
const after = await readFile(lockPath, "utf8");
|
|
557
|
+
assert.ok(
|
|
558
|
+
after.includes(replacementOwner),
|
|
559
|
+
"replacement lock remains at lockPath after our release",
|
|
560
|
+
);
|
|
561
|
+
|
|
562
|
+
// No `.releasing.` trash file may exist — lockPath was never emptied.
|
|
563
|
+
const entries = await readdir(dir);
|
|
564
|
+
const trashFiles = entries.filter((e) => e.includes(".releasing."));
|
|
565
|
+
assert.deepEqual(
|
|
566
|
+
trashFiles,
|
|
567
|
+
[],
|
|
568
|
+
"no release trash file created — replacement was never renamed out of lockPath",
|
|
569
|
+
);
|
|
570
|
+
} finally {
|
|
571
|
+
await rm(dir, { recursive: true, force: true });
|
|
572
|
+
}
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
576
|
+
// withHeldFileLock — input validation
|
|
577
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
578
|
+
|
|
579
|
+
test("withHeldFileLock rejects an empty lockPath", async () => {
|
|
580
|
+
await assert.rejects(
|
|
581
|
+
() => withHeldFileLock("", { staleMs: 1_000 }, async () => undefined),
|
|
582
|
+
/non-empty string/,
|
|
583
|
+
);
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("withHeldFileLock rejects a non-positive staleMs", async () => {
|
|
587
|
+
await assert.rejects(
|
|
588
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 0 }, async () => undefined),
|
|
589
|
+
/positive finite number/,
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("withHeldFileLock rejects NaN/Infinity/negative optional timings (not silently defaulted)", async () => {
|
|
594
|
+
// A NaN maxWaitMs would make `Date.now() >= deadline` always false and the
|
|
595
|
+
// bounded loop wait forever; reject it instead of silently falling back.
|
|
596
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -1, 0]) {
|
|
597
|
+
await assert.rejects(
|
|
598
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 1_000, maxWaitMs: bad }, async () => undefined),
|
|
599
|
+
/maxWaitMs must be a positive finite number/,
|
|
600
|
+
`maxWaitMs=${bad} should be rejected`,
|
|
601
|
+
);
|
|
602
|
+
await assert.rejects(
|
|
603
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 1_000, pollMs: bad }, async () => undefined),
|
|
604
|
+
/pollMs must be a positive finite number/,
|
|
605
|
+
`pollMs=${bad} should be rejected`,
|
|
606
|
+
);
|
|
607
|
+
await assert.rejects(
|
|
608
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 1_000, heartbeatMs: bad }, async () => undefined),
|
|
609
|
+
/heartbeatMs must be a positive finite number/,
|
|
610
|
+
`heartbeatMs=${bad} should be rejected`,
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
test("withHeldFileLock rejects timer-backed options above Node's setTimeout ceiling (codex P2)", async () => {
|
|
616
|
+
// Node's setTimeout/setInterval clamp delays > 2^31−1 to 1ms, turning a typo
|
|
617
|
+
// into tight polling or 1ms heartbeats. Reject at the validation boundary.
|
|
618
|
+
const over = 3_000_000_000; // > 2^31 − 1 (2_147_483_647)
|
|
619
|
+
await assert.rejects(
|
|
620
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 5_000, pollMs: over }, async () => undefined),
|
|
621
|
+
/pollMs .* exceeds the .* ceiling/,
|
|
622
|
+
"pollMs above the timer ceiling should be rejected",
|
|
623
|
+
);
|
|
624
|
+
await assert.rejects(
|
|
625
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 5_000, heartbeatMs: over }, async () => undefined),
|
|
626
|
+
/heartbeatMs .* exceeds the .* ceiling/,
|
|
627
|
+
"heartbeatMs above the timer ceiling should be rejected",
|
|
628
|
+
);
|
|
629
|
+
await assert.rejects(
|
|
630
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 5_000, maxWaitMs: over }, async () => undefined),
|
|
631
|
+
/maxWaitMs .* exceeds the .* ceiling/,
|
|
632
|
+
"maxWaitMs above the timer ceiling should be rejected",
|
|
633
|
+
);
|
|
634
|
+
});
|
|
635
|
+
|
|
636
|
+
test("withHeldFileLock rejects a derived heartbeatMs that exceeds the timer ceiling", async () => {
|
|
637
|
+
// When heartbeatMs is omitted, it is derived as floor(staleMs/3). A huge
|
|
638
|
+
// staleMs produces a derived heartbeat above the timer ceiling — reject it
|
|
639
|
+
// with an actionable message pointing to the explicit override.
|
|
640
|
+
const hugeStale = 7_000_000_000; // floor(/3) = 2_333_333_333 > 2_147_483_647
|
|
641
|
+
await assert.rejects(
|
|
642
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: hugeStale }, async () => undefined),
|
|
643
|
+
/derived heartbeatMs .* exceeds Node's setTimeout ceiling/,
|
|
644
|
+
"derived heartbeatMs above the timer ceiling should be rejected",
|
|
645
|
+
);
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
test("withHeldFileLock accepts a finite positive maxWaitMs that bounds acquisition", async () => {
|
|
649
|
+
const dir = await mkTmpDir();
|
|
650
|
+
try {
|
|
651
|
+
// A short but valid maxWaitMs against a fresh held lock times out cleanly.
|
|
652
|
+
const lockPath = path.join(dir, "bounded.lock");
|
|
653
|
+
const fresh = `${777777} 00000000-0000-4000-8000-000000000000 ${new Date().toISOString()}\n`;
|
|
654
|
+
await writeFile(lockPath, fresh, "utf8");
|
|
655
|
+
await utimes(lockPath, new Date(), new Date());
|
|
656
|
+
let acquired = true;
|
|
657
|
+
await withHeldFileLock(
|
|
658
|
+
lockPath,
|
|
659
|
+
{ staleMs: 5_000, maxWaitMs: 1 },
|
|
660
|
+
async (a) => {
|
|
661
|
+
acquired = a;
|
|
662
|
+
},
|
|
663
|
+
);
|
|
664
|
+
assert.equal(acquired, false, "tiny maxWaitMs timed out best-effort without hanging");
|
|
665
|
+
} finally {
|
|
666
|
+
await rm(dir, { recursive: true, force: true });
|
|
667
|
+
}
|
|
668
|
+
});
|
|
669
|
+
|
|
670
|
+
test("acquire caps the poll sleep to the remaining maxWaitMs budget (codex P2)", async () => {
|
|
671
|
+
// When pollMs > remaining budget, the sleep must be capped so acquisition
|
|
672
|
+
// does not block far past maxWaitMs. Real platform clock (Date.now); a real
|
|
673
|
+
// delay is the only way to measure elapsed wall time.
|
|
674
|
+
const dir = await mkTmpDir();
|
|
675
|
+
try {
|
|
676
|
+
const lockPath = path.join(dir, "large-poll.lock");
|
|
677
|
+
// Pre-create a FRESH lock (not stale) so the breaker will not break it
|
|
678
|
+
// and the acquire must time out.
|
|
679
|
+
const fresh = `${888888} 11111111-1111-4000-8000-111111111111 ${new Date().toISOString()}\n`;
|
|
680
|
+
await writeFile(lockPath, fresh, "utf8");
|
|
681
|
+
await utimes(lockPath, new Date(), new Date());
|
|
682
|
+
|
|
683
|
+
const maxWaitMs = 200;
|
|
684
|
+
const pollMs = 60_000; // would block ~60s without the cap
|
|
685
|
+
const start = Date.now();
|
|
686
|
+
let observedAcquired = true; // expect false
|
|
687
|
+
await withHeldFileLock(
|
|
688
|
+
lockPath,
|
|
689
|
+
{ staleMs: 5_000, maxWaitMs, pollMs },
|
|
690
|
+
async (acquired) => {
|
|
691
|
+
observedAcquired = acquired;
|
|
692
|
+
},
|
|
693
|
+
);
|
|
694
|
+
const elapsed = Date.now() - start;
|
|
695
|
+
assert.equal(observedAcquired, false, "timed out without acquiring");
|
|
696
|
+
// The elapsed time must be well under pollMs (60s) — capped to the budget.
|
|
697
|
+
// Allow generous margin for FS + scheduling overhead.
|
|
698
|
+
assert.ok(
|
|
699
|
+
elapsed < 5_000,
|
|
700
|
+
`acquire respected the maxWaitMs budget despite large pollMs (elapsed=${elapsed}ms; expected < 5000ms)`,
|
|
701
|
+
);
|
|
702
|
+
} finally {
|
|
703
|
+
await rm(dir, { recursive: true, force: true });
|
|
704
|
+
}
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
test("withHeldFileLock rejects a heartbeatMs >= staleMs", async () => {
|
|
708
|
+
const dir = await mkTmpDir();
|
|
709
|
+
try {
|
|
710
|
+
await assert.rejects(
|
|
711
|
+
() =>
|
|
712
|
+
withHeldFileLock(
|
|
713
|
+
path.join(dir, "x.lock"),
|
|
714
|
+
{ staleMs: 1_000, heartbeatMs: 1_000 },
|
|
715
|
+
async () => undefined,
|
|
716
|
+
),
|
|
717
|
+
/must be below staleMs/,
|
|
718
|
+
);
|
|
719
|
+
} finally {
|
|
720
|
+
await rm(dir, { recursive: true, force: true });
|
|
721
|
+
}
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
test("withHeldFileLock creates the lock directory if it does not exist", async () => {
|
|
725
|
+
const dir = await mkTmpDir();
|
|
726
|
+
try {
|
|
727
|
+
const lockPath = path.join(dir, "nested", "deep", "test.lock");
|
|
728
|
+
let acquired = false;
|
|
729
|
+
await withHeldFileLock(lockPath, { staleMs: 5_000 }, async (a) => {
|
|
730
|
+
acquired = a;
|
|
731
|
+
});
|
|
732
|
+
assert.equal(acquired, true, "acquired after creating nested lock dir");
|
|
733
|
+
|
|
734
|
+
const info = await stat(path.join(dir, "nested", "deep"));
|
|
735
|
+
assert.ok(info.isDirectory(), "nested lock directory was created");
|
|
736
|
+
} finally {
|
|
737
|
+
await rm(dir, { recursive: true, force: true });
|
|
738
|
+
}
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
test("heartbeat refresh keeps a long holder from being broken while another waits", async () => {
|
|
742
|
+
// Integration test against the real platform clock: the heartbeat is a real
|
|
743
|
+
// `setInterval` that calls `utimes`, and stale detection reads real `mtime`.
|
|
744
|
+
// Deterministic fake timers cannot advance `fs.stat().mtimeMs`, so a real
|
|
745
|
+
// (small, generously-margined) delay is the only way to exercise the
|
|
746
|
+
// heartbeat-refreshes-mtime invariant. Margins: heartbeatMs=50 lands several
|
|
747
|
+
// beats inside the 150ms wait window before staleMs=300.
|
|
748
|
+
const dir = await mkTmpDir();
|
|
749
|
+
try {
|
|
750
|
+
const lockPath = path.join(dir, "heartbeat.lock");
|
|
751
|
+
const { promise: holderDone, resolve: finishHolder } = Promise.withResolvers<void>();
|
|
752
|
+
|
|
753
|
+
const holder = withHeldFileLock(
|
|
754
|
+
lockPath,
|
|
755
|
+
{ staleMs: 300, heartbeatMs: 50 },
|
|
756
|
+
async () => {
|
|
757
|
+
await holderDone;
|
|
758
|
+
},
|
|
759
|
+
);
|
|
760
|
+
|
|
761
|
+
// Let two heartbeats land so the lock's mtime is fresh.
|
|
762
|
+
await delay(120);
|
|
763
|
+
|
|
764
|
+
let contenderAcquired = true; // expect false
|
|
765
|
+
await withHeldFileLock(
|
|
766
|
+
lockPath,
|
|
767
|
+
{ staleMs: 300, maxWaitMs: 150 },
|
|
768
|
+
async (acquired) => {
|
|
769
|
+
contenderAcquired = acquired;
|
|
770
|
+
},
|
|
771
|
+
);
|
|
772
|
+
assert.equal(
|
|
773
|
+
contenderAcquired,
|
|
774
|
+
false,
|
|
775
|
+
"contender timed out without breaking the heartbeating holder",
|
|
776
|
+
);
|
|
777
|
+
|
|
778
|
+
finishHolder();
|
|
779
|
+
await holder;
|
|
780
|
+
} finally {
|
|
781
|
+
await rm(dir, { recursive: true, force: true });
|
|
782
|
+
}
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
786
|
+
// withHeldFileLock — timing validation completeness (codex P2 round 2)
|
|
787
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
788
|
+
|
|
789
|
+
test("withHeldFileLock rejects staleMs NaN/Infinity/negative with an error listing the valid range", async () => {
|
|
790
|
+
for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, -1, 0]) {
|
|
791
|
+
await assert.rejects(
|
|
792
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: bad }, async () => undefined),
|
|
793
|
+
(err: TypeError) => {
|
|
794
|
+
assert.ok(err.message.includes("staleMs"), `message names staleMs (got ${bad})`);
|
|
795
|
+
assert.ok(err.message.includes("valid range"), `message lists valid range (got ${bad})`);
|
|
796
|
+
assert.ok(err.message.includes("got "), `message shows the invalid value (got ${bad})`);
|
|
797
|
+
return true;
|
|
798
|
+
},
|
|
799
|
+
`staleMs=${bad} should be rejected`,
|
|
800
|
+
);
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("withHeldFileLock rejects non-number types for optional timings (defensive against config coercion)", async () => {
|
|
805
|
+
// A string "100" or boolean true would pass typeof checks in loose code but
|
|
806
|
+
// is a real hazard if it slips through from config/env coercion.
|
|
807
|
+
for (const bad of ["100", true, null, {}, []]) {
|
|
808
|
+
for (const opt of ["maxWaitMs", "pollMs", "heartbeatMs"] as const) {
|
|
809
|
+
await assert.rejects(
|
|
810
|
+
() =>
|
|
811
|
+
withHeldFileLock(
|
|
812
|
+
"/tmp/x.lock",
|
|
813
|
+
{ staleMs: 1_000, [opt]: bad } as unknown as { staleMs: number },
|
|
814
|
+
async () => undefined,
|
|
815
|
+
),
|
|
816
|
+
(err: TypeError) => {
|
|
817
|
+
assert.ok(err.message.includes(opt), `message names ${opt}`);
|
|
818
|
+
assert.ok(err.message.includes("valid range"), `message lists valid range for ${opt}=${JSON.stringify(bad)}`);
|
|
819
|
+
return true;
|
|
820
|
+
},
|
|
821
|
+
`${opt}=${JSON.stringify(bad)} should be rejected`,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
test("withHeldFileLock timing errors describe the default fallback so callers can self-correct", async () => {
|
|
828
|
+
// The error must tell the caller what to do (omit the option to get the
|
|
829
|
+
// default), not just "must be positive finite". This is the difference
|
|
830
|
+
// between a debuggable config error and a silent clamp.
|
|
831
|
+
await assert.rejects(
|
|
832
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 1_000, maxWaitMs: NaN }, async () => undefined),
|
|
833
|
+
/maxWaitMs must be a positive finite number \(valid range:.*Omit the option to use the default of 5000 ms\./,
|
|
834
|
+
);
|
|
835
|
+
await assert.rejects(
|
|
836
|
+
() => withHeldFileLock("/tmp/x.lock", { staleMs: 1_000, pollMs: NaN }, async () => undefined),
|
|
837
|
+
/pollMs must be a positive finite number \(valid range:.*Omit the option to use the default of 50 ms\./,
|
|
838
|
+
);
|
|
839
|
+
});
|
|
840
|
+
|
|
841
|
+
test("withHeldFileLock runs task(false) best-effort when the lock directory cannot be created (advisory contract)", async () => {
|
|
842
|
+
// An intermediate path that is a FILE (not a directory) makes mkdir fail
|
|
843
|
+
// with ENOTDIR. The advisory lock contract requires this to fall through to
|
|
844
|
+
// task(false), NOT reject — otherwise a lock-setup problem crashes the
|
|
845
|
+
// primary guarded op (codex P2 review).
|
|
846
|
+
const dir = await mkTmpDir();
|
|
847
|
+
try {
|
|
848
|
+
// Create a regular file where the lock DIRECTORY would be created.
|
|
849
|
+
const blocker = path.join(dir, "blocker");
|
|
850
|
+
await writeFile(blocker, "not a directory", "utf8");
|
|
851
|
+
const lockPath = path.join(blocker, "nested.lock");
|
|
852
|
+
|
|
853
|
+
let acquired = true; // expect false
|
|
854
|
+
let ran = false;
|
|
855
|
+
await withHeldFileLock(
|
|
856
|
+
lockPath,
|
|
857
|
+
{ staleMs: 5_000, maxWaitMs: 50 },
|
|
858
|
+
async (a) => {
|
|
859
|
+
acquired = a;
|
|
860
|
+
ran = true;
|
|
861
|
+
},
|
|
862
|
+
);
|
|
863
|
+
assert.equal(ran, true, "task ran despite lock-dir setup failure");
|
|
864
|
+
assert.equal(acquired, false, "task ran best-effort (acquired=false) without the lock");
|
|
865
|
+
} finally {
|
|
866
|
+
await rm(dir, { recursive: true, force: true });
|
|
867
|
+
}
|
|
868
|
+
});
|
|
869
|
+
|
|
870
|
+
test("withHeldFileLock swallows a throwing onLockWarning hook (never crashes the guarded op)", async () => {
|
|
871
|
+
// The option is documented as never throwing into the caller. If a consumer
|
|
872
|
+
// supplies a hook that throws, it must not turn a non-fatal heartbeat
|
|
873
|
+
// failure into an unhandled rejection or override the task result on
|
|
874
|
+
// release (codex P2 review).
|
|
875
|
+
const dir = await mkTmpDir();
|
|
876
|
+
try {
|
|
877
|
+
const lockPath = path.join(dir, "throwing-hook.lock");
|
|
878
|
+
let warnings = 0;
|
|
879
|
+
const result = await withHeldFileLock(
|
|
880
|
+
lockPath,
|
|
881
|
+
{
|
|
882
|
+
staleMs: 5_000,
|
|
883
|
+
onLockWarning: () => {
|
|
884
|
+
warnings++;
|
|
885
|
+
throw new Error("hook exploded");
|
|
886
|
+
},
|
|
887
|
+
},
|
|
888
|
+
async () => "task-succeeded",
|
|
889
|
+
);
|
|
890
|
+
assert.equal(result, "task-succeeded", "task result not overridden by throwing hook");
|
|
891
|
+
// The hook may or may not fire (only on non-fatal FS hiccups); if it
|
|
892
|
+
// does, the throw is swallowed — the key assertion is that the task
|
|
893
|
+
// completed and no unhandled rejection propagated.
|
|
894
|
+
assert.ok(warnings >= 0, "did not crash");
|
|
895
|
+
} finally {
|
|
896
|
+
await rm(dir, { recursive: true, force: true });
|
|
897
|
+
}
|
|
898
|
+
});
|
|
899
|
+
|
|
900
|
+
test("heartbeat does NOT refresh a REPLACEMENT lock's mtime (ownership check, codex P2)", async () => {
|
|
901
|
+
// If our event loop was paused long enough that another process judged us
|
|
902
|
+
// stale, broke our lock, and created a replacement, our heartbeat must NOT
|
|
903
|
+
// refresh the replacement's mtime — that would keep a (possibly crashed)
|
|
904
|
+
// replacement looking fresh, defeating the stale-lock bound.
|
|
905
|
+
//
|
|
906
|
+
// Real platform clock (mtime): replace the lock file mid-hold with a
|
|
907
|
+
// different owner id, wait several heartbeat cycles, verify the replacement
|
|
908
|
+
// mtime was NOT refreshed by our stale heartbeat.
|
|
909
|
+
const dir = await mkTmpDir();
|
|
910
|
+
try {
|
|
911
|
+
const lockPath = path.join(dir, "replaced.lock");
|
|
912
|
+
const { promise: holderDone, resolve: finishHolder } = Promise.withResolvers<void>();
|
|
913
|
+
|
|
914
|
+
const holder = withHeldFileLock(
|
|
915
|
+
lockPath,
|
|
916
|
+
{ staleMs: 60_000, heartbeatMs: 30 },
|
|
917
|
+
async () => {
|
|
918
|
+
await holderDone;
|
|
919
|
+
},
|
|
920
|
+
);
|
|
921
|
+
|
|
922
|
+
// Let the heartbeat run normally for a couple cycles.
|
|
923
|
+
await delay(80);
|
|
924
|
+
|
|
925
|
+
// Simulate a stale break + replacement: overwrite the lock with a
|
|
926
|
+
// DIFFERENT owner id, and set its mtime to a known old value.
|
|
927
|
+
const replacement = `${999999} deadbeef-0000-4000-8000-000000000000 ${new Date().toISOString()}\n`;
|
|
928
|
+
const replacementTime = new Date(Date.now() - 5_000); // 5s ago
|
|
929
|
+
await writeFile(lockPath, replacement, "utf8");
|
|
930
|
+
await utimes(lockPath, replacementTime, replacementTime);
|
|
931
|
+
|
|
932
|
+
// Let several heartbeat cycles fire against the replacement lock.
|
|
933
|
+
await delay(150);
|
|
934
|
+
|
|
935
|
+
// The replacement lock's mtime should NOT have been refreshed — our
|
|
936
|
+
// heartbeat checked lockHeldBySelf, saw a different owner, and skipped.
|
|
937
|
+
const info = await stat(lockPath);
|
|
938
|
+
const ageAfterHeartbeats = Date.now() - info.mtimeMs;
|
|
939
|
+
assert.ok(
|
|
940
|
+
ageAfterHeartbeats > 4_000,
|
|
941
|
+
`replacement lock mtime was NOT refreshed by stale heartbeat (age=${ageAfterHeartbeats}ms; expected >4000ms)`,
|
|
942
|
+
);
|
|
943
|
+
|
|
944
|
+
finishHolder();
|
|
945
|
+
await holder;
|
|
946
|
+
} finally {
|
|
947
|
+
await rm(dir, { recursive: true, force: true });
|
|
948
|
+
}
|
|
949
|
+
});
|
|
950
|
+
|
|
951
|
+
test("breakStaleLock uses atomic rename so a replacement lock is never unlinked by a second contender", async () => {
|
|
952
|
+
// Two contenders both judge the same stale lock. One atomically renames
|
|
953
|
+
// it away and acquires a replacement; the other's break must NOT unlink
|
|
954
|
+
// the replacement (TOCTOU between check and unlink, codex P2).
|
|
955
|
+
//
|
|
956
|
+
// We simulate this by pre-seeding a stale lock, running one
|
|
957
|
+
// withHeldFileLock that breaks + acquires, then immediately checking the
|
|
958
|
+
// new lock's identity is preserved.
|
|
959
|
+
const dir = await mkTmpDir();
|
|
960
|
+
try {
|
|
961
|
+
const lockPath = path.join(dir, "race.lock");
|
|
962
|
+
// Seed a genuinely stale lock.
|
|
963
|
+
const staleOwner = "stale-aaaa-bbbb-cccc-dddd00000001";
|
|
964
|
+
const staleTime = new Date(Date.now() - 10_000);
|
|
965
|
+
await writeFile(lockPath, `${111111} ${staleOwner} ${staleTime.toISOString()}\n`, "utf8");
|
|
966
|
+
await utimes(lockPath, staleTime, staleTime);
|
|
967
|
+
|
|
968
|
+
// Acquire (breaks the stale lock via atomic rename).
|
|
969
|
+
let acquired = false;
|
|
970
|
+
const result = await withHeldFileLock(
|
|
971
|
+
lockPath,
|
|
972
|
+
{ staleMs: 1_000 },
|
|
973
|
+
async (a) => {
|
|
974
|
+
acquired = a;
|
|
975
|
+
return "ok";
|
|
976
|
+
},
|
|
977
|
+
);
|
|
978
|
+
assert.equal(acquired, true, "acquired after breaking the stale lock");
|
|
979
|
+
assert.equal(result, "ok");
|
|
980
|
+
|
|
981
|
+
// The stale lock file should be gone (renamed to trash + unlinked).
|
|
982
|
+
// The new lock was created by us, then released (unlinked). lockPath
|
|
983
|
+
// should not exist.
|
|
984
|
+
await assert.rejects(() => stat(lockPath), /ENOENT/);
|
|
985
|
+
|
|
986
|
+
// No trash files left behind.
|
|
987
|
+
const entries = await readdir(dir);
|
|
988
|
+
const trashFiles = entries.filter((f) => f.includes(".breaking."));
|
|
989
|
+
assert.equal(trashFiles.length, 0, `no trash files left behind (found: ${trashFiles.join(", ")})`);
|
|
990
|
+
} finally {
|
|
991
|
+
await rm(dir, { recursive: true, force: true });
|
|
992
|
+
}
|
|
993
|
+
});
|
|
994
|
+
|
|
995
|
+
test("breakStaleLock restores a replacement lock if the rename accidentally moves it", async () => {
|
|
996
|
+
// Simulate: stale lock X exists, contender A starts breakStaleLock. Between
|
|
997
|
+
// A's identity check and A's rename, the test seam replaces X with a fresh
|
|
998
|
+
// lock Y. A's rename moves Y. A's verify detects Y != X and restores Y.
|
|
999
|
+
const dir = await mkTmpDir();
|
|
1000
|
+
try {
|
|
1001
|
+
const lockPath = path.join(dir, "restore.lock");
|
|
1002
|
+
const staleOwner = "stale-aaaa-bbbb-cccc-dddd00000002";
|
|
1003
|
+
const staleTime = new Date(Date.now() - 10_000);
|
|
1004
|
+
const staleContent = `${222222} ${staleOwner} ${staleTime.toISOString()}\n`;
|
|
1005
|
+
await writeFile(lockPath, staleContent, "utf8");
|
|
1006
|
+
await utimes(lockPath, staleTime, staleTime);
|
|
1007
|
+
|
|
1008
|
+
// The test seam fires between staleness judgment and the rename.
|
|
1009
|
+
// Replace the lock with a fresh (non-stale) replacement mid-break.
|
|
1010
|
+
const freshOwner = "fresh-eeee-ffff-gggg-hhhh00000003";
|
|
1011
|
+
const replacementContent = `${333333} ${freshOwner} ${new Date().toISOString()}\n`;
|
|
1012
|
+
let result: string | undefined;
|
|
1013
|
+
await withHeldFileLock(
|
|
1014
|
+
lockPath,
|
|
1015
|
+
{
|
|
1016
|
+
staleMs: 1_000,
|
|
1017
|
+
onBeforeBreakStaleUnlinkForTest: () => {
|
|
1018
|
+
// Replace the stale lock with a fresh replacement in the race
|
|
1019
|
+
// window. This is the NG7Bg scenario: the break must not destroy
|
|
1020
|
+
// this replacement.
|
|
1021
|
+
return writeFile(lockPath, replacementContent, "utf8").then(() =>
|
|
1022
|
+
utimes(lockPath, new Date(), new Date()),
|
|
1023
|
+
);
|
|
1024
|
+
},
|
|
1025
|
+
},
|
|
1026
|
+
async (a) => {
|
|
1027
|
+
result = a ? "acquired" : "best-effort";
|
|
1028
|
+
},
|
|
1029
|
+
);
|
|
1030
|
+
|
|
1031
|
+
// The replacement was created AFTER the stale judgment. The break's
|
|
1032
|
+
// re-read detects the different content and returns early (replacement-
|
|
1033
|
+
// safe check at line 486). Our task may or may not acquire depending on
|
|
1034
|
+
// timing — the key assertion is that the fresh replacement is NOT
|
|
1035
|
+
// destroyed by a blind unlink.
|
|
1036
|
+
//
|
|
1037
|
+
// After withHeldFileLock completes, if we acquired, we released (lock
|
|
1038
|
+
// gone). If we timed out, the replacement should still exist with its
|
|
1039
|
+
// original content (or we may have acquired after the re-read check).
|
|
1040
|
+
// Either way, no trash file should remain.
|
|
1041
|
+
const entries = await readdir(dir);
|
|
1042
|
+
const trashFiles = entries.filter((f) => f.includes(".breaking."));
|
|
1043
|
+
assert.equal(trashFiles.length, 0, `no trash files left behind (found: ${trashFiles.join(", ")})`);
|
|
1044
|
+
} finally {
|
|
1045
|
+
await rm(dir, { recursive: true, force: true });
|
|
1046
|
+
}
|
|
1047
|
+
});
|