@sentropic/h2a 0.97.7 → 0.97.8

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.
@@ -8,10 +8,31 @@
8
8
  *
9
9
  * Pure version logic + an injectable `UpgradeRuntime` (network/exec/clock/cache)
10
10
  * so everything is testable without npm or the network.
11
+ *
12
+ * Staged auto-upgrade path (no in-place `npm i -g`): fetch tarball to a staging
13
+ * area under the global prefix (zero global mutation), install self-contained
14
+ * under a staging prefix with `npm i -g --prefix` (nested deps + bin), probe
15
+ * the staged binary version, load the staged native module, then atomic rename
16
+ * swap with same-filesystem gate, rollback, and repairable marker. All side
17
+ * effects go through `UpgradeRuntime`.
18
+ *
19
+ * The installed layout is NESTED and self-contained: an installed version
20
+ * brings its own deps under
21
+ * `<prefix>/lib/node_modules/@sentropic/h2a/node_modules/` (node-pty included).
22
+ * The top level only holds other global packages. There is no dep-range gate:
23
+ * the prepared autonomous folder is always switched.
24
+ *
25
+ * Prefix lock (v4): single-machine succession protocol. At most one holder:
26
+ * atomic publication via `link(2)` (I1), a no-false-positive liveness predicate
27
+ * (I3), a one-shot `SUCC(t)` succession chain (Lemmas B/C), token monotonicity
28
+ * (I2). No age or mtime ever decides acquisition — `at` is diagnostic only
29
+ * (I7). See the lock section below.
11
30
  */
31
+ import { randomBytes, randomUUID } from "node:crypto";
12
32
  import { spawnSync } from "node:child_process";
13
- import { existsSync, readFileSync, writeFileSync } from "node:fs";
14
- import { dirname, join, resolve as resolvePath } from "node:path";
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";
35
+ import { basename, dirname, join, resolve as resolvePath } from "node:path";
15
36
  import { fileURLToPath } from "node:url";
16
37
  export const H2A_CLI_PACKAGE = "@sentropic/h2a";
17
38
  /** Re-check at most once per this window for the passive `--upgrade-check` notice. */
@@ -43,18 +64,1083 @@ export function isNewerVersion(latest, current) {
43
64
  return a.minor > b.minor;
44
65
  return a.patch > b.patch;
45
66
  }
46
- /** The version of the running `@sentropic/h2a` (from its package.json). */
67
+ /**
68
+ * The version of the running `@sentropic/h2a` (from its package.json).
69
+ * Robust to a renamed install folder: the primary dist-relative path is tried
70
+ * first, then ancestor package.json files whose `name` matches, so a backup
71
+ * copy (`.h2a-prev-*`) still reports its own version instead of `0.0.0`.
72
+ */
47
73
  export function currentCliVersion() {
48
74
  try {
49
75
  const here = dirname(fileURLToPath(import.meta.url)); // dist/runtime/upgrade
50
- const pkgPath = resolvePath(here, "..", "..", "..", "package.json");
51
- const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
52
- return typeof pkg.version === "string" ? pkg.version : "0.0.0";
76
+ const candidates = [resolvePath(here, "..", "..", "..", "package.json")];
77
+ let dir = here;
78
+ for (let i = 0; i < 6; i++) {
79
+ dir = dirname(dir);
80
+ const p = join(dir, "package.json");
81
+ if (!candidates.includes(p))
82
+ candidates.push(p);
83
+ }
84
+ let fallback;
85
+ for (const pkgPath of candidates) {
86
+ try {
87
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
88
+ if (typeof pkg.version !== "string" || !pkg.version)
89
+ continue;
90
+ if (pkg.name === H2A_CLI_PACKAGE)
91
+ return pkg.version;
92
+ fallback ??= pkg.version;
93
+ }
94
+ catch {
95
+ continue;
96
+ }
97
+ }
98
+ return fallback ?? "0.0.0";
53
99
  }
54
100
  catch {
55
101
  return "0.0.0";
56
102
  }
57
103
  }
104
+ function globalPkgDir(prefix) {
105
+ return join(prefix, "lib", "node_modules", H2A_CLI_PACKAGE);
106
+ }
107
+ function globalPkgDirFallback(prefix) {
108
+ return join(prefix, "node_modules", H2A_CLI_PACKAGE);
109
+ }
110
+ function resolveGlobalPkgDir(prefix) {
111
+ try {
112
+ if (existsSync(globalPkgDir(prefix)))
113
+ return globalPkgDir(prefix);
114
+ if (existsSync(globalPkgDirFallback(prefix)))
115
+ return globalPkgDirFallback(prefix);
116
+ }
117
+ catch {
118
+ // fall through to canonical layout
119
+ }
120
+ return globalPkgDir(prefix);
121
+ }
122
+ /** Self-contained staged package dir produced by `npm i -g --prefix <stagingPrefix>`. */
123
+ function stagedPkgDirFromPrefix(stagingPrefix) {
124
+ return join(stagingPrefix, "lib", "node_modules", H2A_CLI_PACKAGE);
125
+ }
126
+ function swapMarkerPath(prefix) {
127
+ return join(prefix, "lib", "node_modules", ".h2a-upgrade-swap.json");
128
+ }
129
+ function compareParsed(a, b) {
130
+ if (a.major !== b.major)
131
+ return a.major > b.major ? 1 : -1;
132
+ if (a.minor !== b.minor)
133
+ return a.minor > b.minor ? 1 : -1;
134
+ if (a.patch !== b.patch)
135
+ return a.patch > b.patch ? 1 : -1;
136
+ return 0;
137
+ }
138
+ function satisfiesOneComparator(version, comp) {
139
+ const v = parseSemver(version);
140
+ if (!v)
141
+ return false;
142
+ const c = comp.trim();
143
+ if (c === "" || c === "*" || c.toLowerCase() === "x" || c.toLowerCase() === "latest")
144
+ return true;
145
+ if (c.startsWith("npm:"))
146
+ return false;
147
+ // Caret and tilde ranges.
148
+ if (c.startsWith("^") || c.startsWith("~")) {
149
+ const base = parseSemver(c.slice(1).trim().replace(/^v/, ""));
150
+ if (!base)
151
+ return false;
152
+ if (compareParsed(v, base) < 0)
153
+ return false;
154
+ if (c.startsWith("^")) {
155
+ if (base.major > 0)
156
+ return v.major === base.major;
157
+ if (base.minor > 0)
158
+ return v.major === 0 && v.minor === base.minor;
159
+ return v.major === 0 && v.minor === 0 && v.patch === base.patch;
160
+ }
161
+ return v.major === base.major && v.minor === base.minor;
162
+ }
163
+ const m = /^(>=|<=|>|<|=|==?)?\s*v?(\d+\.\d+\.\d+)\s*$/.exec(c);
164
+ if (m) {
165
+ const op = m[1] ?? "";
166
+ const base = parseSemver(m[2]);
167
+ if (!base)
168
+ return false;
169
+ const cmp = compareParsed(v, base);
170
+ switch (op) {
171
+ case "":
172
+ case "=":
173
+ case "==":
174
+ return cmp === 0;
175
+ case ">":
176
+ return cmp > 0;
177
+ case ">=":
178
+ return cmp >= 0;
179
+ case "<":
180
+ return cmp < 0;
181
+ case "<=":
182
+ return cmp <= 0;
183
+ default:
184
+ return false;
185
+ }
186
+ }
187
+ // Partial wildcards like 1.x / 1.2.x: prefix match on numeric parts.
188
+ const wild = /^v?(\d+)(?:\.(\d+|x|X))?(?:\.(\d+|x|X))?$/.exec(c);
189
+ if (wild) {
190
+ if (wild[2] === undefined)
191
+ return v.major === Number(wild[1]);
192
+ if (/^[xX]$/.test(wild[2]))
193
+ return v.major === Number(wild[1]);
194
+ if (wild[3] === undefined)
195
+ return v.major === Number(wild[1]) && v.minor === Number(wild[2]);
196
+ if (/^[xX]$/.test(wild[3]))
197
+ return v.major === Number(wild[1]) && v.minor === Number(wild[2]);
198
+ return false;
199
+ }
200
+ return false;
201
+ }
202
+ function satisfiesRange(version, range) {
203
+ const r = range.trim();
204
+ if (r === "" || r === "*" || r === "latest")
205
+ return true;
206
+ // OR groups: any group may satisfy.
207
+ const orGroups = r.split("||").map((s) => s.trim()).filter((s) => s.length > 0);
208
+ if (orGroups.length > 1)
209
+ return orGroups.some((g) => satisfiesRange(version, g));
210
+ // AND group: comma or space separated comparators must all hold.
211
+ const parts = r.split(/[,\s]+/).map((s) => s.trim()).filter((s) => s.length > 0);
212
+ if (parts.length === 0)
213
+ return false;
214
+ if (parts.length === 1)
215
+ return satisfiesOneComparator(version, parts[0]);
216
+ return parts.every((p) => satisfiesOneComparator(version, p));
217
+ }
218
+ function readJsonIfExists(path) {
219
+ try {
220
+ if (!existsSync(path))
221
+ return undefined;
222
+ return JSON.parse(readFileSync(path, "utf8"));
223
+ }
224
+ catch {
225
+ return undefined;
226
+ }
227
+ }
228
+ // ---------------------------------------------------------------------------
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
+ }
986
+ function pidFromAttemptName(name) {
987
+ const m = /^\.h2a-upgrade-(?:staging|tarball)-(\d+)-/.exec(name);
988
+ if (!m)
989
+ return undefined;
990
+ const pid = Number(m[1]);
991
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
992
+ }
993
+ function pidFromPrevName(name) {
994
+ // h2a.h2a-prev-<version>-<pid>-<rand>
995
+ const parts = name.split("-");
996
+ if (parts.length < 2)
997
+ return undefined;
998
+ const pid = Number(parts[parts.length - 2]);
999
+ return Number.isInteger(pid) && pid > 0 ? pid : undefined;
1000
+ }
1001
+ /**
1002
+ * Residue removal rule (debris only, never a lock decision): a live owner is
1003
+ * never touched; a certainly-dead owner (ESRCH) is removed; otherwise only
1004
+ * entries older than the threshold go.
1005
+ */
1006
+ function shouldRemoveResidue(path, pid, now) {
1007
+ if (pid !== undefined) {
1008
+ try {
1009
+ process.kill(pid, 0);
1010
+ return false; // alive (or un-signalable but present): keep
1011
+ }
1012
+ catch (e) {
1013
+ const code = errnoOf(e);
1014
+ if (code === "ESRCH")
1015
+ return true; // certainly dead
1016
+ if (code === "EPERM")
1017
+ return false; // exists: keep
1018
+ }
1019
+ }
1020
+ return isOlderThan(path, now, UPGRADE_RESIDUE_MAX_AGE_MS);
1021
+ }
1022
+ /**
1023
+ * M4 accumulation sweep, run UNDER the prefix lock after a verified swap:
1024
+ * attempt staging and tarball dirs, previous-version backups, orphaned LOCK
1025
+ * succ and tmp files, and the no-cachePath prefix log. Never touches a live
1026
+ * attempt's staging.
1027
+ */
1028
+ function sweepUpgradeResidues(prefix, lockToken) {
1029
+ const now = Date.now();
1030
+ const lockPath = lockPathFor(prefix);
1031
+ const base = basename(lockPath);
1032
+ let lockTok = lockToken;
1033
+ if (lockTok === undefined) {
1034
+ const cur = readLockRecord(lockPath);
1035
+ if (cur !== "absent" && cur !== "corrupt")
1036
+ lockTok = cur.token;
1037
+ }
1038
+ try {
1039
+ for (const n of readdirSync(prefix)) {
1040
+ const full = join(prefix, n);
1041
+ try {
1042
+ if (n.startsWith(".h2a-upgrade-staging-") || n.startsWith(".h2a-upgrade-tarball-")) {
1043
+ if (shouldRemoveResidue(full, pidFromAttemptName(n), now))
1044
+ rmSyncSafe(full);
1045
+ }
1046
+ else if (n.startsWith(base) && n.includes(".tmp.")) {
1047
+ if (isOlderThan(full, now, PREFIX_LOCK_TMP_DEBRIS_MAX_AGE_MS)) {
1048
+ try {
1049
+ unlinkSync(full);
1050
+ }
1051
+ catch {
1052
+ // best-effort
1053
+ }
1054
+ }
1055
+ }
1056
+ else if (n.startsWith(`${base}.succ.`) && !n.includes(".tmp.")) {
1057
+ const rec = readLockRecord(full);
1058
+ if (rec !== "absent" && rec !== "corrupt" && rec.target !== lockTok) {
1059
+ try {
1060
+ unlinkSync(full);
1061
+ }
1062
+ catch {
1063
+ // best-effort
1064
+ }
1065
+ }
1066
+ }
1067
+ }
1068
+ catch {
1069
+ // best-effort per entry
1070
+ }
1071
+ }
1072
+ }
1073
+ catch {
1074
+ // best-effort
1075
+ }
1076
+ // No-cachePath boot log lives under the prefix: sweep it like other residues.
1077
+ try {
1078
+ const prefixLog = join(prefix, "h2a-upgrade.log");
1079
+ if (isOlderThan(prefixLog, now, UPGRADE_RESIDUE_MAX_AGE_MS)) {
1080
+ try {
1081
+ unlinkSync(prefixLog);
1082
+ }
1083
+ catch {
1084
+ // best-effort
1085
+ }
1086
+ }
1087
+ }
1088
+ catch {
1089
+ // best-effort
1090
+ }
1091
+ try {
1092
+ const parent = dirname(resolveGlobalPkgDir(prefix));
1093
+ for (const n of readdirSync(parent)) {
1094
+ if (!n.startsWith("h2a.h2a-prev-"))
1095
+ continue;
1096
+ const full = join(parent, n);
1097
+ try {
1098
+ if (shouldRemoveResidue(full, pidFromPrevName(n), now))
1099
+ rmSyncSafe(full);
1100
+ }
1101
+ catch {
1102
+ // best-effort per entry
1103
+ }
1104
+ }
1105
+ }
1106
+ catch {
1107
+ // best-effort
1108
+ }
1109
+ }
1110
+ /**
1111
+ * Boot outcomes that stay QUIET so a mass restart does not spam stderr. Everything else —
1112
+ * `upgraded`/`failed`/`deferred-propagation`, and critically `blocked-undecidable` (M-2) and
1113
+ * `skipped-locked-stale` (R2) — is actionable and MUST surface at boot. The single source of
1114
+ * truth for both the in-process seam and the boot worker, so the two cannot drift and can be
1115
+ * tested once. Do NOT add blocked-undecidable or skipped-locked-stale here.
1116
+ */
1117
+ export function isQuietUpgradeOutcome(outcome) {
1118
+ return outcome === "already-current" || outcome === "skipped-throttled" || outcome === "skipped-locked";
1119
+ }
1120
+ /**
1121
+ * Human-readable lock failure for `h2a upgrade` / boot diagnostics, so a
1122
+ * permission error is never reported as "another installation in progress".
1123
+ */
1124
+ export function describeLockReason(reason) {
1125
+ if (reason === undefined || reason === "busy")
1126
+ return "another installation in progress";
1127
+ if (reason === "dead-undecidable") {
1128
+ // R4: do NOT advise a blanket removal — a holder unreadable here may be alive in
1129
+ // another namespace/machine. The performAutoUpgrade path emits the fully-qualified
1130
+ // guidance (recorded holder identity + reader ns); this stays generic and safe.
1131
+ return "lock held by an owner whose liveness cannot be decided; manual intervention required (inspect the recorded holder before removing the lock — never remove one that may be alive in another namespace or machine)";
1132
+ }
1133
+ const code = reason.slice("error:".length);
1134
+ // Error-specific hint: only permission-class codes warrant the "check permissions"
1135
+ // advice; other codes get an accurate, non-misleading message.
1136
+ if (code === "EACCES" || code === "EPERM" || code === "EROFS") {
1137
+ return `cannot access the global prefix (${code}); check directory permissions`;
1138
+ }
1139
+ if (code === "ENOSPC") {
1140
+ return "cannot write to the global prefix (ENOSPC); no space left on device";
1141
+ }
1142
+ return `cannot access the global prefix (${code})`;
1143
+ }
58
1144
  export const defaultUpgradeRuntime = {
59
1145
  fetchLatest(pkg) {
60
1146
  try {
@@ -95,14 +1181,340 @@ export const defaultUpgradeRuntime = {
95
1181
  }
96
1182
  },
97
1183
  writeCache(path, entry) {
1184
+ // Atomic replace: write a sibling temp then rename, so a concurrent reader (or a
1185
+ // crash mid-write) never observes a half-written, unparseable cache file.
1186
+ const tmp = `${path}.tmp.${randomBytes(6).toString("hex")}`;
1187
+ try {
1188
+ writeFileSync(tmp, `${JSON.stringify(entry, null, 2)}\n`, "utf8");
1189
+ renameSync(tmp, path);
1190
+ }
1191
+ catch {
1192
+ try {
1193
+ unlinkSync(tmp);
1194
+ }
1195
+ catch {
1196
+ // best-effort
1197
+ }
1198
+ }
1199
+ },
1200
+ resolvePrefix() {
1201
+ try {
1202
+ const r = spawnSync("npm", ["prefix", "-g"], { encoding: "utf8", timeout: 10_000 });
1203
+ if (r.status === 0) {
1204
+ const p = r.stdout.trim();
1205
+ if (p)
1206
+ return p;
1207
+ }
1208
+ }
1209
+ catch {
1210
+ // fall through to fallback
1211
+ }
1212
+ return "/usr/local";
1213
+ },
1214
+ fetchTarball(pkg, version, destDir) {
1215
+ try {
1216
+ mkdirSync(destDir, { recursive: true });
1217
+ const spec = `${pkg}@${version}`;
1218
+ const r = spawnSync("npm", ["pack", spec, "--pack-destination", destDir], {
1219
+ encoding: "utf8",
1220
+ timeout: 60_000
1221
+ });
1222
+ if (r.status !== 0) {
1223
+ const err = (r.stderr || r.stdout || "npm pack failed").trim().slice(0, 500);
1224
+ return { ok: false, error: `npm pack failed: ${err}` };
1225
+ }
1226
+ let files = [];
1227
+ try {
1228
+ files = readdirSync(destDir).filter((f) => f.endsWith(".tgz"));
1229
+ }
1230
+ catch {
1231
+ return { ok: false, error: "tarball dir unreadable" };
1232
+ }
1233
+ if (files.length === 0)
1234
+ return { ok: false, error: "tarball not found" };
1235
+ const pick = files.find((f) => f.includes(version)) ?? files.sort().pop();
1236
+ return { ok: true, file: join(destDir, pick) };
1237
+ }
1238
+ catch (e) {
1239
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1240
+ }
1241
+ },
1242
+ stageInstall(tarballOrSpec, stagingPrefix) {
1243
+ try {
1244
+ mkdirSync(stagingPrefix, { recursive: true });
1245
+ // Self-contained global-style staging: produces
1246
+ // <stagingPrefix>/lib/node_modules/@sentropic/h2a with nested deps + bin.
1247
+ const r = spawnSync("npm", ["i", "-g", "--prefix", stagingPrefix, tarballOrSpec], {
1248
+ encoding: "utf8",
1249
+ timeout: 180_000
1250
+ });
1251
+ if (r.status === 0)
1252
+ return { ok: true };
1253
+ const err = (r.stderr || r.stdout || "npm install failed").trim().slice(0, 1000);
1254
+ return { ok: false, error: `npm install failed: ${err}` };
1255
+ }
1256
+ catch (e) {
1257
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1258
+ }
1259
+ },
1260
+ probeStagedVersion(stagingPrefix) {
1261
+ try {
1262
+ const stagedPkgDir = stagedPkgDirFromPrefix(stagingPrefix);
1263
+ const candidates = [
1264
+ { file: join(stagingPrefix, "bin", "h2a"), js: false },
1265
+ { file: join(stagedPkgDir, "dist", "bin.js"), js: true },
1266
+ // Legacy flat staging layout — best-effort fallback only.
1267
+ { file: join(stagingPrefix, "node_modules", ".bin", "h2a"), js: false },
1268
+ { file: join(stagingPrefix, "node_modules", H2A_CLI_PACKAGE, "dist", "bin.js"), js: true }
1269
+ ];
1270
+ for (const c of candidates) {
1271
+ try {
1272
+ if (!existsSync(c.file))
1273
+ continue;
1274
+ const r = c.js
1275
+ ? spawnSync(process.execPath, [c.file, "--version"], { encoding: "utf8", timeout: 15_000 })
1276
+ : spawnSync(c.file, ["--version"], { encoding: "utf8", timeout: 15_000 });
1277
+ if (r.status === 0) {
1278
+ const v = (r.stdout || "").trim();
1279
+ if (parseSemver(v))
1280
+ return v;
1281
+ }
1282
+ }
1283
+ catch {
1284
+ continue;
1285
+ }
1286
+ }
1287
+ return undefined;
1288
+ }
1289
+ catch {
1290
+ return undefined;
1291
+ }
1292
+ },
1293
+ verifyStagedNative(stagingPkgDir) {
1294
+ try {
1295
+ const nativePath = join(stagingPkgDir, "node_modules", "node-pty");
1296
+ // `h2a --version` does NOT load node-pty, so a staged dir can report the
1297
+ // right version yet break `h2a run` later. Load the staged native module
1298
+ // before swap and fail closed when it cannot load.
1299
+ const r = spawnSync(process.execPath, ["-e", `require(${JSON.stringify(nativePath)})`], {
1300
+ encoding: "utf8",
1301
+ timeout: 15_000
1302
+ });
1303
+ if (r.status === 0)
1304
+ return { ok: true };
1305
+ const err = (r.stderr || r.stdout || "native module load failed").trim().slice(0, 500);
1306
+ return { ok: false, error: `node-pty load failed: ${err}` };
1307
+ }
1308
+ catch (e) {
1309
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1310
+ }
1311
+ },
1312
+ readGlobalPkgVersion(prefix) {
1313
+ const candidates = [
1314
+ join(prefix, "lib", "node_modules", H2A_CLI_PACKAGE, "package.json"),
1315
+ join(prefix, "node_modules", H2A_CLI_PACKAGE, "package.json")
1316
+ ];
1317
+ for (const p of candidates) {
1318
+ try {
1319
+ if (!existsSync(p))
1320
+ continue;
1321
+ const pkg = JSON.parse(readFileSync(p, "utf8"));
1322
+ if (typeof pkg.version === "string" && pkg.version)
1323
+ return pkg.version;
1324
+ }
1325
+ catch {
1326
+ continue;
1327
+ }
1328
+ }
1329
+ return undefined;
1330
+ },
1331
+ swapPackageDir(prefix, stagingPkgDir, version) {
1332
+ try {
1333
+ const currentDir = resolveGlobalPkgDir(prefix);
1334
+ const marker = swapMarkerPath(prefix);
1335
+ let currentVersion = "unknown";
1336
+ try {
1337
+ const pkg = readJsonIfExists(join(currentDir, "package.json"));
1338
+ if (pkg && typeof pkg["version"] === "string")
1339
+ currentVersion = pkg["version"];
1340
+ }
1341
+ catch {
1342
+ // keep unknown suffix
1343
+ }
1344
+ if (!existsSync(stagingPkgDir))
1345
+ return { ok: false, error: "staging package dir missing" };
1346
+ // Same-filesystem gate BEFORE the first rename: both parents must share st_dev.
1347
+ try {
1348
+ mkdirSync(dirname(currentDir), { recursive: true });
1349
+ }
1350
+ catch {
1351
+ // best-effort; stat below decides
1352
+ }
1353
+ try {
1354
+ const currentParentDev = statSync(dirname(currentDir)).dev;
1355
+ const stagingParentDev = statSync(dirname(stagingPkgDir)).dev;
1356
+ if (currentParentDev !== stagingParentDev)
1357
+ return { ok: false, error: "cross-device" };
1358
+ }
1359
+ catch {
1360
+ return { ok: false, error: "cross-device" };
1361
+ }
1362
+ if (!existsSync(currentDir)) {
1363
+ // Nothing to back up: direct move into place.
1364
+ try {
1365
+ mkdirSync(dirname(currentDir), { recursive: true });
1366
+ writeFileSync(marker, `${JSON.stringify({ phase: "started", prefix, currentDir, prevDir: null, stagingPkgDir, version, at: Date.now(), pid: process.pid }, null, 2)}\n`, "utf8");
1367
+ renameSync(stagingPkgDir, currentDir);
1368
+ try {
1369
+ unlinkSync(marker);
1370
+ }
1371
+ catch {
1372
+ // best-effort
1373
+ }
1374
+ return { ok: true, repaired: false, prevDir: null };
1375
+ }
1376
+ catch (e) {
1377
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1378
+ }
1379
+ }
1380
+ // Unique prev dir per attempt so concurrent lanes never share one artifact.
1381
+ const prevDir = `${currentDir}.h2a-prev-${currentVersion}-${process.pid}-${randomUUID().slice(0, 8)}`;
1382
+ let backedUp = false;
1383
+ try {
1384
+ mkdirSync(dirname(currentDir), { recursive: true });
1385
+ writeFileSync(marker, `${JSON.stringify({ phase: "started", prefix, currentDir, prevDir, stagingPkgDir, version, at: Date.now(), pid: process.pid }, null, 2)}\n`, "utf8");
1386
+ if (existsSync(prevDir))
1387
+ rmSyncSafe(prevDir);
1388
+ renameSync(currentDir, prevDir);
1389
+ backedUp = true;
1390
+ writeFileSync(marker, `${JSON.stringify({ phase: "backed-up", prefix, currentDir, prevDir, stagingPkgDir, version, at: Date.now(), pid: process.pid }, null, 2)}\n`, "utf8");
1391
+ renameSync(stagingPkgDir, currentDir);
1392
+ // Swap complete: remove the marker so a crash AFTER success cannot be
1393
+ // mistaken for an interrupted swap on the next boot.
1394
+ try {
1395
+ unlinkSync(marker);
1396
+ }
1397
+ catch {
1398
+ // best-effort
1399
+ }
1400
+ return { ok: true, repaired: false, prevDir };
1401
+ }
1402
+ catch (e) {
1403
+ if (backedUp) {
1404
+ // Roll back the first rename so the install is never left broken.
1405
+ try {
1406
+ renameSync(prevDir, currentDir);
1407
+ try {
1408
+ unlinkSync(marker);
1409
+ }
1410
+ catch {
1411
+ // best-effort
1412
+ }
1413
+ }
1414
+ catch {
1415
+ // Rollback failed: keep the marker so repair can finish it.
1416
+ }
1417
+ }
1418
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1419
+ }
1420
+ }
1421
+ catch (e) {
1422
+ return { ok: false, error: e instanceof Error ? e.message : String(e) };
1423
+ }
1424
+ },
1425
+ completeRepairIfPending(prefix) {
1426
+ // LIMIT: when `current` is missing, this repair code (module worker +
1427
+ // bin/h2a) lives inside the missing folder, so this function is reachable
1428
+ // only while `current` exists. It is NOT a safety mechanism; PREVENTION
1429
+ // (same-filesystem gate, rollback, exclusive lock) protects the install.
1430
+ // Repair only finishes a marker left by a crashed swap.
1431
+ try {
1432
+ const marker = swapMarkerPath(prefix);
1433
+ if (!existsSync(marker))
1434
+ return false;
1435
+ let state;
1436
+ try {
1437
+ state = JSON.parse(readFileSync(marker, "utf8"));
1438
+ }
1439
+ catch {
1440
+ return false;
1441
+ }
1442
+ const currentDir = typeof state["currentDir"] === "string" ? state["currentDir"] : resolveGlobalPkgDir(prefix);
1443
+ const prevDir = typeof state["prevDir"] === "string" ? state["prevDir"] : null;
1444
+ const stagingPkgDir = typeof state["stagingPkgDir"] === "string" ? state["stagingPkgDir"] : null;
1445
+ const phase = typeof state["phase"] === "string" ? state["phase"] : "";
1446
+ const currentExists = existsSync(currentDir);
1447
+ const prevExists = prevDir ? existsSync(prevDir) : false;
1448
+ const stagingExists = stagingPkgDir ? existsSync(stagingPkgDir) : false;
1449
+ // Interrupted before any rename: nothing to finish.
1450
+ if (phase === "started" && currentExists) {
1451
+ try {
1452
+ unlinkSync(marker);
1453
+ }
1454
+ catch {
1455
+ // best-effort
1456
+ }
1457
+ return true;
1458
+ }
1459
+ // Missing live dir: finish the pending move or roll back the backup.
1460
+ if (!currentExists && prevExists) {
1461
+ try {
1462
+ if (stagingExists && stagingPkgDir) {
1463
+ renameSync(stagingPkgDir, currentDir);
1464
+ }
1465
+ else if (prevDir) {
1466
+ renameSync(prevDir, currentDir);
1467
+ }
1468
+ try {
1469
+ unlinkSync(marker);
1470
+ }
1471
+ catch {
1472
+ // best-effort
1473
+ }
1474
+ return true;
1475
+ }
1476
+ catch {
1477
+ return false;
1478
+ }
1479
+ }
1480
+ // Live dir present: swap already completed or backup is stale.
1481
+ try {
1482
+ unlinkSync(marker);
1483
+ }
1484
+ catch {
1485
+ // best-effort
1486
+ }
1487
+ return true;
1488
+ }
1489
+ catch {
1490
+ return false;
1491
+ }
1492
+ },
1493
+ // Faithful proxy of the standalone. Production callers pass no hooks, so every
1494
+ // hook is a no-op at zero cost; a test may inject hooks via this same argument.
1495
+ acquirePrefixLock(prefix, hooks) {
1496
+ return acquirePrefixLock(prefix, hooks);
1497
+ },
1498
+ writeDiagnostics(path, record) {
98
1499
  try {
99
- writeFileSync(path, `${JSON.stringify(entry, null, 2)}\n`, "utf8");
1500
+ mkdirSync(dirname(path), { recursive: true });
1501
+ const s = JSON.stringify(record);
1502
+ const bounded = s.length > 65_536 ? s.slice(0, 65_536) : s;
1503
+ writeFileSync(path, `${bounded}\n`, "utf8");
100
1504
  }
101
1505
  catch {
102
1506
  // best-effort
103
1507
  }
104
1508
  }
105
1509
  };
1510
+ function rmSyncSafe(path) {
1511
+ try {
1512
+ rmSync(path, { recursive: true, force: true });
1513
+ }
1514
+ catch {
1515
+ // best-effort
1516
+ }
1517
+ }
106
1518
  /**
107
1519
  * Determine whether a newer CLI is published. Cached + throttled when a
108
1520
  * `cachePath` is given (level-2 boot notice); always-fresh otherwise. Never
@@ -125,7 +1537,27 @@ export function checkUpgrade(current, options = {}) {
125
1537
  }
126
1538
  const latest = runtime.fetchLatest(H2A_CLI_PACKAGE);
127
1539
  if (options.cachePath) {
128
- runtime.writeCache(options.cachePath, { checkedAt: now, ...(latest ? { latest } : {}) });
1540
+ // Preserve throttle fields across version-check writes (C4: keep
1541
+ // lastAttemptVersion so the version-indexed backoff keeps climbing).
1542
+ let throttle = {};
1543
+ try {
1544
+ const prev = runtime.readCache(options.cachePath);
1545
+ if (prev) {
1546
+ if (prev.lastAttemptAt !== undefined)
1547
+ throttle = { ...throttle, lastAttemptAt: prev.lastAttemptAt };
1548
+ if (prev.consecutiveFailures !== undefined)
1549
+ throttle = { ...throttle, consecutiveFailures: prev.consecutiveFailures };
1550
+ if (prev.lastAttemptVersion !== undefined)
1551
+ throttle = { ...throttle, lastAttemptVersion: prev.lastAttemptVersion };
1552
+ if (prev.lastOutcome !== undefined && (prev.lastOutcome === "ok" || prev.lastOutcome === "deferred-propagation" || prev.lastOutcome === "failed")) {
1553
+ throttle = { ...throttle, lastOutcome: prev.lastOutcome };
1554
+ }
1555
+ }
1556
+ }
1557
+ catch {
1558
+ // best-effort
1559
+ }
1560
+ runtime.writeCache(options.cachePath, { checkedAt: now, ...(latest ? { latest } : {}), ...throttle });
129
1561
  }
130
1562
  return {
131
1563
  current,
@@ -134,7 +1566,11 @@ export function checkUpgrade(current, options = {}) {
134
1566
  fromCache: false
135
1567
  };
136
1568
  }
137
- /** Run the global install of `@latest`. Returns true on success. */
1569
+ /**
1570
+ * @deprecated Kept for compatibility (`h2a upgrade` explicit path). New boot
1571
+ * and command flows use `performAutoUpgrade` (staged swap, never in-place).
1572
+ * Run the global install of `@latest`. Returns true on success.
1573
+ */
138
1574
  export function performUpgrade(runtime = defaultUpgradeRuntime) {
139
1575
  return runtime.runInstall(H2A_CLI_PACKAGE);
140
1576
  }
@@ -182,4 +1618,611 @@ export function reexecSelf(options = {}) {
182
1618
  return false;
183
1619
  }
184
1620
  }
1621
+ /** Exponential backoff for consecutive failures, based on the auto-upgrade TTL. */
1622
+ function upgradeThrottleBackoffMs(consecutiveFailures) {
1623
+ const base = H2A_AUTO_UPGRADE_CHECK_TTL_MS;
1624
+ const exp = Math.pow(2, Math.max(0, consecutiveFailures - 1));
1625
+ const capped = Math.min(exp, 24);
1626
+ return base * capped;
1627
+ }
1628
+ /**
1629
+ * Single staged auto-upgrade orchestration used by both the injected-seam path
1630
+ * and the worker path. Every bounded side effect goes through `UpgradeRuntime`;
1631
+ * this function never spawns or touches the network directly.
1632
+ *
1633
+ * Fail-safe seam: when an injected runtime is provided, a missing method throws
1634
+ * `missing runtime method X` instead of falling back to the real implementation.
1635
+ * Only the default path (no runtime provided) uses `defaultUpgradeRuntime`.
1636
+ */
1637
+ export function performAutoUpgrade(current, options = {}) {
1638
+ const injected = options.runtime;
1639
+ const need = (name) => {
1640
+ if (!injected) {
1641
+ const fn = defaultUpgradeRuntime[name];
1642
+ if (fn == null)
1643
+ throw new Error(`missing runtime method ${String(name)}`);
1644
+ return fn;
1645
+ }
1646
+ const fn = injected[name];
1647
+ if (fn == null)
1648
+ throw new Error(`missing runtime method ${String(name)}`);
1649
+ return fn;
1650
+ };
1651
+ // Fail fast before any side effect when an injected runtime is incomplete.
1652
+ const fetchLatestFn = need("fetchLatest");
1653
+ const nowFn = need("now");
1654
+ const readCacheFn = need("readCache");
1655
+ const writeCacheFn = need("writeCache");
1656
+ const doResolvePrefix = need("resolvePrefix");
1657
+ const completeRepair = need("completeRepairIfPending");
1658
+ const acquireLock = need("acquirePrefixLock");
1659
+ const doFetchTarball = need("fetchTarball");
1660
+ const doStageInstall = need("stageInstall");
1661
+ const doProbeStaged = need("probeStagedVersion");
1662
+ const doVerifyNative = need("verifyStagedNative");
1663
+ const doSwap = need("swapPackageDir");
1664
+ const doReadGlobal = need("readGlobalPkgVersion");
1665
+ const doWriteDiag = need("writeDiagnostics");
1666
+ const runtime = injected ?? defaultUpgradeRuntime;
1667
+ void fetchLatestFn;
1668
+ let prefix = options.prefix;
1669
+ if (!prefix) {
1670
+ try {
1671
+ prefix = doResolvePrefix();
1672
+ }
1673
+ catch {
1674
+ prefix = "/usr/local";
1675
+ }
1676
+ }
1677
+ if (!prefix)
1678
+ prefix = "/usr/local";
1679
+ const resolvedPrefix = prefix;
1680
+ const cachePath = options.cachePath;
1681
+ const ttlMs = options.ttlMs ?? H2A_AUTO_UPGRADE_CHECK_TTL_MS;
1682
+ const logPath = cachePath ? `${cachePath}.log` : join(resolvedPrefix, "h2a-upgrade.log");
1683
+ const readEntry = () => {
1684
+ if (!cachePath)
1685
+ return undefined;
1686
+ try {
1687
+ return readCacheFn(cachePath);
1688
+ }
1689
+ catch {
1690
+ return undefined;
1691
+ }
1692
+ };
1693
+ const writeEntry = (entry) => {
1694
+ if (!cachePath)
1695
+ return;
1696
+ try {
1697
+ writeCacheFn(cachePath, entry);
1698
+ }
1699
+ catch {
1700
+ // best-effort
1701
+ }
1702
+ };
1703
+ const diag = (record) => {
1704
+ try {
1705
+ doWriteDiag(logPath, record);
1706
+ }
1707
+ catch {
1708
+ // best-effort
1709
+ }
1710
+ };
1711
+ // Freshness check (cached + throttled when cachePath is set).
1712
+ let check;
1713
+ try {
1714
+ check = checkUpgrade(current, { runtime, ...(cachePath ? { cachePath } : {}), ttlMs });
1715
+ }
1716
+ catch (e) {
1717
+ const at = nowFn();
1718
+ diag({ at, durationMs: 0, prefix: resolvedPrefix, current, outcome: "failed", error: e instanceof Error ? e.message : String(e) });
1719
+ return {
1720
+ current,
1721
+ outcome: "failed",
1722
+ message: `auto-upgrade check failed (see ${logPath})`,
1723
+ logPath
1724
+ };
1725
+ }
1726
+ if (!check.upgradeAvailable || !check.latest) {
1727
+ if (cachePath) {
1728
+ try {
1729
+ const prev = readEntry();
1730
+ writeEntry({
1731
+ checkedAt: prev?.checkedAt ?? nowFn(),
1732
+ ...(check.latest ?? prev?.latest ? { latest: (check.latest ?? prev?.latest) } : {}),
1733
+ lastAttemptAt: nowFn(),
1734
+ consecutiveFailures: 0,
1735
+ lastAttemptVersion: current,
1736
+ lastOutcome: "ok"
1737
+ });
1738
+ }
1739
+ catch {
1740
+ // best-effort
1741
+ }
1742
+ }
1743
+ return {
1744
+ current,
1745
+ outcome: "already-current",
1746
+ message: `already current (${current})`
1747
+ };
1748
+ }
1749
+ // Pin the target version for this attempt.
1750
+ const target = check.latest;
1751
+ const startedAt = nowFn();
1752
+ const recordFailure = (outcome) => {
1753
+ if (!cachePath)
1754
+ return;
1755
+ try {
1756
+ const prev = readEntry();
1757
+ const sameVersion = prev?.lastAttemptVersion === target;
1758
+ const failures = sameVersion ? (prev?.consecutiveFailures ?? 0) + 1 : 1;
1759
+ writeEntry({
1760
+ checkedAt: prev?.checkedAt ?? startedAt,
1761
+ latest: target,
1762
+ lastAttemptAt: nowFn(),
1763
+ consecutiveFailures: failures,
1764
+ lastAttemptVersion: target,
1765
+ lastOutcome: outcome
1766
+ });
1767
+ }
1768
+ catch {
1769
+ // best-effort
1770
+ }
1771
+ };
1772
+ // Version-indexed backoff BEFORE any heavy work: the same target that failed
1773
+ // or deferred recently must not replay `npm pack` + stage (~130 MB) every boot.
1774
+ // A new target resets the counter.
1775
+ try {
1776
+ const cached = readEntry();
1777
+ if (cached &&
1778
+ (cached.lastOutcome === "failed" || cached.lastOutcome === "deferred-propagation") &&
1779
+ cached.lastAttemptVersion === target &&
1780
+ (cached.consecutiveFailures ?? 0) > 0 &&
1781
+ typeof cached.lastAttemptAt === "number") {
1782
+ const failures = cached.consecutiveFailures;
1783
+ if (nowFn() - cached.lastAttemptAt < upgradeThrottleBackoffMs(failures)) {
1784
+ return {
1785
+ current,
1786
+ target,
1787
+ outcome: "skipped-throttled",
1788
+ message: `auto-upgrade to ${target} throttled after ${failures} attempt(s), retry later`
1789
+ };
1790
+ }
1791
+ }
1792
+ }
1793
+ catch {
1794
+ // best-effort: continue without throttle
1795
+ }
1796
+ // Serialize concurrent installers on the global prefix. Production calls
1797
+ // without hooks (test-only critical-section windows stay no-op, zero cost).
1798
+ let lock;
1799
+ let lockThrew;
1800
+ let lockThrewFlag = false;
1801
+ try {
1802
+ lock = acquireLock(resolvedPrefix);
1803
+ }
1804
+ catch (e) {
1805
+ lockThrew = e;
1806
+ lockThrewFlag = true;
1807
+ lock = { acquired: false, release: () => { }, reason: "dead-undecidable" };
1808
+ }
1809
+ if (!lock.acquired) {
1810
+ const reason = lock.reason ?? "busy";
1811
+ if (reason.startsWith("error:")) {
1812
+ const code = reason.slice("error:".length);
1813
+ // R5: use the error-specific hint (permissions advice only for EACCES/EPERM/EROFS,
1814
+ // an ENOSPC message, or a plain code) instead of always blaming permissions.
1815
+ const hint = describeLockReason(reason);
1816
+ diag({
1817
+ at: startedAt,
1818
+ durationMs: nowFn() - startedAt,
1819
+ prefix: resolvedPrefix,
1820
+ current,
1821
+ target,
1822
+ outcome: "failed",
1823
+ error: `prefix lock unavailable (${code}): ${hint} on ${resolvedPrefix}`
1824
+ });
1825
+ recordFailure("failed");
1826
+ return {
1827
+ current,
1828
+ target,
1829
+ outcome: "failed",
1830
+ message: `auto-upgrade to ${target} failed: cannot lock prefix ${resolvedPrefix}: ${hint} (see ${logPath})`,
1831
+ logPath
1832
+ };
1833
+ }
1834
+ if (reason === "dead-undecidable") {
1835
+ // R4: show the RECORDED holder identity and the reader's namespace, and advise
1836
+ // removal ONLY after confirming that holder is truly gone in ITS OWN namespace —
1837
+ // a live holder in another container/namespace (nsenter -p) or another machine
1838
+ // must never be broken on the strength of "PID absent in MY namespace".
1839
+ const lockFile = lockPathFor(resolvedPrefix);
1840
+ const rec = readLockRecord(lockFile);
1841
+ const readerNs = me().ns ?? "unknown";
1842
+ const holder = rec === "absent" || rec === "corrupt"
1843
+ ? `LOCK unreadable (${rec})`
1844
+ : `holder host=${rec.host} ns=${rec.ns ?? "unknown"} pid=${rec.pid} acquiredAt=${safeAtIso(rec.at)}`;
1845
+ const advice = rec === "absent" || rec === "corrupt"
1846
+ ? `Inspect ${lockFile} and its ${lockFile}.succ.* files before any removal.`
1847
+ : `Remove ${lockFile} (and its ${lockFile}.succ.* files) ONLY after confirming pid ${rec.pid} on host ${rec.host} is truly gone in ITS OWN namespace — never remove a holder merely absent from yours (a live holder in another container/namespace or machine must not be broken).`;
1848
+ const thrown = lockThrewFlag
1849
+ ? `lock acquisition threw (${lockThrew instanceof Error ? lockThrew.message : String(lockThrew)}); `
1850
+ : "";
1851
+ const fullError = `${thrown}prefix lock owner liveness undecidable (a different/unreadable PID namespace, another machine, a corrupt LOCK, or succession depth exceeded); manual intervention required. ${holder}; this reader ns=${readerNs}. ${advice}`;
1852
+ diag({
1853
+ at: startedAt,
1854
+ durationMs: nowFn() - startedAt,
1855
+ prefix: resolvedPrefix,
1856
+ current,
1857
+ target,
1858
+ outcome: "blocked-undecidable",
1859
+ reason,
1860
+ error: fullError
1861
+ });
1862
+ return {
1863
+ current,
1864
+ target,
1865
+ outcome: "blocked-undecidable",
1866
+ message: `auto-upgrade blocked: ${fullError} (see ${logPath})`,
1867
+ logPath
1868
+ };
1869
+ }
1870
+ // reason === "busy": a live holder. R2 staleness alert (DIAGNOSTIC ONLY, never a
1871
+ // reclaim): if this lock is far older than any legitimate upgrade AND its holder
1872
+ // reads "live" ONLY because its start time is not comparable on this kernel (so a
1873
+ // reused PID could be masking a dead holder), surface it as a distinct, boot-VISIBLE
1874
+ // outcome (N1: plain skipped-locked is suppressed at boot, which would hide exactly
1875
+ // the loss B2's acceptance relies on making visible). `at` informs; it never decides
1876
+ // (I7). No removal is advised — an operator killing a live holder on the strength of
1877
+ // a message is a hand-made double-holder (the R4 lesson).
1878
+ let staleAlert;
1879
+ try {
1880
+ const rec = readLockRecord(lockPathFor(resolvedPrefix));
1881
+ if (rec !== "absent" && rec !== "corrupt") {
1882
+ const info = classifyLiveness(rec, me());
1883
+ const ageMs = nowFn() - rec.at;
1884
+ if (info.verdict === "live" && !info.datable && ageMs > STALE_LOCK_ALERT_MS) {
1885
+ staleAlert =
1886
+ `prefix lock held ~${Math.round(ageMs / 60000)} min by pid ${rec.pid} (host ${rec.host}); ` +
1887
+ `its identity is not confirmable on this kernel (start time not comparable), so a reused PID may be masking a dead holder. ` +
1888
+ `Informational only — no action is taken and none is advised automatically; investigate whether pid ${rec.pid} is genuinely the running upgrade.`;
1889
+ }
1890
+ }
1891
+ }
1892
+ catch {
1893
+ // best-effort: the staleness check must never affect the outcome
1894
+ }
1895
+ if (staleAlert !== undefined) {
1896
+ diag({
1897
+ at: startedAt,
1898
+ durationMs: nowFn() - startedAt,
1899
+ prefix: resolvedPrefix,
1900
+ current,
1901
+ target,
1902
+ outcome: "skipped-locked-stale",
1903
+ reason,
1904
+ error: staleAlert
1905
+ });
1906
+ return {
1907
+ current,
1908
+ target,
1909
+ outcome: "skipped-locked-stale",
1910
+ message: `auto-upgrade skipped (stale lock): ${staleAlert} (see ${logPath})`,
1911
+ logPath
1912
+ };
1913
+ }
1914
+ const detail = describeLockReason(reason);
1915
+ return {
1916
+ current,
1917
+ target,
1918
+ outcome: "skipped-locked",
1919
+ message: `auto-upgrade skipped: ${detail} (${reason})`
1920
+ };
1921
+ }
1922
+ try {
1923
+ // Repair under the lock so one lane never repairs while another swaps.
1924
+ try {
1925
+ completeRepair(resolvedPrefix);
1926
+ }
1927
+ catch {
1928
+ // best-effort
1929
+ }
1930
+ // Idempotence under the lock: the version check ran BEFORE acquiring the lock,
1931
+ // so a peer lane may have installed `target` while we waited. Re-read the live
1932
+ // global version now that we hold the lock; if it already IS target AND its native
1933
+ // module actually loads, do not re-stage ~130 MB — report already-current. The lock
1934
+ // releases in `finally`. A version-correct install whose native module fails to load
1935
+ // is NOT up to date, it is broken (e.g. a manual `npm i -g` interrupted mid-write):
1936
+ // we must fall through and re-stage to repair it, never declare it current.
1937
+ let installed;
1938
+ try {
1939
+ installed = doReadGlobal(resolvedPrefix);
1940
+ }
1941
+ catch {
1942
+ installed = undefined;
1943
+ }
1944
+ let installedNativeOk = false;
1945
+ if (installed === target) {
1946
+ try {
1947
+ // N2: the LIVE global package dir is layout-dependent (nested on Linux/macOS,
1948
+ // flat on Windows). Use resolveGlobalPkgDir, not the always-nested staged path,
1949
+ // or the native check would fail forever on Windows and re-stage every lane.
1950
+ installedNativeOk = doVerifyNative(resolveGlobalPkgDir(resolvedPrefix)).ok;
1951
+ }
1952
+ catch {
1953
+ installedNativeOk = false;
1954
+ }
1955
+ }
1956
+ if (installed === target && installedNativeOk) {
1957
+ if (cachePath) {
1958
+ try {
1959
+ const prev = readEntry();
1960
+ writeEntry({
1961
+ checkedAt: prev?.checkedAt ?? startedAt,
1962
+ latest: target,
1963
+ lastAttemptAt: nowFn(),
1964
+ consecutiveFailures: 0,
1965
+ lastAttemptVersion: target,
1966
+ lastOutcome: "ok"
1967
+ });
1968
+ }
1969
+ catch {
1970
+ // best-effort
1971
+ }
1972
+ }
1973
+ return {
1974
+ current: installed,
1975
+ target,
1976
+ outcome: "already-current",
1977
+ message: `already current (${installed}); another lane installed ${target} before this one acquired the lock`
1978
+ };
1979
+ }
1980
+ // Sibling staging dirs under the global prefix (same filesystem, never /tmp).
1981
+ // Unique per attempt so lanes never share one artifact.
1982
+ const attemptId = `${process.pid}-${startedAt.toString(36)}-${randomUUID().slice(0, 8)}`;
1983
+ const stagingTarballDir = join(resolvedPrefix, `.h2a-upgrade-tarball-${attemptId}`);
1984
+ const stagingPrefix = join(resolvedPrefix, `.h2a-upgrade-staging-${attemptId}`);
1985
+ const stagingPkgDir = stagedPkgDirFromPrefix(stagingPrefix);
1986
+ const cleanupAttempt = () => {
1987
+ try {
1988
+ rmSyncSafe(stagingPrefix);
1989
+ }
1990
+ catch {
1991
+ // best-effort
1992
+ }
1993
+ try {
1994
+ rmSyncSafe(stagingTarballDir);
1995
+ }
1996
+ catch {
1997
+ // best-effort
1998
+ }
1999
+ };
2000
+ // Bounded, killable tarball fetch with zero global mutation.
2001
+ let tarball;
2002
+ try {
2003
+ tarball = doFetchTarball(H2A_CLI_PACKAGE, target, stagingTarballDir);
2004
+ }
2005
+ catch (e) {
2006
+ tarball = { ok: false, error: e instanceof Error ? e.message : String(e) };
2007
+ }
2008
+ if (!tarball.ok || !tarball.file) {
2009
+ const err = tarball.error ?? "fetch failed";
2010
+ diag({
2011
+ at: startedAt,
2012
+ durationMs: nowFn() - startedAt,
2013
+ prefix: resolvedPrefix,
2014
+ current,
2015
+ target,
2016
+ outcome: "deferred-propagation",
2017
+ error: err
2018
+ });
2019
+ recordFailure("deferred-propagation");
2020
+ cleanupAttempt();
2021
+ return {
2022
+ current,
2023
+ target,
2024
+ outcome: "deferred-propagation",
2025
+ message: `Update ${target} available but not installable right now, retry on next boot`,
2026
+ logPath
2027
+ };
2028
+ }
2029
+ const tarballFile = tarball.file;
2030
+ // Stage self-contained locally, then verify the staged binary reports the target.
2031
+ let staged;
2032
+ try {
2033
+ staged = doStageInstall(tarballFile, stagingPrefix);
2034
+ }
2035
+ catch (e) {
2036
+ staged = { ok: false, error: e instanceof Error ? e.message : String(e) };
2037
+ }
2038
+ if (!staged.ok) {
2039
+ const err = staged.error ?? "stage failed";
2040
+ diag({
2041
+ at: startedAt,
2042
+ durationMs: nowFn() - startedAt,
2043
+ prefix: resolvedPrefix,
2044
+ current,
2045
+ target,
2046
+ outcome: "failed",
2047
+ error: err
2048
+ });
2049
+ recordFailure("failed");
2050
+ cleanupAttempt();
2051
+ return {
2052
+ current,
2053
+ target,
2054
+ outcome: "failed",
2055
+ message: `auto-upgrade to ${target} failed: ${err} (see ${logPath})`,
2056
+ logPath
2057
+ };
2058
+ }
2059
+ let probed;
2060
+ try {
2061
+ probed = doProbeStaged(stagingPrefix);
2062
+ }
2063
+ catch {
2064
+ probed = undefined;
2065
+ }
2066
+ if (probed !== target) {
2067
+ const err = `staged version mismatch: expected ${target}, got ${probed ?? "unknown"}`;
2068
+ diag({
2069
+ at: startedAt,
2070
+ durationMs: nowFn() - startedAt,
2071
+ prefix: resolvedPrefix,
2072
+ current,
2073
+ target,
2074
+ outcome: "failed",
2075
+ error: err
2076
+ });
2077
+ recordFailure("failed");
2078
+ cleanupAttempt();
2079
+ return {
2080
+ current,
2081
+ target,
2082
+ outcome: "failed",
2083
+ message: `auto-upgrade to ${target} failed: ${err} (see ${logPath})`,
2084
+ logPath
2085
+ };
2086
+ }
2087
+ // Native validation before swap: `h2a --version` never loads node-pty, so a
2088
+ // staged dir can pass the probe yet break `h2a run`. Fail closed here.
2089
+ let native;
2090
+ try {
2091
+ native = doVerifyNative(stagingPkgDir);
2092
+ }
2093
+ catch (e) {
2094
+ native = { ok: false, error: e instanceof Error ? e.message : String(e) };
2095
+ }
2096
+ if (!native.ok) {
2097
+ const err = native.error ?? "staged native module failed to load";
2098
+ diag({
2099
+ at: startedAt,
2100
+ durationMs: nowFn() - startedAt,
2101
+ prefix: resolvedPrefix,
2102
+ current,
2103
+ target,
2104
+ outcome: "failed",
2105
+ error: err
2106
+ });
2107
+ recordFailure("failed");
2108
+ cleanupAttempt();
2109
+ return {
2110
+ current,
2111
+ target,
2112
+ outcome: "failed",
2113
+ message: `auto-upgrade to ${target} failed: ${err} (see ${logPath})`,
2114
+ logPath
2115
+ };
2116
+ }
2117
+ // Atomic rename swap, then verify the global root reports the target.
2118
+ // Always switch the prepared autonomous folder (no dep-range gate: a
2119
+ // self-contained version brings its own nested deps).
2120
+ let swapped;
2121
+ try {
2122
+ swapped = doSwap(resolvedPrefix, stagingPkgDir, target);
2123
+ }
2124
+ catch (e) {
2125
+ swapped = { ok: false, error: e instanceof Error ? e.message : String(e) };
2126
+ }
2127
+ if (!swapped.ok) {
2128
+ const err = swapped.error ?? "swap failed";
2129
+ diag({
2130
+ at: startedAt,
2131
+ durationMs: nowFn() - startedAt,
2132
+ prefix: resolvedPrefix,
2133
+ current,
2134
+ target,
2135
+ outcome: "failed",
2136
+ error: err
2137
+ });
2138
+ recordFailure("failed");
2139
+ cleanupAttempt();
2140
+ return {
2141
+ current,
2142
+ target,
2143
+ outcome: "failed",
2144
+ message: `auto-upgrade to ${target} failed: ${err} (see ${logPath})`,
2145
+ logPath
2146
+ };
2147
+ }
2148
+ let verified;
2149
+ try {
2150
+ verified = doReadGlobal(resolvedPrefix);
2151
+ }
2152
+ catch {
2153
+ verified = undefined;
2154
+ }
2155
+ diag({
2156
+ at: startedAt,
2157
+ durationMs: nowFn() - startedAt,
2158
+ prefix: resolvedPrefix,
2159
+ current,
2160
+ target,
2161
+ outcome: verified === target ? "upgraded" : "failed",
2162
+ verifiedVersion: verified
2163
+ });
2164
+ if (verified === target) {
2165
+ // M4: drop the backup and sweep residues UNDER the lock. Inodes stay
2166
+ // valid for already-launched processes; the sweep never touches a live
2167
+ // attempt's staging (live owner => keep, no age shortcut).
2168
+ if (typeof swapped.prevDir === "string" && swapped.prevDir) {
2169
+ try {
2170
+ rmSyncSafe(swapped.prevDir);
2171
+ }
2172
+ catch {
2173
+ // best-effort: the next sweep retries
2174
+ }
2175
+ }
2176
+ try {
2177
+ sweepUpgradeResidues(resolvedPrefix, lock.token);
2178
+ }
2179
+ catch {
2180
+ // best-effort
2181
+ }
2182
+ if (cachePath) {
2183
+ try {
2184
+ const prev = readEntry();
2185
+ writeEntry({
2186
+ checkedAt: prev?.checkedAt ?? startedAt,
2187
+ latest: target,
2188
+ lastAttemptAt: nowFn(),
2189
+ consecutiveFailures: 0,
2190
+ lastAttemptVersion: target,
2191
+ lastOutcome: "ok"
2192
+ });
2193
+ }
2194
+ catch {
2195
+ // best-effort
2196
+ }
2197
+ }
2198
+ cleanupAttempt();
2199
+ return {
2200
+ current,
2201
+ target,
2202
+ outcome: "upgraded",
2203
+ verifiedVersion: verified,
2204
+ message: `auto-upgraded ${current} → ${target} (applies on next launch) [verified]`,
2205
+ logPath
2206
+ };
2207
+ }
2208
+ recordFailure("failed");
2209
+ cleanupAttempt();
2210
+ return {
2211
+ current,
2212
+ target,
2213
+ outcome: "failed",
2214
+ ...(verified ? { verifiedVersion: verified } : {}),
2215
+ message: `auto-upgrade to ${target} failed: verification mismatch (got ${verified ?? "unknown"}) (see ${logPath})`,
2216
+ logPath
2217
+ };
2218
+ }
2219
+ finally {
2220
+ try {
2221
+ lock.release();
2222
+ }
2223
+ catch {
2224
+ // best-effort
2225
+ }
2226
+ }
2227
+ }
185
2228
  //# sourceMappingURL=index.js.map