@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.
- package/.claude-plugin/plugin.json +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/dist/cli-contract.js +1 -1
- package/dist/cli-contract.js.map +1 -1
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +85 -48
- package/dist/cli.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -2
- package/dist/index.js.map +1 -1
- package/dist/runtime/upgrade/index.d.ts +239 -3
- package/dist/runtime/upgrade/index.d.ts.map +1 -1
- package/dist/runtime/upgrade/index.js +2052 -9
- package/dist/runtime/upgrade/index.js.map +1 -1
- package/package.json +3 -3
|
@@ -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 {
|
|
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
|
-
/**
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|