@remnic/core 9.3.679 → 9.3.681

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.
Files changed (161) hide show
  1. package/dist/access-boundary.d.ts +178 -0
  2. package/dist/access-boundary.js +121 -0
  3. package/dist/access-boundary.js.map +1 -0
  4. package/dist/access-cli.js +114 -100
  5. package/dist/access-cli.js.map +1 -1
  6. package/dist/access-http.d.ts +1 -1
  7. package/dist/access-http.js +47 -45
  8. package/dist/access-mcp.d.ts +1 -1
  9. package/dist/access-mcp.js +43 -41
  10. package/dist/access-operations.d.ts +127 -0
  11. package/dist/access-operations.js +115 -0
  12. package/dist/access-operations.js.map +1 -0
  13. package/dist/access-schema.d.ts +34 -34
  14. package/dist/access-schema.js +3 -3
  15. package/dist/{access-service-S9oGKPZc.d.ts → access-service-DvA6jyHL.d.ts} +1 -1
  16. package/dist/access-service.d.ts +1 -1
  17. package/dist/access-service.js +39 -39
  18. package/dist/access-surface-catalog.d.ts +125 -0
  19. package/dist/access-surface-catalog.js +162 -0
  20. package/dist/access-surface-catalog.js.map +1 -0
  21. package/dist/adapters/index.js +7 -7
  22. package/dist/adapters/registry.js +3 -3
  23. package/dist/auto-sync-5CJBJMPZ.js +1 -1
  24. package/dist/briefing.js +8 -8
  25. package/dist/causal-behavior.js +5 -5
  26. package/dist/causal-chain.js +3 -3
  27. package/dist/causal-consolidation.js +16 -16
  28. package/dist/causal-retrieval.js +3 -3
  29. package/dist/causal-trajectory.js +1 -1
  30. package/dist/{chunk-JBPKEARU.js → chunk-2QSZNTDO.js} +7 -7
  31. package/dist/{chunk-3OKWZT7F.js → chunk-3IND7N4X.js} +2 -2
  32. package/dist/{chunk-GYSYLGNE.js → chunk-7MOTEVAA.js} +2 -2
  33. package/dist/{chunk-6T4LTI2F.js → chunk-7XH7VJN4.js} +4 -4
  34. package/dist/{chunk-AGNBY3VG.js → chunk-APJQ6UEA.js} +4 -4
  35. package/dist/{chunk-LZSMQHXC.js → chunk-ARLRTZZZ.js} +5 -5
  36. package/dist/{chunk-Q2H5U37U.js → chunk-B2B2IHUH.js} +2 -2
  37. package/dist/{chunk-SECQS4G4.js → chunk-BTVX7ZXZ.js} +5 -5
  38. package/dist/{chunk-DGEZKYVI.js → chunk-DOCTITOP.js} +4 -4
  39. package/dist/{chunk-EQYP3HA6.js → chunk-EG4TCVMU.js} +2 -2
  40. package/dist/{chunk-SLTKP5WJ.js → chunk-EW5KFXHL.js} +4 -4
  41. package/dist/{chunk-K2JYO6QV.js → chunk-FDSOMA6M.js} +28 -41
  42. package/dist/chunk-FDSOMA6M.js.map +1 -0
  43. package/dist/{chunk-CTCPB57O.js → chunk-G7Z3C2X6.js} +2 -2
  44. package/dist/{chunk-4PPMUNV5.js → chunk-H4BDNIKQ.js} +52 -52
  45. package/dist/{chunk-MTJ2LFAJ.js → chunk-H6PMGMNP.js} +2 -2
  46. package/dist/{chunk-7AAKSHDG.js → chunk-I3HSKQT7.js} +136 -136
  47. package/dist/{chunk-NXBXM7Q6.js → chunk-I75DF4FZ.js} +2 -2
  48. package/dist/{chunk-RC3AFF6Z.js → chunk-JD4SCARD.js} +1 -1
  49. package/dist/{chunk-LVTTO3VC.js → chunk-KACIOX42.js} +2 -2
  50. package/dist/{chunk-ATRB6Q25.js → chunk-KV6CX4ON.js} +2 -2
  51. package/dist/{chunk-VL5JJOOY.js → chunk-L5MUA6Q7.js} +5 -5
  52. package/dist/{chunk-PCGCQTU6.js → chunk-M4I3TREG.js} +75 -75
  53. package/dist/chunk-NHFXF4ZO.js +107 -0
  54. package/dist/chunk-NHFXF4ZO.js.map +1 -0
  55. package/dist/{chunk-MNUPGYIV.js → chunk-NQMBSSWW.js} +2 -2
  56. package/dist/{chunk-V4ZHKCGA.js → chunk-O2WELT5C.js} +5 -5
  57. package/dist/{chunk-Z6SEG36L.js → chunk-OUWAQVDJ.js} +4 -4
  58. package/dist/{chunk-57ME5VSI.js → chunk-Q5ZU3RNY.js} +4 -4
  59. package/dist/{chunk-ACYX37IM.js → chunk-QUA2JPH2.js} +6 -6
  60. package/dist/{chunk-UNZLU2MX.js → chunk-QVWM4C24.js} +37 -32
  61. package/dist/chunk-QVWM4C24.js.map +1 -0
  62. package/dist/{chunk-2AP4QJX5.js → chunk-TOQEZ63C.js} +8 -8
  63. package/dist/{chunk-EUM7CZFM.js → chunk-TY5NT3T3.js} +17 -17
  64. package/dist/{chunk-ZCVPFDHB.js → chunk-UAODC6GJ.js} +14 -14
  65. package/dist/{chunk-YJ4J2JJ2.js → chunk-UJDV2NLT.js} +9 -9
  66. package/dist/chunk-V254FAT5.js +85 -0
  67. package/dist/chunk-V254FAT5.js.map +1 -0
  68. package/dist/{chunk-3IE22DJ2.js → chunk-WEPMT6SC.js} +10 -10
  69. package/dist/{chunk-EZ25VE3G.js → chunk-YNDLCWXS.js} +4 -4
  70. package/dist/{cli-B2Ve7R22.d.ts → cli-feUe-x3I.d.ts} +1 -1
  71. package/dist/cli.d.ts +2 -2
  72. package/dist/cli.js +74 -72
  73. package/dist/compounding/engine.js +9 -9
  74. package/dist/connectors/codex-materialize-runner.js +9 -9
  75. package/dist/connectors/index.js +9 -9
  76. package/dist/consolidation-provenance-check.js +2 -2
  77. package/dist/contradiction/index.js +4 -4
  78. package/dist/dashboard-runtime.js +2 -2
  79. package/dist/entity-retrieval.js +7 -7
  80. package/dist/extraction.js +2 -2
  81. package/dist/{first-start-migration-PG5HBC3K.js → first-start-migration-FF7YFGRP.js} +4 -4
  82. package/dist/index.d.ts +2 -2
  83. package/dist/index.js +209 -207
  84. package/dist/index.js.map +1 -1
  85. package/dist/lcm/engine.js +4 -4
  86. package/dist/lcm/index.js +12 -12
  87. package/dist/maintenance/memory-governance.js +8 -8
  88. package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +7 -7
  89. package/dist/maintenance/rebuild-memory-projection.js +9 -9
  90. package/dist/mcp-memory-inspector-app.d.ts +1 -1
  91. package/dist/namespaces/migrate.js +17 -17
  92. package/dist/namespaces/search.js +8 -8
  93. package/dist/namespaces/storage.js +8 -8
  94. package/dist/operator-toolkit.js +22 -22
  95. package/dist/orchestrator.js +70 -70
  96. package/dist/resume-bundles.js +1 -1
  97. package/dist/schemas.d.ts +50 -50
  98. package/dist/search/factory.js +7 -7
  99. package/dist/search/index.js +11 -11
  100. package/dist/search/lancedb-backend.js +3 -3
  101. package/dist/search/meilisearch-backend.js +3 -3
  102. package/dist/search/orama-backend.js +3 -3
  103. package/dist/semantic-consolidation.js +11 -11
  104. package/dist/semantic-rule-promotion.js +7 -7
  105. package/dist/semantic-rule-verifier.js +8 -8
  106. package/dist/storage.js +6 -6
  107. package/dist/transfer/backup.js +2 -2
  108. package/dist/transfer/capsule-export.js +2 -2
  109. package/dist/transfer/capsule-import.js +1 -1
  110. package/dist/transfer/import-sqlite.js +2 -2
  111. package/dist/transfer/types.d.ts +38 -38
  112. package/dist/utils/serialize-mutations.d.ts +122 -0
  113. package/dist/utils/serialize-mutations.js +287 -0
  114. package/dist/utils/serialize-mutations.js.map +1 -0
  115. package/dist/verified-recall.js +8 -8
  116. package/package.json +12 -2
  117. package/src/access-boundary.test.ts +212 -0
  118. package/src/access-boundary.ts +235 -0
  119. package/src/access-cli.ts +32 -15
  120. package/src/access-http.ts +38 -28
  121. package/src/access-mcp.ts +41 -35
  122. package/src/access-operations.ts +157 -0
  123. package/src/access-surface-catalog.test.ts +772 -0
  124. package/src/access-surface-catalog.ts +218 -0
  125. package/src/utils/serialize-mutations.test.ts +1047 -0
  126. package/src/utils/serialize-mutations.ts +679 -0
  127. package/dist/chunk-K2JYO6QV.js.map +0 -1
  128. package/dist/chunk-UNZLU2MX.js.map +0 -1
  129. /package/dist/{chunk-JBPKEARU.js.map → chunk-2QSZNTDO.js.map} +0 -0
  130. /package/dist/{chunk-3OKWZT7F.js.map → chunk-3IND7N4X.js.map} +0 -0
  131. /package/dist/{chunk-GYSYLGNE.js.map → chunk-7MOTEVAA.js.map} +0 -0
  132. /package/dist/{chunk-6T4LTI2F.js.map → chunk-7XH7VJN4.js.map} +0 -0
  133. /package/dist/{chunk-AGNBY3VG.js.map → chunk-APJQ6UEA.js.map} +0 -0
  134. /package/dist/{chunk-LZSMQHXC.js.map → chunk-ARLRTZZZ.js.map} +0 -0
  135. /package/dist/{chunk-Q2H5U37U.js.map → chunk-B2B2IHUH.js.map} +0 -0
  136. /package/dist/{chunk-SECQS4G4.js.map → chunk-BTVX7ZXZ.js.map} +0 -0
  137. /package/dist/{chunk-DGEZKYVI.js.map → chunk-DOCTITOP.js.map} +0 -0
  138. /package/dist/{chunk-EQYP3HA6.js.map → chunk-EG4TCVMU.js.map} +0 -0
  139. /package/dist/{chunk-SLTKP5WJ.js.map → chunk-EW5KFXHL.js.map} +0 -0
  140. /package/dist/{chunk-CTCPB57O.js.map → chunk-G7Z3C2X6.js.map} +0 -0
  141. /package/dist/{chunk-4PPMUNV5.js.map → chunk-H4BDNIKQ.js.map} +0 -0
  142. /package/dist/{chunk-MTJ2LFAJ.js.map → chunk-H6PMGMNP.js.map} +0 -0
  143. /package/dist/{chunk-7AAKSHDG.js.map → chunk-I3HSKQT7.js.map} +0 -0
  144. /package/dist/{chunk-NXBXM7Q6.js.map → chunk-I75DF4FZ.js.map} +0 -0
  145. /package/dist/{chunk-RC3AFF6Z.js.map → chunk-JD4SCARD.js.map} +0 -0
  146. /package/dist/{chunk-LVTTO3VC.js.map → chunk-KACIOX42.js.map} +0 -0
  147. /package/dist/{chunk-ATRB6Q25.js.map → chunk-KV6CX4ON.js.map} +0 -0
  148. /package/dist/{chunk-VL5JJOOY.js.map → chunk-L5MUA6Q7.js.map} +0 -0
  149. /package/dist/{chunk-PCGCQTU6.js.map → chunk-M4I3TREG.js.map} +0 -0
  150. /package/dist/{chunk-MNUPGYIV.js.map → chunk-NQMBSSWW.js.map} +0 -0
  151. /package/dist/{chunk-V4ZHKCGA.js.map → chunk-O2WELT5C.js.map} +0 -0
  152. /package/dist/{chunk-Z6SEG36L.js.map → chunk-OUWAQVDJ.js.map} +0 -0
  153. /package/dist/{chunk-57ME5VSI.js.map → chunk-Q5ZU3RNY.js.map} +0 -0
  154. /package/dist/{chunk-ACYX37IM.js.map → chunk-QUA2JPH2.js.map} +0 -0
  155. /package/dist/{chunk-2AP4QJX5.js.map → chunk-TOQEZ63C.js.map} +0 -0
  156. /package/dist/{chunk-EUM7CZFM.js.map → chunk-TY5NT3T3.js.map} +0 -0
  157. /package/dist/{chunk-ZCVPFDHB.js.map → chunk-UAODC6GJ.js.map} +0 -0
  158. /package/dist/{chunk-YJ4J2JJ2.js.map → chunk-UJDV2NLT.js.map} +0 -0
  159. /package/dist/{chunk-3IE22DJ2.js.map → chunk-WEPMT6SC.js.map} +0 -0
  160. /package/dist/{chunk-EZ25VE3G.js.map → chunk-YNDLCWXS.js.map} +0 -0
  161. /package/dist/{first-start-migration-PG5HBC3K.js.map → first-start-migration-FF7YFGRP.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
+ });