@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.
@@ -26,12 +26,11 @@
26
26
  * atomic publication via `link(2)` (I1), a no-false-positive liveness predicate
27
27
  * (I3), a one-shot `SUCC(t)` succession chain (Lemmas B/C), token monotonicity
28
28
  * (I2). No age or mtime ever decides acquisition — `at` is diagnostic only
29
- * (I7). See the lock section below.
29
+ * (I7). See `../local-files/succession-lock.ts`.
30
30
  */
31
31
  import { randomBytes, randomUUID } from "node:crypto";
32
32
  import { spawnSync } from "node:child_process";
33
- import { closeSync, existsSync, mkdirSync, fsyncSync, linkSync, openSync, readdirSync, readFileSync, readlinkSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync, writeSync } from "node:fs";
34
- import { hostname } from "node:os";
33
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, statSync, unlinkSync, writeFileSync } from "node:fs";
35
34
  import { basename, dirname, join, resolve as resolvePath } from "node:path";
36
35
  import { fileURLToPath } from "node:url";
37
36
  export const H2A_CLI_PACKAGE = "@sentropic/h2a";
@@ -226,763 +225,12 @@ function readJsonIfExists(path) {
226
225
  }
227
226
  }
228
227
  // ---------------------------------------------------------------------------
229
- // Prefix lock (v4): single-machine succession protocol.
230
- //
231
- // At most one holder (proof sketch in the design notes):
232
- // - I1 (atomic publication): a name appears only with complete, durable
233
- // content — tmp file (wx + write + fsync + close) then `link(2)`; a reader
234
- // sees ENOENT or a full record, so unreadable content means corruption
235
- // (fail closed), never a torn write. A FS without hard links fails closed.
236
- // - I2 (monotonicity): a retired token never reappears; LOCK != g is forever.
237
- // - I3 (predicate): `isCertainlyDead` has no false positive; death is stable.
238
- // - I4: only (R) the live owner releasing, or (S) a successor that created its
239
- // SUCC then re-read LOCK == g, can remove LOCK == g.
240
- // - I5: no SUCC targeting g is removed while LOCK == g.
241
- // - I6: SUCC(t) always targets the same g (created after t judged dead).
242
- // - I7: no age or mtime in any acquisition decision; `at` is diagnostic only.
243
- // Ages below are used solely for debris GC, never to break a lock.
244
- //
245
- // Lemma A (stability): while live holder P holds p, LOCK == p — removing it
246
- // would require SUCC(p), which requires P certainly dead. Lemma B (unique
247
- // successor): while LOCK == g the chain g -> r0 -> r1 ... is linear, so only
248
- // the last link can be live. Lemma C (targeted unlink): a successor that read
249
- // LOCK == g removes g, since no other live successor exists (Lemma B) and any
250
- // earlier one would already have made LOCK != g (I2).
251
- //
252
- // H1: the prefix is on a single machine's local FS; `link(2)` is atomic and
253
- // returns EEXIST when the name exists. H2: the recorded process runs the
254
- // shared mutation (swap) for the whole critical section; children (npm) only
255
- // touch a per-token private staging dir. H3: tokens are random (>= 96 bits)
256
- // and never republished.
257
- /**
258
- * R2 staleness-alert threshold. An upgrade held under the lock completes in seconds to
259
- * a few minutes (bounded tarball fetch + stage + atomic swap), so a lock far older than
260
- * that whose holder reads "live" ONLY for want of a comparable start time is worth
261
- * surfacing (a reused PID may be masking a dead holder). Set well above any legitimate
262
- * hold. It NEVER triggers a reclaim — purely diagnostic (I7: `at` informs, never decides).
263
- */
264
- export const STALE_LOCK_ALERT_MS = 30 * 60 * 1000;
265
- /** Acquire rounds before giving up (a retry means a rival won meanwhile). */
266
- export const PREFIX_LOCK_MAX_ROUNDS = 3;
267
- /** Succession chain depth before failing closed with a diagnostic. */
268
- export const PREFIX_LOCK_MAX_CHAIN = 8;
269
- /** Holder-only GC age for abandoned TMP files (debris, never a decision). */
270
- export const PREFIX_LOCK_TMP_DEBRIS_MAX_AGE_MS = 60 * 60 * 1000;
271
- /** Fallback age for the residue sweep when no owner can be identified. */
272
- export const UPGRADE_RESIDUE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
273
- /** Tokens are hex (plus -/_ tolerance); anything else in a record is corruption. */
274
- const LOCK_TOKEN_RE = /^[A-Za-z0-9_-]{12,128}$/;
275
- function lockPathFor(prefix) {
276
- return join(prefix, ".h2a-upgrade.lock");
277
- }
278
- function succPathFor(lockPath, t) {
279
- return `${lockPath}.succ.${t}`;
280
- }
281
- function tmpPathFor(path, t) {
282
- // C2: the publish tmp must never live in the `.succ.` namespace, even when
283
- // publishing a SUCC record. Derive it from the LOCK base so sweeps that
284
- // match real SUCC records never collect a tmp before its link(2).
285
- const idx = path.indexOf(".succ.");
286
- const base = idx >= 0 ? path.slice(0, idx) : path;
287
- return `${base}.tmp.${t}`;
288
- }
289
- function errnoOf(e) {
290
- const code = e?.code;
291
- return typeof code === "string" && code.length > 0 ? code : "EIO";
292
- }
293
- // N3: `at` is validated only as a finite number (parseLockRec), so it may be out of
294
- // Date's representable range; `new Date(at).toISOString()` would throw a RangeError.
295
- // Never let a diagnostic string crash the caller (at boot the exception would swallow
296
- // the whole M-2 alarm; `h2a upgrade` would abort). Fall back to the raw number.
297
- function safeAtIso(at) {
298
- const ms = Number(at);
299
- if (Number.isFinite(ms) && Math.abs(ms) <= 8.64e15) {
300
- try {
301
- return new Date(ms).toISOString();
302
- }
303
- catch {
304
- // fall through
305
- }
306
- }
307
- return `epoch-ms:${at}`;
308
- }
309
- function readHostId() {
310
- try {
311
- const v = readFileSync("/etc/machine-id", "utf8").trim();
312
- if (v)
313
- return v;
314
- }
315
- catch {
316
- // fall through to hostname
317
- }
318
- try {
319
- const h = hostname().trim();
320
- if (h)
321
- return h;
322
- }
323
- catch {
324
- // fall through to sentinel
325
- }
326
- return "unknown-host";
327
- }
328
- function readBootId() {
329
- try {
330
- const v = readFileSync("/proc/sys/kernel/random/boot_id", "utf8").trim();
331
- if (v)
332
- return v;
333
- }
334
- catch {
335
- // not Linux: fall through to sysctl below
336
- }
337
- // Outside Linux prefer the stable session UUID (TZ-independent) when present.
338
- try {
339
- const r = spawnSync("sysctl", ["-n", "kern.bootsessionuuid"], {
340
- encoding: "utf8",
341
- timeout: 2000,
342
- env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
343
- });
344
- const v = (r.stdout ?? "").trim();
345
- if (r.status === 0 && v)
346
- return v;
347
- }
348
- catch {
349
- // best-effort
350
- }
351
- try {
352
- const r = spawnSync("sysctl", ["-n", "kern.boottime"], {
353
- encoding: "utf8",
354
- timeout: 2000,
355
- env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
356
- });
357
- const v = (r.stdout ?? "").trim();
358
- if (r.status === 0 && v)
359
- return v;
360
- }
361
- catch {
362
- // best-effort
363
- }
364
- return null;
365
- }
366
- // B2 decomposition: distinguish "this platform has no namespaces" (a KNOWN fact —
367
- // one space per host) from "the namespace exists but is unreadable" (a genuine
368
- // unknown). Only the latter is null; the former is the sentinel "host" so same-host
369
- // liveness stays decidable (macOS/Windows/BSD). `platform`/`readLink` are injected
370
- // only by tests (macOS/Windows/no-/proc sims); production passes none.
371
- //
372
- // readPidNs is the CONSERVATIVE gate that decides reclaim at all: an unknown (null)
373
- // pid namespace makes even a PID-absent holder undecidable, because "absent" in an
374
- // unknown namespace proves nothing. Any Linux /proc failure → null, never a false
375
- // "host" that could match a foreign namespace.
376
- export function readPidNs(platform = process.platform, readLink = readlinkSync) {
377
- if (platform !== "linux")
378
- return "host";
379
- try {
380
- return readLink("/proc/self/ns/pid");
381
- }
382
- catch {
383
- return null; // the namespace exists here but is unreadable: genuine unknown
384
- }
385
- }
386
- // The reader's time namespace (Linux): /proc/<pid>/stat starttime is expressed
387
- // relative to it, so two lanes in different time namespaces read different values
388
- // for the same live process. timeNs is consulted ONLY to gate the start-time
389
- // COMPARISON (the PID-present branch of livenessOf); the PID-absent incident branch
390
- // never needs it. So this reader is SYMMETRIC with readPidNs on purpose — no ENOENT
391
- // special-case: a genuinely masked /proc (e.g. gVisor exposing ns/pid but not
392
- // ns/time while time namespaces are in use) must yield null (⇒ "start not
393
- // comparable ⇒ live"), never a false "host" that would compare across time bases
394
- // and risk a false death (the B-1 class). null here is safe and quiet, not a wedge.
395
- export function readTimeNs(platform = process.platform, readLink = readlinkSync) {
396
- if (platform !== "linux")
397
- return "host";
398
- try {
399
- return readLink("/proc/self/ns/time");
400
- }
401
- catch {
402
- return null; // no readable time namespace: start times are not comparable
403
- }
404
- }
405
- // Trust /proc for start/state only when it maps to THIS process's pid namespace.
406
- // Under `unshare --pid` without `--mount-proc`, `kill` targets the right process
407
- // but `/proc/<pid>` describes another — a false start mismatch or zombie.
408
- function procMapsToSelf() {
409
- try {
410
- return readlinkSync("/proc/self") === String(process.pid);
411
- }
412
- catch {
413
- return false;
414
- }
415
- }
416
- /**
417
- * Process start identity, prefixed by its source ("proc:" from Linux /proc
418
- * field 22, "ps:" from `ps lstart`). The prefix is part of the stored value
419
- * so a reader using a different source never concludes "dead" from a
420
- * format/TZ-fragile comparison (C1).
421
- */
422
- // `platform`/`mapsToSelf` are injected only by tests (mis-mapped-/proc / non-Linux
423
- // sims); production passes none.
424
- export function procStartInfo(pid, platform = process.platform, mapsToSelf = procMapsToSelf) {
425
- // A start time is only trusted from a source whose value is STABLE for the life of the
426
- // process (never moving under a wall-clock step), else a clock jump while the lock is held
427
- // could make a live holder's start "differ" ⇒ a false death ⇒ two holders (I3).
428
- // - Linux: /proc field 22 (proc:) is the only trusted source.
429
- // - B3: a mis-mapped /proc (unshare --pid without --mount-proc) makes both /proc AND
430
- // `ps` (procps reads /proc/<pid>) describe another namespace's process ⇒ undatable.
431
- // - N5: `ps` lstart under Linux derives from btime + starttime and moves on a clock step,
432
- // so it is not stable either. So under Linux: /proc when it maps to us, else undefined.
433
- // - darwin: `ps lstart` is the ABSOLUTE fork wall-clock time (p_starttime), stable ⇒ trusted.
434
- // - R-BSD: on FreeBSD/OpenBSD `ps` start is boot-relative and its boot time is re-derived on
435
- // a clock step, so it is NOT stable. Every other non-Linux platform (incl. Windows, no ps)
436
- // ⇒ undefined (undatable ⇒ live). In doubt, never dead.
437
- if (platform === "linux") {
438
- if (!mapsToSelf())
439
- return undefined;
440
- try {
441
- const s = readFileSync(`/proc/${pid}/stat`, "utf8");
442
- const close = s.lastIndexOf(")");
443
- if (close >= 0) {
444
- const after = s.slice(close + 1).trim().split(/\s+/);
445
- // after[0] is state (field 3); after[19] is starttime (field 22).
446
- if (after.length >= 20 && after[0] && after[19]) {
447
- return { state: after[0], start: `proc:${after[19]}` };
448
- }
449
- }
450
- }
451
- catch {
452
- // no readable /proc for this pid: undatable
453
- }
454
- return undefined;
455
- }
456
- if (platform !== "darwin")
457
- return undefined; // only darwin ps is a stable source
458
- try {
459
- const r = spawnSync("ps", ["-o", "lstart=", "-p", String(pid)], {
460
- encoding: "utf8",
461
- timeout: 5000,
462
- env: { ...process.env, LC_ALL: "C", TZ: "UTC0" }
463
- });
464
- const v = (r.stdout ?? "").trim();
465
- if (r.status === 0 && v)
466
- return { start: `ps:${v}` };
467
- }
468
- catch {
469
- // best-effort
470
- }
471
- return undefined;
472
- }
473
- function startSource(s) {
474
- if (s.startsWith("proc:"))
475
- return "proc";
476
- if (s.startsWith("ps:"))
477
- return "ps";
478
- return "legacy";
479
- }
480
- let ME_CACHE;
481
- /** This process's identity, computed once and memoized. */
482
- function me() {
483
- ME_CACHE ??= {
484
- host: readHostId(),
485
- boot: readBootId(),
486
- ns: readPidNs(),
487
- timeNs: readTimeNs(),
488
- pid: process.pid,
489
- start: procStartInfo(process.pid)?.start ?? null
490
- };
491
- return ME_CACHE;
492
- }
493
- /** Fresh random token, >= 96 bits, never republished (H3). */
494
- function newToken() {
495
- return randomBytes(16).toString("hex");
496
- }
497
- function makeLockRec(token, target) {
498
- const self = me();
499
- return {
500
- host: self.host,
501
- boot: self.boot,
502
- ns: self.ns,
503
- timeNs: self.timeNs,
504
- pid: self.pid,
505
- start: self.start,
506
- token,
507
- ...(target !== undefined ? { target } : {}),
508
- at: Date.now()
509
- };
510
- }
511
- function parseLockRec(raw) {
512
- if (typeof raw !== "object" || raw === null)
513
- throw new Error("bad lock record");
514
- const o = raw;
515
- const { host, boot, ns, timeNs, pid, start, token, target, at } = o;
516
- // timeNs is a required field like host/boot/ns/pid/start: an absent field is a
517
- // malformed record → corrupt → fail-closed. No back-compat exception is carved
518
- // for a "v4 without timeNs" — the redesign was never published, so no such lock
519
- // exists outside fixtures (kept in step with makeLockRec). The VALUE may be null
520
- // (genuine unknown time namespace); the liveness guard then treats the proc start
521
- // as undatable ⇒ "live" (never reclaim, never a false death), not undecidable.
522
- if (typeof host !== "string" || host.length === 0)
523
- throw new Error("bad host");
524
- if (boot !== null && typeof boot !== "string")
525
- throw new Error("bad boot");
526
- if (ns !== null && typeof ns !== "string")
527
- throw new Error("bad ns");
528
- if (timeNs !== null && typeof timeNs !== "string")
529
- throw new Error("bad timeNs");
530
- if (typeof pid !== "number" || !Number.isInteger(pid) || pid <= 0)
531
- throw new Error("bad pid");
532
- if (start !== null && typeof start !== "string")
533
- throw new Error("bad start");
534
- if (typeof token !== "string" || !LOCK_TOKEN_RE.test(token))
535
- throw new Error("bad token");
536
- if (target !== undefined && typeof target !== "string")
537
- throw new Error("bad target");
538
- if (typeof at !== "number" || !Number.isFinite(at))
539
- throw new Error("bad at");
540
- return {
541
- host,
542
- boot,
543
- ns,
544
- timeNs,
545
- pid,
546
- start,
547
- token,
548
- ...(target !== undefined ? { target } : {}),
549
- at
550
- };
551
- }
552
- /** opus `read`: ENOENT -> absent, anything else unreadable -> corrupt (I1). */
553
- function readLockRecord(path) {
554
- try {
555
- return parseLockRec(JSON.parse(readFileSync(path, "utf8")));
556
- }
557
- catch (e) {
558
- return e?.code === "ENOENT" ? "absent" : "corrupt";
559
- }
560
- }
561
- /**
562
- * opus `publish` (I1): a name appears only with complete, durable content.
563
- * Write tmp (wx + byte-count-checked write + fsync + close), then `link(2)`;
564
- * EEXIST -> "exists", ENOENT at link -> "retry" (tmp reaped before link),
565
- * any other failure -> "error" with the errno (no hard links -> fail closed).
566
- * The tmp never contains `.succ.` (C2).
567
- */
568
- function publishLockRecord(path, rec) {
569
- const tmp = tmpPathFor(path, rec.token);
570
- try {
571
- const data = JSON.stringify(rec);
572
- const fd = openSync(tmp, "wx", 0o644);
573
- let truncated = false;
574
- try {
575
- const written = writeSync(fd, data);
576
- if (written !== Buffer.byteLength(data)) {
577
- truncated = true;
578
- }
579
- else {
580
- fsyncSync(fd);
581
- }
582
- }
583
- finally {
584
- try {
585
- closeSync(fd);
586
- }
587
- catch {
588
- // best-effort
589
- }
590
- }
591
- if (truncated) {
592
- try {
593
- unlinkSync(tmp);
594
- }
595
- catch {
596
- // best-effort
597
- }
598
- return { status: "error", code: "ENOSPC" };
599
- }
600
- }
601
- catch (e) {
602
- try {
603
- unlinkSync(tmp);
604
- }
605
- catch {
606
- // best-effort
607
- }
608
- return { status: "error", code: errnoOf(e) };
609
- }
610
- try {
611
- linkSync(tmp, path);
612
- return { status: "ok" };
613
- }
614
- catch (e) {
615
- const code = errnoOf(e);
616
- if (code === "EEXIST")
617
- return { status: "exists" };
618
- if (code === "ENOENT")
619
- return { status: "retry" };
620
- return { status: "error", code };
621
- }
622
- finally {
623
- try {
624
- unlinkSync(tmp);
625
- }
626
- catch {
627
- // best-effort
628
- }
629
- }
630
- }
631
- /**
632
- * Single liveness classifier. `livenessOf` and `isCertainlyDead` are exactly its
633
- * "verdict"/"dead" arm, and the R2 staleness alert reads its `datable` bit, so none
634
- * of them can drift from this one decision. Never derives "dead" from a fragile
635
- * comparison: a source mismatch or an unknown/differing time namespace both yield a
636
- * safe "live" (undatable), and a corrupt "legacy" start yields "undecidable" (C1).
637
- */
638
- function classifyLiveness(r, self, deps = {}) {
639
- const platform = deps.platform ?? process.platform;
640
- const probe = deps.probe ?? procStartInfo;
641
- if (r.host !== self.host)
642
- return { verdict: "undecidable", datable: false }; // other machine
643
- // B1: only a Linux boot_id is a stable, trustworthy boot identity, so a difference
644
- // there is a previous boot ⇒ dead. Off Linux a boot difference must NOT short-circuit
645
- // to undecidable (that wedged a Mac rebooted mid-lock forever); fall through to kill(0)
646
- // (PID absent ⇒ dead, on every platform) then the start comparison (only conclusive
647
- // where the start is datable — Linux /proc and darwin ps; on BSD/Windows it is undatable
648
- // ⇒ live, so after a reboot a reused PID blocks until it exits, surfaced by R2).
649
- if (r.boot && self.boot && r.boot !== self.boot && platform === "linux") {
650
- return { verdict: "dead", datable: false };
651
- }
652
- // An unreadable namespace on EITHER side is a genuine unknown — two records with a
653
- // null namespace must never be treated as co-located (null-equality).
654
- if (r.ns === null || self.ns === null)
655
- return { verdict: "undecidable", datable: false };
656
- if (r.ns !== self.ns)
657
- return { verdict: "undecidable", datable: false }; // other, known ns
658
- // From here the namespace is known AND shared, so a PID's absence is conclusive.
659
- let exists = false;
660
- try {
661
- process.kill(r.pid, 0);
662
- exists = true;
663
- }
664
- catch (e) {
665
- const code = errnoOf(e);
666
- // PID absent in a known, shared namespace ⇒ dead, with certainty, no start time.
667
- if (code === "ESRCH")
668
- return { verdict: "dead", datable: false };
669
- if (code === "EPERM")
670
- exists = true; // exists, no permission: keep checking
671
- else
672
- return { verdict: "undecidable", datable: false };
673
- }
674
- const p = probe(r.pid); // undefined when unknown
675
- if (p?.state === "Z")
676
- return { verdict: "dead", datable: false }; // zombie never runs again
677
- if (r.start !== null && p?.start !== undefined) {
678
- const rSrc = startSource(r.start);
679
- const pSrc = startSource(p.start);
680
- // A "legacy" (malformed, pre-source-prefix) start is not a trustworthy value ⇒
681
- // undecidable (fail closed). Any other source mismatch (proc vs ps) is simply not
682
- // comparable ⇒ undatable ⇒ live.
683
- if (rSrc === "legacy" || pSrc === "legacy")
684
- return { verdict: "undecidable", datable: false };
685
- if (rSrc !== pSrc)
686
- return { verdict: "live", datable: false };
687
- // proc starttime is expressed relative to the reader's time namespace, so a
688
- // proc-sourced comparison is only meaningful when both time namespaces are KNOWN and
689
- // EQUAL. Otherwise the recorded start is UNDATABLE ⇒ "live" — never reclaim, never a
690
- // false death, never a false M-2 alarm. This never wedges an incident: the PID-absent
691
- // branch concluded "dead" without a start. The only cost is a genuinely-reused PID
692
- // left alive until it exits — rare, bounded, and surfaced by the R2 staleness alert.
693
- // A ps: start now arises ONLY on darwin (procStartInfo trusts ps only there), where
694
- // lstart is the absolute fork wall-clock (TZ-normalised), stable ⇒ it needs no time gate.
695
- if (rSrc === "proc" && (r.timeNs === null || self.timeNs === null || r.timeNs !== self.timeNs)) {
696
- return { verdict: "live", datable: false };
697
- }
698
- return p.start !== r.start
699
- ? { verdict: "dead", datable: true } // PID reused (a confirmed different start)
700
- : { verdict: "live", datable: true }; // same, confirmed process
701
- }
702
- // Present but with an undatable start ⇒ live: never reclaim a live-or-unknown holder.
703
- return { verdict: exists ? "live" : "undecidable", datable: false };
704
- }
705
- export function livenessOf(r, self, deps = {}) {
706
- return classifyLiveness(r, self, deps).verdict;
707
- }
708
- /** No false positive: true implies certainly dead; any doubt is alive (I3). */
709
- function isCertainlyDead(r) {
710
- return classifyLiveness(r, me()).verdict === "dead";
711
- }
712
- function lockDenied(reason) {
713
- return { acquired: false, release: () => { }, reason };
714
- }
715
- function invokeLockHook(fn, ctx) {
716
- if (!fn)
717
- return;
718
- try {
719
- fn(ctx);
720
- }
721
- catch {
722
- // Observation-only: a test hook must never break the protocol.
723
- }
724
- }
725
- /** opus `acquirePrefixLock`, plus M5 reasons and test-only critical hooks. */
726
- export function acquirePrefixLock(prefix, hooks = {}) {
727
- const lockPath = lockPathFor(prefix);
728
- try {
729
- mkdirSync(prefix, { recursive: true });
730
- }
731
- catch (e) {
732
- return lockDenied(`error:${errnoOf(e)}`);
733
- }
734
- const self = me();
735
- for (let round = 0; round < PREFIX_LOCK_MAX_ROUNDS; round++) {
736
- if (round === 0) {
737
- invokeLockHook(hooks.beforePublishLock, { prefix, lockPath, path: lockPath, round, depth: 0 });
738
- }
739
- const tok = newToken();
740
- const pub = publishLockRecord(lockPath, makeLockRec(tok));
741
- if (pub.status === "ok")
742
- return makeLease(prefix, lockPath, tok);
743
- if (pub.status === "error")
744
- return lockDenied(`error:${pub.code}`);
745
- if (pub.status === "retry")
746
- continue;
747
- const cur = readLockRecord(lockPath);
748
- if (cur === "absent")
749
- continue; // released meanwhile
750
- if (cur === "corrupt")
751
- return lockDenied("dead-undecidable"); // fail closed
752
- const live = livenessOf(cur, self);
753
- if (live !== "dead")
754
- return lockDenied(live === "live" ? "busy" : "dead-undecidable");
755
- const next = succeedDeadToken(prefix, lockPath, cur.token, hooks, round);
756
- if (next !== "retry")
757
- return next;
758
- }
759
- return lockDenied("busy");
760
- }
761
- /**
762
- * opus `succeed`: the owner of g is certainly dead. Walk the one-shot chain
763
- * SUCC(g) -> SUCC(r0) -> ...; publishing a link elects the sole live
764
- * successor (Lemma B). "retry" means the chain settled meanwhile (re-read).
765
- */
766
- function succeedDeadToken(prefix, lockPath, g, hooks, round) {
767
- const self = me();
768
- let t = g;
769
- for (let depth = 0; depth < PREFIX_LOCK_MAX_CHAIN; depth++) {
770
- const tok = newToken();
771
- const pub = publishLockRecord(succPathFor(lockPath, t), makeLockRec(tok, g));
772
- if (pub.status === "ok") {
773
- invokeLockHook(hooks.afterPublishSucc, {
774
- prefix,
775
- lockPath,
776
- path: succPathFor(lockPath, t),
777
- token: tok,
778
- target: g,
779
- round,
780
- depth
781
- });
782
- return retireDeadToken(prefix, lockPath, g, hooks, round, depth);
783
- }
784
- if (pub.status === "error")
785
- return lockDenied(`error:${pub.code}`);
786
- if (pub.status === "retry")
787
- return "retry";
788
- const s = readLockRecord(succPathFor(lockPath, t));
789
- if (s === "absent")
790
- return "retry"; // chain already settled
791
- if (s === "corrupt" || s.target !== g)
792
- return lockDenied("dead-undecidable");
793
- const live = livenessOf(s, self);
794
- if (live !== "dead")
795
- return lockDenied(live === "live" ? "busy" : "dead-undecidable");
796
- t = s.token; // it died: succeed it
797
- }
798
- // Chain too deep: fail closed (diagnostic-only; no sink at this layer).
799
- return lockDenied("dead-undecidable");
800
- }
801
- /**
802
- * opus `retire`: targeted removal (S) of exactly g (Lemma C), then purge the
803
- * now-inert SUCC files targeting g (I5), then publish a fresh token.
804
- */
805
- function retireDeadToken(prefix, lockPath, g, hooks, round, depth) {
806
- const cur = readLockRecord(lockPath);
807
- if (cur === "corrupt")
808
- return lockDenied("dead-undecidable"); // keep our SUCC: fail closed
809
- if (cur !== "absent" && cur.token === g) {
810
- invokeLockHook(hooks.beforeRetireUnlink, {
811
- prefix,
812
- lockPath,
813
- path: lockPath,
814
- target: g,
815
- round,
816
- depth
817
- });
818
- // Lemma C (targeted removal), hardened: re-read LOCK as the LAST step before the
819
- // unlink and remove it ONLY while it is STILL exactly g. Successor uniqueness
820
- // (Lemma B) + monotonicity (I2) prove LOCK cannot legally change from g under this
821
- // sole successor, so in production this re-read always confirms g. It is the
822
- // defense-in-depth that also holds under manual intervention / a fresh owner /
823
- // corruption appearing in this window: such a LOCK bears a different token (or is
824
- // corrupt) and is NEVER deleted — we only ever unlink a lock we still own.
825
- const now = readLockRecord(lockPath);
826
- if (now === "corrupt")
827
- return lockDenied("dead-undecidable"); // keep our SUCC: fail closed
828
- if (now !== "absent" && now.token !== g) {
829
- // A different owner appeared in the window: our SUCC(g) is inert. Purge it and
830
- // retry against the new LOCK — never delete a lock we do not own.
831
- purgeSuccession(prefix, lockPath, g);
832
- return "retry";
833
- }
834
- if (now !== "absent") {
835
- // now.token === g: safe to remove exactly g.
836
- let unlinkCode;
837
- try {
838
- unlinkSync(lockPath); // (S) removes exactly g (Lemma C)
839
- }
840
- catch (e) {
841
- unlinkCode = errnoOf(e);
842
- }
843
- invokeLockHook(hooks.afterRetireUnlink, {
844
- prefix,
845
- lockPath,
846
- path: lockPath,
847
- target: g,
848
- round,
849
- depth
850
- });
851
- // LOCK != g not established: keep our SUCC file, fail closed.
852
- if (unlinkCode !== undefined && unlinkCode !== "ENOENT") {
853
- return lockDenied(`error:${unlinkCode}`);
854
- }
855
- }
856
- // now === "absent": g already removed by someone else; fall through to publish.
857
- }
858
- // From here LOCK != g forever (I2): every SUCC targeting g is inert.
859
- purgeSuccession(prefix, lockPath, g); // unlink SUCC files whose target === g
860
- const tok = newToken();
861
- const pub = publishLockRecord(lockPath, makeLockRec(tok));
862
- if (pub.status === "ok")
863
- return makeLease(prefix, lockPath, tok);
864
- if (pub.status === "exists" || pub.status === "retry")
865
- return "retry";
866
- return lockDenied(`error:${pub.code}`);
867
- }
868
- /**
869
- * opus `lease`: conditional release — unlink only when LOCK == tok (I2/Lemma
870
- * A: while we live, LOCK == tok is stable). Runs on process exit too.
871
- */
872
- function makeLease(prefix, lockPath, token) {
873
- let done = false;
874
- const release = () => {
875
- if (done)
876
- return;
877
- done = true;
878
- try {
879
- process.off("exit", release);
880
- }
881
- catch {
882
- // best-effort
883
- }
884
- const cur = readLockRecord(lockPath);
885
- if (cur !== "absent" && cur !== "corrupt" && cur.token === token) {
886
- try {
887
- unlinkSync(lockPath); // (R) safe: LOCK == tok stable while we live
888
- }
889
- catch {
890
- // best-effort
891
- }
892
- }
893
- // else: lock lost — unreachable under the invariants (Lemma A).
894
- };
895
- try {
896
- process.on("exit", release);
897
- }
898
- catch {
899
- // best-effort
900
- }
901
- collectLockDebris(prefix, lockPath, token); // holder-only GC (I5-safe)
902
- return { acquired: true, release, token };
903
- }
904
- /** Unlink SUCC files whose target === g (called only when LOCK != g can hold). */
905
- function purgeSuccession(prefix, lockPath, g) {
906
- let names;
907
- try {
908
- names = readdirSync(prefix);
909
- }
910
- catch {
911
- return;
912
- }
913
- const base = basename(lockPath);
914
- for (const n of names) {
915
- if (!n.startsWith(`${base}.succ.`))
916
- continue;
917
- if (n.includes(".tmp."))
918
- continue; // C2: never treat a tmp as a SUCC record
919
- const full = join(prefix, n);
920
- const rec = readLockRecord(full);
921
- if (rec !== "absent" && rec !== "corrupt" && rec.target === g) {
922
- try {
923
- unlinkSync(full);
924
- }
925
- catch {
926
- // best-effort
927
- }
928
- }
929
- }
930
- }
931
- /**
932
- * Holder-only GC (I5-safe: our token is the current LOCK value, so SUCC files
933
- * targeting anything else are inert; TMP files are never lock state).
934
- */
935
- function collectLockDebris(prefix, lockPath, token) {
936
- let names;
937
- try {
938
- names = readdirSync(prefix);
939
- }
940
- catch {
941
- return;
942
- }
943
- const base = basename(lockPath);
944
- const now = Date.now();
945
- for (const n of names) {
946
- const full = join(prefix, n);
947
- if (n.startsWith(`${base}.succ.`) && !n.includes(".tmp.")) {
948
- const rec = readLockRecord(full);
949
- if (rec !== "absent" && rec !== "corrupt" && rec.target !== token) {
950
- try {
951
- unlinkSync(full);
952
- }
953
- catch {
954
- // best-effort
955
- }
956
- }
957
- }
958
- else if (n.startsWith(base) && n.includes(".tmp.")) {
959
- // TMP debris (including legacy `.succ.*.tmp.*` names), never lock state.
960
- try {
961
- const age = now - statSync(full).mtimeMs;
962
- if (age > PREFIX_LOCK_TMP_DEBRIS_MAX_AGE_MS) {
963
- try {
964
- unlinkSync(full);
965
- }
966
- catch {
967
- // best-effort
968
- }
969
- }
970
- }
971
- catch {
972
- // best-effort: leave what cannot be stated
973
- }
974
- }
975
- }
976
- }
977
- /** True when the path is older than the threshold; false when unstated. */
978
- function isOlderThan(path, now, maxAgeMs) {
979
- try {
980
- return now - statSync(path).mtimeMs > maxAgeMs;
981
- }
982
- catch {
983
- return false;
984
- }
985
- }
228
+ // Prefix lock (v4): extracted to `../local-files/succession-lock.ts` (Lot 4,
229
+ // step 1 — neutral intra-package move). Re-exported here so the module's public
230
+ // API is unchanged for existing importers (tests, cli.ts, src/index.ts).
231
+ // ---------------------------------------------------------------------------
232
+ export { STALE_LOCK_ALERT_MS, PREFIX_LOCK_MAX_ROUNDS, PREFIX_LOCK_MAX_CHAIN, PREFIX_LOCK_TMP_DEBRIS_MAX_AGE_MS, UPGRADE_RESIDUE_MAX_AGE_MS, readPidNs, readTimeNs, procStartInfo, livenessOf, acquirePrefixLock } from "../local-files/succession-lock.js";
233
+ import { STALE_LOCK_ALERT_MS, PREFIX_LOCK_TMP_DEBRIS_MAX_AGE_MS, UPGRADE_RESIDUE_MAX_AGE_MS, errnoOf, acquirePrefixLock, classifyLiveness, isOlderThan, lockPathFor, me, readLockRecord, safeAtIso } from "../local-files/succession-lock.js";
986
234
  function pidFromAttemptName(name) {
987
235
  const m = /^\.h2a-upgrade-(?:staging|tarball)-(\d+)-/.exec(name);
988
236
  if (!m)