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