@sentropic/h2a 0.97.9 → 0.97.10

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.
@@ -0,0 +1,777 @@
1
+ /**
2
+ * Succession lock (v4) — single-machine succession protocol.
3
+ *
4
+ * Extracted from `runtime/upgrade/index.ts` (Lot 4, step 1: neutral
5
+ * intra-package move — zero behaviour change). This is the shared lock primitive
6
+ * used by the auto-upgrade prefix lock today, and (later lots) by the identity
7
+ * binding lock. It is a LEAF: it imports only `node:fs/os/crypto/child_process`
8
+ * and nothing from the store, so `upgrade/index.ts` and `local-files/locks.ts`
9
+ * import it directly by file, never via `local-files/index.js`.
10
+ *
11
+ * The full protocol proof-sketch (I1–I7, Lemmas A–C) lives with the code below,
12
+ * moved unchanged from its original location.
13
+ */
14
+ import { randomBytes } from "node:crypto";
15
+ import { spawnSync } from "node:child_process";
16
+ import { closeSync, fsyncSync, linkSync, mkdirSync, openSync, readdirSync, readFileSync, readlinkSync, statSync, unlinkSync, writeSync } from "node:fs";
17
+ import { hostname } from "node:os";
18
+ import { basename, join } from "node:path";
19
+ // ---------------------------------------------------------------------------
20
+ // Prefix lock (v4): single-machine succession protocol.
21
+ //
22
+ // At most one holder (proof sketch in the design notes):
23
+ // - I1 (atomic publication): a name appears only with complete, durable
24
+ // content — tmp file (wx + write + fsync + close) then `link(2)`; a reader
25
+ // sees ENOENT or a full record, so unreadable content means corruption
26
+ // (fail closed), never a torn write. A FS without hard links fails closed.
27
+ // - I2 (monotonicity): a retired token never reappears; LOCK != g is forever.
28
+ // - I3 (predicate): `isCertainlyDead` has no false positive; death is stable.
29
+ // - I4: only (R) the live owner releasing, or (S) a successor that created its
30
+ // SUCC then re-read LOCK == g, can remove LOCK == g.
31
+ // - I5: no SUCC targeting g is removed while LOCK == g.
32
+ // - I6: SUCC(t) always targets the same g (created after t judged dead).
33
+ // - I7: no age or mtime in any acquisition decision; `at` is diagnostic only.
34
+ // Ages below are used solely for debris GC, never to break a lock.
35
+ //
36
+ // Lemma A (stability): while live holder P holds p, LOCK == p — removing it
37
+ // would require SUCC(p), which requires P certainly dead. Lemma B (unique
38
+ // successor): while LOCK == g the chain g -> r0 -> r1 ... is linear, so only
39
+ // the last link can be live. Lemma C (targeted unlink): a successor that read
40
+ // LOCK == g removes g, since no other live successor exists (Lemma B) and any
41
+ // earlier one would already have made LOCK != g (I2).
42
+ //
43
+ // H1: the prefix is on a single machine's local FS; `link(2)` is atomic and
44
+ // returns EEXIST when the name exists. H2: the recorded process runs the
45
+ // shared mutation (swap) for the whole critical section; children (npm) only
46
+ // touch a per-token private staging dir. H3: tokens are random (>= 96 bits)
47
+ // and never republished.
48
+ /**
49
+ * R2 staleness-alert threshold. An upgrade held under the lock completes in seconds to
50
+ * a few minutes (bounded tarball fetch + stage + atomic swap), so a lock far older than
51
+ * that whose holder reads "live" ONLY for want of a comparable start time is worth
52
+ * surfacing (a reused PID may be masking a dead holder). Set well above any legitimate
53
+ * hold. It NEVER triggers a reclaim — purely diagnostic (I7: `at` informs, never decides).
54
+ */
55
+ export const STALE_LOCK_ALERT_MS = 30 * 60 * 1000;
56
+ /** Acquire rounds before giving up (a retry means a rival won meanwhile). */
57
+ export const PREFIX_LOCK_MAX_ROUNDS = 3;
58
+ /** Succession chain depth before failing closed with a diagnostic. */
59
+ export const PREFIX_LOCK_MAX_CHAIN = 8;
60
+ /** Holder-only GC age for abandoned TMP files (debris, never a decision). */
61
+ export const PREFIX_LOCK_TMP_DEBRIS_MAX_AGE_MS = 60 * 60 * 1000;
62
+ /** Fallback age for the residue sweep when no owner can be identified. */
63
+ export const UPGRADE_RESIDUE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
64
+ /** Tokens are hex (plus -/_ tolerance); anything else in a record is corruption. */
65
+ const LOCK_TOKEN_RE = /^[A-Za-z0-9_-]{12,128}$/;
66
+ export function lockPathFor(prefix) {
67
+ return join(prefix, ".h2a-upgrade.lock");
68
+ }
69
+ function succPathFor(lockPath, t) {
70
+ return `${lockPath}.succ.${t}`;
71
+ }
72
+ function tmpPathFor(path, t) {
73
+ // C2: the publish tmp must never live in the `.succ.` namespace, even when
74
+ // publishing a SUCC record. Derive it from the LOCK base so sweeps that
75
+ // match real SUCC records never collect a tmp before its link(2).
76
+ const idx = path.indexOf(".succ.");
77
+ const base = idx >= 0 ? path.slice(0, idx) : path;
78
+ return `${base}.tmp.${t}`;
79
+ }
80
+ export function errnoOf(e) {
81
+ const code = e?.code;
82
+ return typeof code === "string" && code.length > 0 ? code : "EIO";
83
+ }
84
+ // N3: `at` is validated only as a finite number (parseLockRec), so it may be out of
85
+ // Date's representable range; `new Date(at).toISOString()` would throw a RangeError.
86
+ // Never let a diagnostic string crash the caller (at boot the exception would swallow
87
+ // the whole M-2 alarm; `h2a upgrade` would abort). Fall back to the raw number.
88
+ export function safeAtIso(at) {
89
+ const ms = Number(at);
90
+ if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) {
91
+ try {
92
+ return new Date(ms).toISOString();
93
+ }
94
+ catch {
95
+ // fall through
96
+ }
97
+ }
98
+ return `epoch-ms:${at}`;
99
+ }
100
+ function readHostId() {
101
+ try {
102
+ const v = readFileSync("/etc/machine-id", "utf8").trim();
103
+ if (v)
104
+ return v;
105
+ }
106
+ catch {
107
+ // fall through to hostname
108
+ }
109
+ try {
110
+ const h = hostname().trim();
111
+ if (h)
112
+ return h;
113
+ }
114
+ catch {
115
+ // fall through to sentinel
116
+ }
117
+ return "unknown-host";
118
+ }
119
+ function readBootId() {
120
+ try {
121
+ const v = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
122
+ if (v)
123
+ return v;
124
+ }
125
+ catch {
126
+ // not Linux: fall through to sysctl below
127
+ }
128
+ // Outside Linux prefer the stable session UUID (TZ-independent) when present.
129
+ try {
130
+ const r = spawnSync("sysctl", ["-n", "kern.bootsessionuuid"], {
131
+ encoding: "utf8",
132
+ timeout: 2000,
133
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
134
+ });
135
+ const v = (r.stdout ?? "").trim();
136
+ if (r.status === 0 && v)
137
+ return v;
138
+ }
139
+ catch {
140
+ // best-effort
141
+ }
142
+ try {
143
+ const r = spawnSync("sysctl", ["-n", "kern.boottime"], {
144
+ encoding: "utf8",
145
+ timeout: 2000,
146
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
147
+ });
148
+ const v = (r.stdout ?? "").trim();
149
+ if (r.status === 0 && v)
150
+ return v;
151
+ }
152
+ catch {
153
+ // best-effort
154
+ }
155
+ return null;
156
+ }
157
+ // B2 decomposition: distinguish "this platform has no namespaces" (a KNOWN fact —
158
+ // one space per host) from "the namespace exists but is unreadable" (a genuine
159
+ // unknown). Only the latter is null; the former is the sentinel "host" so same-host
160
+ // liveness stays decidable (macOS/Windows/BSD). `platform`/`readLink` are injected
161
+ // only by tests (macOS/Windows/no-/proc sims); production passes none.
162
+ //
163
+ // readPidNs is the CONSERVATIVE gate that decides reclaim at all: an unknown (null)
164
+ // pid namespace makes even a PID-absent holder undecidable, because "absent" in an
165
+ // unknown namespace proves nothing. Any Linux /proc failure → null, never a false
166
+ // "host" that could match a foreign namespace.
167
+ export function readPidNs(platform = process.platform, readLink = readlinkSync) {
168
+ if (platform !== "linux")
169
+ return "host";
170
+ try {
171
+ return readLink("/proc/self/ns/pid");
172
+ }
173
+ catch {
174
+ return null; // the namespace exists here but is unreadable: genuine unknown
175
+ }
176
+ }
177
+ // The reader's time namespace (Linux): /proc/<pid>/stat starttime is expressed
178
+ // relative to it, so two lanes in different time namespaces read different values
179
+ // for the same live process. timeNs is consulted ONLY to gate the start-time
180
+ // COMPARISON (the PID-present branch of livenessOf); the PID-absent incident branch
181
+ // never needs it. So this reader is SYMMETRIC with readPidNs on purpose — no ENOENT
182
+ // special-case: a genuinely masked /proc (e.g. gVisor exposing ns/pid but not
183
+ // ns/time while time namespaces are in use) must yield null (⇒ "start not
184
+ // comparable ⇒ live"), never a false "host" that would compare across time bases
185
+ // and risk a false death (the B-1 class). null here is safe and quiet, not a wedge.
186
+ export function readTimeNs(platform = process.platform, readLink = readlinkSync) {
187
+ if (platform !== "linux")
188
+ return "host";
189
+ try {
190
+ return readLink("/proc/self/ns/time");
191
+ }
192
+ catch {
193
+ return null; // no readable time namespace: start times are not comparable
194
+ }
195
+ }
196
+ // Trust /proc for start/state only when it maps to THIS process's pid namespace.
197
+ // Under `unshare --pid` without `--mount-proc`, `kill` targets the right process
198
+ // but `/proc/<pid>` describes another — a false start mismatch or zombie.
199
+ function procMapsToSelf() {
200
+ try {
201
+ return readlinkSync("/proc/self") === String(process.pid);
202
+ }
203
+ catch {
204
+ return false;
205
+ }
206
+ }
207
+ /**
208
+ * Process start identity, prefixed by its source ("proc:" from Linux /proc
209
+ * field 22, "ps:" from `ps lstart`). The prefix is part of the stored value
210
+ * so a reader using a different source never concludes "dead" from a
211
+ * format/TZ-fragile comparison (C1).
212
+ */
213
+ // `platform`/`mapsToSelf` are injected only by tests (mis-mapped-/proc / non-Linux
214
+ // sims); production passes none.
215
+ export function procStartInfo(pid, platform = process.platform, mapsToSelf = procMapsToSelf) {
216
+ // A start time is only trusted from a source whose value is STABLE for the life of the
217
+ // process (never moving under a wall-clock step), else a clock jump while the lock is held
218
+ // could make a live holder's start "differ" ⇒ a false death ⇒ two holders (I3).
219
+ // - Linux: /proc field 22 (proc:) is the only trusted source.
220
+ // - B3: a mis-mapped /proc (unshare --pid without --mount-proc) makes both /proc AND
221
+ // `ps` (procps reads /proc/<pid>) describe another namespace's process ⇒ undatable.
222
+ // - N5: `ps` lstart under Linux derives from btime + starttime and moves on a clock step,
223
+ // so it is not stable either. So under Linux: /proc when it maps to us, else undefined.
224
+ // - darwin: `ps lstart` is the ABSOLUTE fork wall-clock time (p_starttime), stable ⇒ trusted.
225
+ // - R-BSD: on FreeBSD/OpenBSD `ps` start is boot-relative and its boot time is re-derived on
226
+ // a clock step, so it is NOT stable. Every other non-Linux platform (incl. Windows, no ps)
227
+ // ⇒ undefined (undatable ⇒ live). In doubt, never dead.
228
+ if (platform === "linux") {
229
+ if (!mapsToSelf())
230
+ return undefined;
231
+ try {
232
+ const s = readFileSync(`/proc/${pid}/stat`, "utf8");
233
+ const close = s.lastIndexOf(")");
234
+ if (close >= 0) {
235
+ const after = s.slice(close + 1).trim().split(/\s+/);
236
+ // after[0] is state (field 3); after[19] is starttime (field 22).
237
+ if (after.length >= 20 && after[0] && after[19]) {
238
+ return { state: after[0], start: `proc:${after[19]}` };
239
+ }
240
+ }
241
+ }
242
+ catch {
243
+ // no readable /proc for this pid: undatable
244
+ }
245
+ return undefined;
246
+ }
247
+ if (platform !== "darwin")
248
+ return undefined; // only darwin ps is a stable source
249
+ try {
250
+ const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
251
+ encoding: "utf8",
252
+ timeout: 5000,
253
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
254
+ });
255
+ const v = (r.stdout ?? "").trim();
256
+ if (r.status === 0 && v)
257
+ return { start: `ps:${v}` };
258
+ }
259
+ catch {
260
+ // best-effort
261
+ }
262
+ return undefined;
263
+ }
264
+ function startSource(s) {
265
+ if (s.startsWith("proc:"))
266
+ return "proc";
267
+ if (s.startsWith("ps:"))
268
+ return "ps";
269
+ return "legacy";
270
+ }
271
+ let ME_CACHE;
272
+ /** This process's identity, computed once and memoized. */
273
+ export function me() {
274
+ ME_CACHE ??= {
275
+ host: readHostId(),
276
+ boot: readBootId(),
277
+ ns: readPidNs(),
278
+ timeNs: readTimeNs(),
279
+ pid: process.pid,
280
+ start: procStartInfo(process.pid)?.start ?? null
281
+ };
282
+ return ME_CACHE;
283
+ }
284
+ /** Fresh random token, >= 96 bits, never republished (H3). */
285
+ function newToken() {
286
+ return randomBytes(16).toString("hex");
287
+ }
288
+ export function makeLockRec(token, target) {
289
+ const self = me();
290
+ return {
291
+ host: self.host,
292
+ boot: self.boot,
293
+ ns: self.ns,
294
+ timeNs: self.timeNs,
295
+ pid: self.pid,
296
+ start: self.start,
297
+ token,
298
+ ...(target !== undefined ? { target } : {}),
299
+ at: Date.now()
300
+ };
301
+ }
302
+ export function parseLockRec(raw) {
303
+ if (typeof raw !== "object" || raw === null)
304
+ throw new Error("bad lock record");
305
+ const o = raw;
306
+ const { host, boot, ns, timeNs, pid, start, token, target, at } = o;
307
+ // timeNs is a required field like host/boot/ns/pid/start: an absent field is a
308
+ // malformed record → corrupt → fail-closed. No back-compat exception is carved
309
+ // for a "v4 without timeNs" — the redesign was never published, so no such lock
310
+ // exists outside fixtures (kept in step with makeLockRec). The VALUE may be null
311
+ // (genuine unknown time namespace); the liveness guard then treats the proc start
312
+ // as undatable ⇒ "live" (never reclaim, never a false death), not undecidable.
313
+ if (typeof host !== "string" || host.length === 0)
314
+ throw new Error("bad host");
315
+ if (boot !== null && typeof boot !== "string")
316
+ throw new Error("bad boot");
317
+ if (ns !== null && typeof ns !== "string")
318
+ throw new Error("bad ns");
319
+ if (timeNs !== null && typeof timeNs !== "string")
320
+ throw new Error("bad timeNs");
321
+ if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
322
+ throw new Error("bad pid");
323
+ if (start !== null && typeof start !== "string")
324
+ throw new Error("bad start");
325
+ if (typeof token !== "string" || !LOCK_TOKEN_RE.test(token))
326
+ throw new Error("bad token");
327
+ if (target !== undefined && typeof target !== "string")
328
+ throw new Error("bad target");
329
+ if (typeof at !== "number" || !Number.isFinite(at))
330
+ throw new Error("bad at");
331
+ return {
332
+ host,
333
+ boot,
334
+ ns,
335
+ timeNs,
336
+ pid,
337
+ start,
338
+ token,
339
+ ...(target !== undefined ? { target } : {}),
340
+ at
341
+ };
342
+ }
343
+ /** opus `read`: ENOENT -> absent, anything else unreadable -> corrupt (I1). */
344
+ export function readLockRecord(path) {
345
+ try {
346
+ return parseLockRec(JSON.parse(readFileSync(path, "utf8")));
347
+ }
348
+ catch (e) {
349
+ return e?.code === "ENOENT" ? "absent" : "corrupt";
350
+ }
351
+ }
352
+ /**
353
+ * opus `publish` (I1): a name appears only with complete, durable content.
354
+ * Write tmp (wx + byte-count-checked write + fsync + close), then `link(2)`;
355
+ * EEXIST -> "exists", ENOENT at link -> "retry" (tmp reaped before link),
356
+ * any other failure -> "error" with the errno (no hard links -> fail closed).
357
+ * The tmp never contains `.succ.` (C2).
358
+ */
359
+ export function publishLockRecord(path, rec) {
360
+ const tmp = tmpPathFor(path, rec.token);
361
+ try {
362
+ const data = JSON.stringify(rec);
363
+ const fd = openSync(tmp, "wx", 0o644);
364
+ let truncated = false;
365
+ try {
366
+ const written = writeSync(fd, data);
367
+ if (written !== Buffer.byteLength(data)) {
368
+ truncated = true;
369
+ }
370
+ else {
371
+ fsyncSync(fd);
372
+ }
373
+ }
374
+ finally {
375
+ try {
376
+ closeSync(fd);
377
+ }
378
+ catch {
379
+ // best-effort
380
+ }
381
+ }
382
+ if (truncated) {
383
+ try {
384
+ unlinkSync(tmp);
385
+ }
386
+ catch {
387
+ // best-effort
388
+ }
389
+ return { status: "error", code: "ENOSPC" };
390
+ }
391
+ }
392
+ catch (e) {
393
+ try {
394
+ unlinkSync(tmp);
395
+ }
396
+ catch {
397
+ // best-effort
398
+ }
399
+ return { status: "error", code: errnoOf(e) };
400
+ }
401
+ try {
402
+ linkSync(tmp, path);
403
+ return { status: "ok" };
404
+ }
405
+ catch (e) {
406
+ const code = errnoOf(e);
407
+ if (code === "EEXIST")
408
+ return { status: "exists" };
409
+ if (code === "ENOENT")
410
+ return { status: "retry" };
411
+ return { status: "error", code };
412
+ }
413
+ finally {
414
+ try {
415
+ unlinkSync(tmp);
416
+ }
417
+ catch {
418
+ // best-effort
419
+ }
420
+ }
421
+ }
422
+ /**
423
+ * Single liveness classifier. `livenessOf` and `isCertainlyDead` are exactly its
424
+ * "verdict"/"dead" arm, and the R2 staleness alert reads its `datable` bit, so none
425
+ * of them can drift from this one decision. Never derives "dead" from a fragile
426
+ * comparison: a source mismatch or an unknown/differing time namespace both yield a
427
+ * safe "live" (undatable), and a corrupt "legacy" start yields "undecidable" (C1).
428
+ */
429
+ export function classifyLiveness(r, self, deps = {}) {
430
+ const platform = deps.platform ?? process.platform;
431
+ const probe = deps.probe ?? procStartInfo;
432
+ if (r.host !== self.host)
433
+ return { verdict: "undecidable", datable: false }; // other machine
434
+ // B1: only a Linux boot_id is a stable, trustworthy boot identity, so a difference
435
+ // there is a previous boot ⇒ dead. Off Linux a boot difference must NOT short-circuit
436
+ // to undecidable (that wedged a Mac rebooted mid-lock forever); fall through to kill(0)
437
+ // (PID absent ⇒ dead, on every platform) then the start comparison (only conclusive
438
+ // where the start is datable — Linux /proc and darwin ps; on BSD/Windows it is undatable
439
+ // ⇒ live, so after a reboot a reused PID blocks until it exits, surfaced by R2).
440
+ if (r.boot && self.boot && r.boot !== self.boot && platform === "linux") {
441
+ return { verdict: "dead", datable: false };
442
+ }
443
+ // An unreadable namespace on EITHER side is a genuine unknown — two records with a
444
+ // null namespace must never be treated as co-located (null-equality).
445
+ if (r.ns === null || self.ns === null)
446
+ return { verdict: "undecidable", datable: false };
447
+ if (r.ns !== self.ns)
448
+ return { verdict: "undecidable", datable: false }; // other, known ns
449
+ // From here the namespace is known AND shared, so a PID's absence is conclusive.
450
+ let exists = false;
451
+ try {
452
+ process.kill(r.pid, 0);
453
+ exists = true;
454
+ }
455
+ catch (e) {
456
+ const code = errnoOf(e);
457
+ // PID absent in a known, shared namespace ⇒ dead, with certainty, no start time.
458
+ if (code === "ESRCH")
459
+ return { verdict: "dead", datable: false };
460
+ if (code === "EPERM")
461
+ exists = true; // exists, no permission: keep checking
462
+ else
463
+ return { verdict: "undecidable", datable: false };
464
+ }
465
+ const p = probe(r.pid); // undefined when unknown
466
+ if (p?.state === "Z")
467
+ return { verdict: "dead", datable: false }; // zombie never runs again
468
+ if (r.start !== null && p?.start !== undefined) {
469
+ const rSrc = startSource(r.start);
470
+ const pSrc = startSource(p.start);
471
+ // A "legacy" (malformed, pre-source-prefix) start is not a trustworthy value ⇒
472
+ // undecidable (fail closed). Any other source mismatch (proc vs ps) is simply not
473
+ // comparable ⇒ undatable ⇒ live.
474
+ if (rSrc === "legacy" || pSrc === "legacy")
475
+ return { verdict: "undecidable", datable: false };
476
+ if (rSrc !== pSrc)
477
+ return { verdict: "live", datable: false };
478
+ // proc starttime is expressed relative to the reader's time namespace, so a
479
+ // proc-sourced comparison is only meaningful when both time namespaces are KNOWN and
480
+ // EQUAL. Otherwise the recorded start is UNDATABLE ⇒ "live" — never reclaim, never a
481
+ // false death, never a false M-2 alarm. This never wedges an incident: the PID-absent
482
+ // branch concluded "dead" without a start. The only cost is a genuinely-reused PID
483
+ // left alive until it exits — rare, bounded, and surfaced by the R2 staleness alert.
484
+ // A ps: start now arises ONLY on darwin (procStartInfo trusts ps only there), where
485
+ // lstart is the absolute fork wall-clock (TZ-normalised), stable ⇒ it needs no time gate.
486
+ if (rSrc === "proc" && (r.timeNs === null || self.timeNs === null || r.timeNs !== self.timeNs)) {
487
+ return { verdict: "live", datable: false };
488
+ }
489
+ return p.start !== r.start
490
+ ? { verdict: "dead", datable: true } // PID reused (a confirmed different start)
491
+ : { verdict: "live", datable: true }; // same, confirmed process
492
+ }
493
+ // Present but with an undatable start ⇒ live: never reclaim a live-or-unknown holder.
494
+ return { verdict: exists ? "live" : "undecidable", datable: false };
495
+ }
496
+ export function livenessOf(r, self, deps = {}) {
497
+ return classifyLiveness(r, self, deps).verdict;
498
+ }
499
+ /** No false positive: true implies certainly dead; any doubt is alive (I3). */
500
+ export function isCertainlyDead(r) {
501
+ return classifyLiveness(r, me()).verdict === "dead";
502
+ }
503
+ function lockDenied(reason) {
504
+ return { acquired: false, release: () => { }, reason };
505
+ }
506
+ function invokeLockHook(fn, ctx) {
507
+ if (!fn)
508
+ return;
509
+ try {
510
+ fn(ctx);
511
+ }
512
+ catch {
513
+ // Observation-only: a test hook must never break the protocol.
514
+ }
515
+ }
516
+ /** opus `acquirePrefixLock`, plus M5 reasons and test-only critical hooks. */
517
+ export function acquirePrefixLock(prefix, hooks = {}) {
518
+ const lockPath = lockPathFor(prefix);
519
+ try {
520
+ mkdirSync(prefix, { recursive: true });
521
+ }
522
+ catch (e) {
523
+ return lockDenied(`error:${errnoOf(e)}`);
524
+ }
525
+ const self = me();
526
+ for (let round = 0; round < PREFIX_LOCK_MAX_ROUNDS; round++) {
527
+ if (round === 0) {
528
+ invokeLockHook(hooks.beforePublishLock, { prefix, lockPath, path: lockPath, round, depth: 0 });
529
+ }
530
+ const tok = newToken();
531
+ const pub = publishLockRecord(lockPath, makeLockRec(tok));
532
+ if (pub.status === "ok")
533
+ return makeLease(prefix, lockPath, tok);
534
+ if (pub.status === "error")
535
+ return lockDenied(`error:${pub.code}`);
536
+ if (pub.status === "retry")
537
+ continue;
538
+ const cur = readLockRecord(lockPath);
539
+ if (cur === "absent")
540
+ continue; // released meanwhile
541
+ if (cur === "corrupt")
542
+ return lockDenied("dead-undecidable"); // fail closed
543
+ const live = livenessOf(cur, self);
544
+ if (live !== "dead")
545
+ return lockDenied(live === "live" ? "busy" : "dead-undecidable");
546
+ const next = succeedDeadToken(prefix, lockPath, cur.token, hooks, round);
547
+ if (next !== "retry")
548
+ return next;
549
+ }
550
+ return lockDenied("busy");
551
+ }
552
+ /**
553
+ * opus `succeed`: the owner of g is certainly dead. Walk the one-shot chain
554
+ * SUCC(g) -> SUCC(r0) -> ...; publishing a link elects the sole live
555
+ * successor (Lemma B). "retry" means the chain settled meanwhile (re-read).
556
+ */
557
+ function succeedDeadToken(prefix, lockPath, g, hooks, round) {
558
+ const self = me();
559
+ let t = g;
560
+ for (let depth = 0; depth < PREFIX_LOCK_MAX_CHAIN; depth++) {
561
+ const tok = newToken();
562
+ const pub = publishLockRecord(succPathFor(lockPath, t), makeLockRec(tok, g));
563
+ if (pub.status === "ok") {
564
+ invokeLockHook(hooks.afterPublishSucc, {
565
+ prefix,
566
+ lockPath,
567
+ path: succPathFor(lockPath, t),
568
+ token: tok,
569
+ target: g,
570
+ round,
571
+ depth
572
+ });
573
+ return retireDeadToken(prefix, lockPath, g, hooks, round, depth);
574
+ }
575
+ if (pub.status === "error")
576
+ return lockDenied(`error:${pub.code}`);
577
+ if (pub.status === "retry")
578
+ return "retry";
579
+ const s = readLockRecord(succPathFor(lockPath, t));
580
+ if (s === "absent")
581
+ return "retry"; // chain already settled
582
+ if (s === "corrupt" || s.target !== g)
583
+ return lockDenied("dead-undecidable");
584
+ const live = livenessOf(s, self);
585
+ if (live !== "dead")
586
+ return lockDenied(live === "live" ? "busy" : "dead-undecidable");
587
+ t = s.token; // it died: succeed it
588
+ }
589
+ // Chain too deep: fail closed (diagnostic-only; no sink at this layer).
590
+ return lockDenied("dead-undecidable");
591
+ }
592
+ /**
593
+ * opus `retire`: targeted removal (S) of exactly g (Lemma C), then purge the
594
+ * now-inert SUCC files targeting g (I5), then publish a fresh token.
595
+ */
596
+ function retireDeadToken(prefix, lockPath, g, hooks, round, depth) {
597
+ const cur = readLockRecord(lockPath);
598
+ if (cur === "corrupt")
599
+ return lockDenied("dead-undecidable"); // keep our SUCC: fail closed
600
+ if (cur !== "absent" && cur.token === g) {
601
+ invokeLockHook(hooks.beforeRetireUnlink, {
602
+ prefix,
603
+ lockPath,
604
+ path: lockPath,
605
+ target: g,
606
+ round,
607
+ depth
608
+ });
609
+ // Lemma C (targeted removal), hardened: re-read LOCK as the LAST step before the
610
+ // unlink and remove it ONLY while it is STILL exactly g. Successor uniqueness
611
+ // (Lemma B) + monotonicity (I2) prove LOCK cannot legally change from g under this
612
+ // sole successor, so in production this re-read always confirms g. It is the
613
+ // defense-in-depth that also holds under manual intervention / a fresh owner /
614
+ // corruption appearing in this window: such a LOCK bears a different token (or is
615
+ // corrupt) and is NEVER deleted — we only ever unlink a lock we still own.
616
+ const now = readLockRecord(lockPath);
617
+ if (now === "corrupt")
618
+ return lockDenied("dead-undecidable"); // keep our SUCC: fail closed
619
+ if (now !== "absent" && now.token !== g) {
620
+ // A different owner appeared in the window: our SUCC(g) is inert. Purge it and
621
+ // retry against the new LOCK — never delete a lock we do not own.
622
+ purgeSuccession(prefix, lockPath, g);
623
+ return "retry";
624
+ }
625
+ if (now !== "absent") {
626
+ // now.token === g: safe to remove exactly g.
627
+ let unlinkCode;
628
+ try {
629
+ unlinkSync(lockPath); // (S) removes exactly g (Lemma C)
630
+ }
631
+ catch (e) {
632
+ unlinkCode = errnoOf(e);
633
+ }
634
+ invokeLockHook(hooks.afterRetireUnlink, {
635
+ prefix,
636
+ lockPath,
637
+ path: lockPath,
638
+ target: g,
639
+ round,
640
+ depth
641
+ });
642
+ // LOCK != g not established: keep our SUCC file, fail closed.
643
+ if (unlinkCode !== undefined && unlinkCode !== "ENOENT") {
644
+ return lockDenied(`error:${unlinkCode}`);
645
+ }
646
+ }
647
+ // now === "absent": g already removed by someone else; fall through to publish.
648
+ }
649
+ // From here LOCK != g forever (I2): every SUCC targeting g is inert.
650
+ purgeSuccession(prefix, lockPath, g); // unlink SUCC files whose target === g
651
+ const tok = newToken();
652
+ const pub = publishLockRecord(lockPath, makeLockRec(tok));
653
+ if (pub.status === "ok")
654
+ return makeLease(prefix, lockPath, tok);
655
+ if (pub.status === "exists" || pub.status === "retry")
656
+ return "retry";
657
+ return lockDenied(`error:${pub.code}`);
658
+ }
659
+ /**
660
+ * opus `lease`: conditional release — unlink only when LOCK == tok (I2/Lemma
661
+ * A: while we live, LOCK == tok is stable). Runs on process exit too.
662
+ */
663
+ function makeLease(prefix, lockPath, token) {
664
+ let done = false;
665
+ const release = () => {
666
+ if (done)
667
+ return;
668
+ done = true;
669
+ try {
670
+ process.off("exit", release);
671
+ }
672
+ catch {
673
+ // best-effort
674
+ }
675
+ const cur = readLockRecord(lockPath);
676
+ if (cur !== "absent" && cur !== "corrupt" && cur.token === token) {
677
+ try {
678
+ unlinkSync(lockPath); // (R) safe: LOCK == tok stable while we live
679
+ }
680
+ catch {
681
+ // best-effort
682
+ }
683
+ }
684
+ // else: lock lost — unreachable under the invariants (Lemma A).
685
+ };
686
+ try {
687
+ process.on("exit", release);
688
+ }
689
+ catch {
690
+ // best-effort
691
+ }
692
+ collectLockDebris(prefix, lockPath, token); // holder-only GC (I5-safe)
693
+ return { acquired: true, release, token };
694
+ }
695
+ /** Unlink SUCC files whose target === g (called only when LOCK != g can hold). */
696
+ function purgeSuccession(prefix, lockPath, g) {
697
+ let names;
698
+ try {
699
+ names = readdirSync(prefix);
700
+ }
701
+ catch {
702
+ return;
703
+ }
704
+ const base = basename(lockPath);
705
+ for (const n of names) {
706
+ if (!n.startsWith(`${base}.succ.`))
707
+ continue;
708
+ if (n.includes(".tmp."))
709
+ continue; // C2: never treat a tmp as a SUCC record
710
+ const full = join(prefix, n);
711
+ const rec = readLockRecord(full);
712
+ if (rec !== "absent" && rec !== "corrupt" && rec.target === g) {
713
+ try {
714
+ unlinkSync(full);
715
+ }
716
+ catch {
717
+ // best-effort
718
+ }
719
+ }
720
+ }
721
+ }
722
+ /**
723
+ * Holder-only GC (I5-safe: our token is the current LOCK value, so SUCC files
724
+ * targeting anything else are inert; TMP files are never lock state).
725
+ */
726
+ function collectLockDebris(prefix, lockPath, token) {
727
+ let names;
728
+ try {
729
+ names = readdirSync(prefix);
730
+ }
731
+ catch {
732
+ return;
733
+ }
734
+ const base = basename(lockPath);
735
+ const now = Date.now();
736
+ for (const n of names) {
737
+ const full = join(prefix, n);
738
+ if (n.startsWith(`${base}.succ.`) && !n.includes(".tmp.")) {
739
+ const rec = readLockRecord(full);
740
+ if (rec !== "absent" && rec !== "corrupt" && rec.target !== token) {
741
+ try {
742
+ unlinkSync(full);
743
+ }
744
+ catch {
745
+ // best-effort
746
+ }
747
+ }
748
+ }
749
+ else if (n.startsWith(base) && n.includes(".tmp.")) {
750
+ // TMP debris (including legacy `.succ.*.tmp.*` names), never lock state.
751
+ try {
752
+ const age = now - statSync(full).mtimeMs;
753
+ if (age > PREFIX_LOCK_TMP_DEBRIS_MAX_AGE_MS) {
754
+ try {
755
+ unlinkSync(full);
756
+ }
757
+ catch {
758
+ // best-effort
759
+ }
760
+ }
761
+ }
762
+ catch {
763
+ // best-effort: leave what cannot be stated
764
+ }
765
+ }
766
+ }
767
+ }
768
+ /** True when the path is older than the threshold; false when unstated. */
769
+ export function isOlderThan(path, now, maxAgeMs) {
770
+ try {
771
+ return now - statSync(path).mtimeMs > maxAgeMs;
772
+ }
773
+ catch {
774
+ return false;
775
+ }
776
+ }
777
+ //# sourceMappingURL=succession-lock.js.map