@indigoai-us/hq-cli 5.109.10 → 5.109.12
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/CHANGELOG.md +39 -0
- package/dist/startup-registration.d.ts +21 -1
- package/dist/startup-registration.js +62 -9
- package/dist/utils/incomplete-install-error.d.ts +37 -10
- package/dist/utils/incomplete-install-error.js +126 -46
- package/dist/utils/install-tree-torn.d.ts +168 -14
- package/dist/utils/install-tree-torn.js +340 -26
- package/dist/utils/sentry-fingerprint.js +5 -5
- package/package.json +1 -1
|
@@ -34,11 +34,15 @@ import * as path from "node:path";
|
|
|
34
34
|
import { fileURLToPath } from "node:url";
|
|
35
35
|
import { CLI_NAME } from "../cli-version.js";
|
|
36
36
|
import { boundedDiagnosticValue } from "./package-root-diagnostics.js";
|
|
37
|
-
import { isLockStale } from "./update-lock.js";
|
|
37
|
+
import { isLockStale, UPDATE_LOCK_STALE_MS } from "./update-lock.js";
|
|
38
38
|
/** Byte caps for the (hq-derived, path-shaped) diagnostic strings. */
|
|
39
39
|
const SPECIFIER_BYTES = 256;
|
|
40
40
|
const IMPORTER_BYTES = 256;
|
|
41
41
|
const PACKAGE_ROOT_BYTES = 256;
|
|
42
|
+
/** Byte cap for the lock holder's `tool` (the only foreign string this reads). */
|
|
43
|
+
const LOCK_TOOL_BYTES = 64;
|
|
44
|
+
/** The eval-throw error name is a short closed-set token; the cap is only a belt. */
|
|
45
|
+
const ERRORNAME_BYTES = 64;
|
|
42
46
|
const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
43
47
|
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
44
48
|
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
@@ -52,6 +56,96 @@ const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)
|
|
|
52
56
|
* the settle wait. `load\b` also excludes the sibling `esm/loader` module.
|
|
53
57
|
*/
|
|
54
58
|
const ESM_LOADER_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]load\b/;
|
|
59
|
+
/**
|
|
60
|
+
* The Node module RESOLVER frame — `finalizeResolution` in
|
|
61
|
+
* `node:internal/modules/esm/resolve` (the observed HQ-CLI-1S route, where
|
|
62
|
+
* realpathSync lstat's a component that vanished after the earlier stat), or the
|
|
63
|
+
* CJS `Module._findPath → toRealPath` route in `node:internal/modules/cjs/loader`
|
|
64
|
+
* (the same stat-then-lstat TOCTOU, accepted for symmetry). Requiring a resolver
|
|
65
|
+
* frame — alongside `syscall === 'lstat'` — is what proves the ENOENT came from
|
|
66
|
+
* the resolver's realpath step, so an ordinary `fs.lstatSync` ENOENT written by
|
|
67
|
+
* hq's own code (no resolver frame) is NOT misclassified as a torn install.
|
|
68
|
+
*/
|
|
69
|
+
const RESOLVER_FRAME = /node:internal[\\/]modules[\\/](?:esm[\\/]resolve|cjs[\\/]loader)\b/;
|
|
70
|
+
/** The closed set of error `name`s the eval-throw dialect accepts (HQ-CLI-1T). */
|
|
71
|
+
const EVAL_THROW_NAMES = new Set(["TypeError", "ReferenceError", "SyntaxError"]);
|
|
72
|
+
/**
|
|
73
|
+
* A CJS module-EVALUATION frame — `Module._compile` / `wrapSafe` in
|
|
74
|
+
* `node:internal/modules/cjs/loader`, where a required module's top-level code
|
|
75
|
+
* runs (and where a SyntaxError is raised at compile). NOT the resolve frame
|
|
76
|
+
* `Module._resolveFilename`, so a resolution failure is never read as an eval.
|
|
77
|
+
*/
|
|
78
|
+
const CJS_EVAL_FRAME = /\bat (?:Module\._compile|wrapSafe) \(node:internal[\\/]modules[\\/]cjs[\\/]loader/;
|
|
79
|
+
/** An ESM module-EVALUATION frame — `ModuleJob` run/evaluate in esm/module_job. */
|
|
80
|
+
const ESM_EVAL_FRAME = /node:internal[\\/]modules[\\/]esm[\\/]module_job\b/;
|
|
81
|
+
/** Whether `stack` carries a CJS or ESM module-EVALUATION loader frame. */
|
|
82
|
+
function hasModuleEvalFrame(stack) {
|
|
83
|
+
return CJS_EVAL_FRAME.test(stack) || ESM_EVAL_FRAME.test(stack);
|
|
84
|
+
}
|
|
85
|
+
/**
|
|
86
|
+
* A stack LOCATION token — the absolute filesystem path in either an
|
|
87
|
+
* `at <fn> (<abs>:L:C)` frame or a bare `<abs>:L` header line (the source
|
|
88
|
+
* location a SyntaxError prepends). Anchored at line-start or an opening paren so
|
|
89
|
+
* a `node:internal/…` internal frame (never an absolute FS path) is skipped, and
|
|
90
|
+
* a Windows drive/UNC path is matched by shape so it classifies on any host.
|
|
91
|
+
*/
|
|
92
|
+
const STACK_LOCATION = /(?:^|\()((?:\/|[A-Za-z]:[\\/]|\\\\)[^():\n]*?):\d+(?::\d+)?(?:\)|$)/;
|
|
93
|
+
/**
|
|
94
|
+
* A `file:` URL location in a stack frame — the form Node uses for an ESM module
|
|
95
|
+
* EVALUATION frame (`at file:///abs/mod.mjs:2:1`, or the same wrapped in parens).
|
|
96
|
+
* The whole non-space token is captured; the caller strips the trailing paren and
|
|
97
|
+
* `:line[:col]` and decodes it with fileURLToPath.
|
|
98
|
+
*/
|
|
99
|
+
function fileUrlFromFrame(line) {
|
|
100
|
+
const token = line.match(/file:\/\/\/\S+/);
|
|
101
|
+
if (!token)
|
|
102
|
+
return null;
|
|
103
|
+
const url = token[0].replace(/\)+$/, "").replace(/:\d+(?::\d+)?$/, "");
|
|
104
|
+
try {
|
|
105
|
+
return fileURLToPath(url);
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
return null; // not a decodable file URL
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The eval-throw THROW SITE — the FIRST absolute filesystem path token in the
|
|
113
|
+
* stack, scanning top-down: for an ordinary error that is the throwing frame (a
|
|
114
|
+
* raw path for a CJS module, or a `file:` URL for an ESM one); for a SyntaxError
|
|
115
|
+
* it is the `<abs>:L` header line the stack carries before the `Error:` line.
|
|
116
|
+
* Returns null when no absolute path token is present.
|
|
117
|
+
*/
|
|
118
|
+
function evalThrowSite(stack) {
|
|
119
|
+
for (const rawLine of stack.split("\n")) {
|
|
120
|
+
const line = rawLine.trim();
|
|
121
|
+
const plain = STACK_LOCATION.exec(line);
|
|
122
|
+
if (plain)
|
|
123
|
+
return plain[1];
|
|
124
|
+
const fromUrl = fileUrlFromFrame(line);
|
|
125
|
+
if (fromUrl)
|
|
126
|
+
return fromUrl;
|
|
127
|
+
}
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Lexical `<root>/node_modules/` containment for the eval-throw throw-site gate.
|
|
132
|
+
* Separator- and (Windows-shape-only) case-folded and anchored at a true
|
|
133
|
+
* directory boundary, so a sibling such as `<root>-old/node_modules/…` never
|
|
134
|
+
* matches. Mirrors incomplete-install-error.ts's isUnderNodeModules discipline;
|
|
135
|
+
* kept local because that module imports THIS one (a shared import would cycle).
|
|
136
|
+
*/
|
|
137
|
+
function looksWin32(p) {
|
|
138
|
+
return /^[a-zA-Z]:[\\/]/.test(p) || /^\\\\/.test(p);
|
|
139
|
+
}
|
|
140
|
+
function foldForCompare(p) {
|
|
141
|
+
const folded = p.replace(/[\\/]+/g, "/").replace(/\/+$/, "");
|
|
142
|
+
return looksWin32(p) ? folded.toLowerCase() : folded;
|
|
143
|
+
}
|
|
144
|
+
function isUnderNodeModules(candidate, root) {
|
|
145
|
+
if (!candidate || !root)
|
|
146
|
+
return false;
|
|
147
|
+
return foldForCompare(candidate).startsWith(`${foldForCompare(root)}/node_modules/`);
|
|
148
|
+
}
|
|
55
149
|
/**
|
|
56
150
|
* A relative module specifier — `./x`, `../x`, `.\x`, `..\x`, or a bare `.`/`..`.
|
|
57
151
|
* Mirrors the shape src/utils/incomplete-install-error.ts uses, extended with the
|
|
@@ -120,6 +214,28 @@ export function classifyModuleNotFound(err) {
|
|
|
120
214
|
target: { kind: "path", path: record.path },
|
|
121
215
|
};
|
|
122
216
|
}
|
|
217
|
+
// resolve-time ENOENT (HQ-CLI-1S): Node's resolver stat'ed a path OK, then
|
|
218
|
+
// realpathSync lstat'ed a component that a concurrent reinstall renamed away
|
|
219
|
+
// between the two, throwing ENOENT with syscall `lstat` from a RESOLVER frame
|
|
220
|
+
// (finalizeResolution / Module._findPath→toRealPath). Gated on the FULL
|
|
221
|
+
// conjunction so an ordinary fs.lstatSync ENOENT from hq's own code (no
|
|
222
|
+
// resolver frame) and a rename/unlink ENOENT (syscall not `lstat`) both stay
|
|
223
|
+
// null. The vanished component may be a package/scope DIRECTORY whose bare
|
|
224
|
+
// existence precedes its `package.json`, so the target is `exists` (presence
|
|
225
|
+
// only); readiness still rides the lock / manifest-health / retired-sibling /
|
|
226
|
+
// quiet guards. Structurally a loader race like esm-enoent — no writer gate.
|
|
227
|
+
if (record.syscall === "lstat" &&
|
|
228
|
+
typeof record.path === "string" &&
|
|
229
|
+
typeof record.stack === "string" &&
|
|
230
|
+
RESOLVER_FRAME.test(record.stack)) {
|
|
231
|
+
return {
|
|
232
|
+
code,
|
|
233
|
+
dialect: "resolve-enoent",
|
|
234
|
+
specifier: record.path,
|
|
235
|
+
importer: "",
|
|
236
|
+
target: { kind: "exists", path: record.path },
|
|
237
|
+
};
|
|
238
|
+
}
|
|
123
239
|
return null;
|
|
124
240
|
}
|
|
125
241
|
if (code !== "ERR_MODULE_NOT_FOUND" && code !== "MODULE_NOT_FOUND")
|
|
@@ -204,6 +320,61 @@ export function classifyModuleNotFound(err) {
|
|
|
204
320
|
// the lock / retired-dir / quiet signals only.
|
|
205
321
|
return { code, dialect: "unknown", specifier: "", importer: "", target: { kind: "unknown" } };
|
|
206
322
|
}
|
|
323
|
+
/**
|
|
324
|
+
* Detect a module-EVALUATION throw STRUCTURALLY, WITHOUT the node_modules
|
|
325
|
+
* boundary gate: an error with NO `code`, a `name` in the closed set
|
|
326
|
+
* {TypeError, ReferenceError, SyntaxError}, a CJS/ESM module-evaluation loader
|
|
327
|
+
* frame, and a resolvable throw-site path. This is the shape a half-written
|
|
328
|
+
* dependency file produces — require() read an empty module and returned `{}`, so
|
|
329
|
+
* calling one of its exports throws a code-less TypeError while the module's own
|
|
330
|
+
* top-level code runs. No message text is ever parsed. The caller applies its own
|
|
331
|
+
* packageRoot gate ({@link classifyEvalThrow} for recovery, the boundary for
|
|
332
|
+
* capture context), so this stays pure and reusable. Returns null for anything
|
|
333
|
+
* that is not this shape — a coded error (ERR_REQUIRE_ESM etc.), an Error /
|
|
334
|
+
* RangeError name, a throw with no loader frame, or no absolute throw site.
|
|
335
|
+
*/
|
|
336
|
+
export function evalThrowShape(err) {
|
|
337
|
+
if (err === null || typeof err !== "object")
|
|
338
|
+
return null;
|
|
339
|
+
const record = err;
|
|
340
|
+
if (record.code !== undefined)
|
|
341
|
+
return null;
|
|
342
|
+
const name = typeof record.name === "string" ? record.name : "";
|
|
343
|
+
if (!EVAL_THROW_NAMES.has(name))
|
|
344
|
+
return null;
|
|
345
|
+
if (typeof record.stack !== "string" || !hasModuleEvalFrame(record.stack))
|
|
346
|
+
return null;
|
|
347
|
+
const throwSite = evalThrowSite(record.stack);
|
|
348
|
+
if (throwSite === null)
|
|
349
|
+
return null;
|
|
350
|
+
return { errorName: name, throwSite };
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Classify a module-EVALUATION throw as a torn-install recovery candidate
|
|
354
|
+
* (HQ-CLI-1T) — the {@link evalThrowShape} fingerprint AND a throw site under
|
|
355
|
+
* `<packageRoot>/node_modules/`. A throw under the install's own `dist/` or
|
|
356
|
+
* `assets/`, outside the root, or with an unresolved root is NOT classified, so a
|
|
357
|
+
* genuine hq-cli code defect keeps its raw capture. Unlike the loader-race
|
|
358
|
+
* dialects the recovery seam gates this on a live writer signal before waiting.
|
|
359
|
+
* `packageRoot` comes from the seam's resolvePackageRoot (running-install → derived
|
|
360
|
+
* fallback). The target is `unknown` — an eval-throw carries no re-resolvable
|
|
361
|
+
* missing file, so recovery keys entirely on the writer signal + quiet window.
|
|
362
|
+
*/
|
|
363
|
+
export function classifyEvalThrow(err, packageRoot) {
|
|
364
|
+
const shape = evalThrowShape(err);
|
|
365
|
+
if (shape === null)
|
|
366
|
+
return null;
|
|
367
|
+
if (!packageRoot || !isUnderNodeModules(shape.throwSite, packageRoot))
|
|
368
|
+
return null;
|
|
369
|
+
return {
|
|
370
|
+
code: "EVAL_THROW",
|
|
371
|
+
dialect: "eval-throw",
|
|
372
|
+
specifier: shape.throwSite,
|
|
373
|
+
importer: "",
|
|
374
|
+
target: { kind: "unknown" },
|
|
375
|
+
errorName: shape.errorName,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
207
378
|
const nodeInstallTreeFs = {
|
|
208
379
|
existsSync: (p) => nodeFs.existsSync(p),
|
|
209
380
|
statSync: (p) => nodeFs.statSync(p),
|
|
@@ -287,6 +458,8 @@ function relativeTargetResolvable(base, fs) {
|
|
|
287
458
|
* or a directory whose package.json `main` (or that main's index)
|
|
288
459
|
* or own `index.*` is a real file. A bare or partially-extracted
|
|
289
460
|
* directory is NOT loadable and reads as not-present.
|
|
461
|
+
* - `exists`: the path exists at all (any file type) — presence only, because
|
|
462
|
+
* the vanished component may be a package/scope directory.
|
|
290
463
|
* - `unknown`: true (readiness turns on the other signals).
|
|
291
464
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
292
465
|
*/
|
|
@@ -294,6 +467,12 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
|
294
467
|
try {
|
|
295
468
|
if (target.kind === "unknown")
|
|
296
469
|
return true;
|
|
470
|
+
// `exists` (resolve-enoent, HQ-CLI-1S): the lstat'ed component may be a
|
|
471
|
+
// package/scope DIRECTORY whose bare existence precedes its `package.json`, so
|
|
472
|
+
// presence alone is the signal here — the surrounding manifest-health /
|
|
473
|
+
// retired-sibling / quiet guards keep a premature re-exec bounded.
|
|
474
|
+
if (target.kind === "exists")
|
|
475
|
+
return fs.existsSync(target.path);
|
|
297
476
|
if (target.kind === "path") {
|
|
298
477
|
if (!fs.existsSync(target.path))
|
|
299
478
|
return false;
|
|
@@ -332,6 +511,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
|
332
511
|
return false;
|
|
333
512
|
}
|
|
334
513
|
}
|
|
514
|
+
/**
|
|
515
|
+
* Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
|
|
516
|
+
* foreign lock held by a live pid. Equal to the shared update-lock contract's own
|
|
517
|
+
* staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
|
|
518
|
+
* this package runs 10 minutes"), so the wait trusts a live cooperating writer for
|
|
519
|
+
* exactly as long as the contract legitimises holding the lock, and never longer.
|
|
520
|
+
* Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
|
|
521
|
+
*/
|
|
522
|
+
export const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS = UPDATE_LOCK_STALE_MS;
|
|
335
523
|
/** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
|
|
336
524
|
function cliNameParts() {
|
|
337
525
|
const slash = CLI_NAME.indexOf("/");
|
|
@@ -339,26 +527,32 @@ function cliNameParts() {
|
|
|
339
527
|
return { scope: null, leaf: CLI_NAME };
|
|
340
528
|
return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
|
|
341
529
|
}
|
|
342
|
-
/**
|
|
343
|
-
|
|
530
|
+
/**
|
|
531
|
+
* The FRESH update lock held by a DIFFERENT live pid, or null. A non-null result
|
|
532
|
+
* blocks readiness AND extends the wait under the lock ceiling; the lock file is
|
|
533
|
+
* only ever READ. Freshness is decided by {@link isLockStale} exactly as before
|
|
534
|
+
* (parseable, `startedAt` younger than {@link UPDATE_LOCK_STALE_MS}, pid alive),
|
|
535
|
+
* and our own pid never counts.
|
|
536
|
+
*/
|
|
537
|
+
function freshForeignLock(lockPath, nowMs, isPidAlive, fs) {
|
|
344
538
|
let raw;
|
|
345
539
|
try {
|
|
346
540
|
raw = fs.readFileSync(lockPath, "utf-8");
|
|
347
541
|
}
|
|
348
542
|
catch {
|
|
349
|
-
return
|
|
543
|
+
return null; // no lock (ENOENT) or unreadable — not a fresh foreign holder
|
|
350
544
|
}
|
|
351
545
|
if (isLockStale(raw, nowMs, isPidAlive))
|
|
352
|
-
return
|
|
546
|
+
return null;
|
|
353
547
|
try {
|
|
354
548
|
const info = JSON.parse(raw);
|
|
355
549
|
if (info.pid === process.pid)
|
|
356
|
-
return
|
|
550
|
+
return null; // our own lock never blocks us
|
|
551
|
+
return info;
|
|
357
552
|
}
|
|
358
553
|
catch {
|
|
359
|
-
return
|
|
554
|
+
return null; // isLockStale already treats unparseable as stale, unreachable
|
|
360
555
|
}
|
|
361
|
-
return true;
|
|
362
556
|
}
|
|
363
557
|
/** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
|
|
364
558
|
function retiredSiblingPresent(packageRoot, fs) {
|
|
@@ -375,13 +569,33 @@ function packageRootHealthy(packageRoot, fs) {
|
|
|
375
569
|
}
|
|
376
570
|
/**
|
|
377
571
|
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
378
|
-
*
|
|
379
|
-
* (ii) the missing target is present, and — when `packageRoot` is known —
|
|
380
|
-
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
|
|
381
|
-
*
|
|
382
|
-
*
|
|
383
|
-
*
|
|
384
|
-
* bounded
|
|
572
|
+
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
573
|
+
* pid, (ii) the missing target is present, and — when `packageRoot` is known —
|
|
574
|
+
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
|
|
575
|
+
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
576
|
+
* its condition "not ready" rather than throwing.
|
|
577
|
+
*
|
|
578
|
+
* The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
|
|
579
|
+
* cooperating writer (the desktop app, the box's update timer, another `hq`)
|
|
580
|
+
* cannot make a legitimately-in-progress reinstall look like a torn install:
|
|
581
|
+
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
582
|
+
* by `deadlineMs`. That is the budget for the TREE to settle once no writer is
|
|
583
|
+
* visible; with no lock ever seen, unlocked time equals total wait time and
|
|
584
|
+
* the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
|
|
585
|
+
* `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
|
|
586
|
+
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
587
|
+
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
588
|
+
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
589
|
+
* that ceiling is reached with the lock still held, the result is unsettled
|
|
590
|
+
* with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
|
|
591
|
+
* outcome and remedy rather than telling the user to reinstall over a live
|
|
592
|
+
* writer.
|
|
593
|
+
* Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
|
|
594
|
+
* unlocked bucket by whether a fresh foreign lock was held when the interval
|
|
595
|
+
* began, so a starved event loop only makes the wait coarser, never unbounded. A
|
|
596
|
+
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
597
|
+
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
598
|
+
* lockWaitMs`), so the caller can never hang.
|
|
385
599
|
*/
|
|
386
600
|
export async function waitForInstallTreeSettled(args) {
|
|
387
601
|
const now = args.now ?? Date.now;
|
|
@@ -391,16 +605,32 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
391
605
|
const pollMs = args.pollMs ?? 250;
|
|
392
606
|
const quietMs = args.quietMs ?? 1500;
|
|
393
607
|
const deadlineMs = args.deadlineMs;
|
|
608
|
+
const lockWaitMs = args.lockWaitMs ?? DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS;
|
|
394
609
|
const start = now();
|
|
395
610
|
let readySince = null;
|
|
396
611
|
let sawLock = false;
|
|
397
612
|
let sawRetired = false;
|
|
613
|
+
let lockedMs = 0;
|
|
614
|
+
let unlockedMs = 0;
|
|
615
|
+
let lockTool = "";
|
|
616
|
+
let prevT = start;
|
|
617
|
+
let prevForeignLockHeld = false;
|
|
618
|
+
let lockExtendedFired = false;
|
|
398
619
|
for (;;) {
|
|
399
620
|
const t = now();
|
|
400
|
-
|
|
401
|
-
|
|
621
|
+
// Attribute the interval that just elapsed [prevT, t) to the bucket for the
|
|
622
|
+
// lock state that held when it BEGAN (real now() deltas, never nominal pollMs).
|
|
623
|
+
if (prevForeignLockHeld)
|
|
624
|
+
lockedMs += t - prevT;
|
|
625
|
+
else
|
|
626
|
+
unlockedMs += t - prevT;
|
|
627
|
+
const lock = freshForeignLock(args.lockPath, t, isPidAlive, fs);
|
|
628
|
+
const foreignLockHeld = lock !== null;
|
|
629
|
+
if (foreignLockHeld) {
|
|
402
630
|
sawLock = true;
|
|
403
|
-
|
|
631
|
+
lockTool = boundedDiagnosticValue(lock.tool, LOCK_TOOL_BYTES);
|
|
632
|
+
}
|
|
633
|
+
let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
|
|
404
634
|
if (ready && args.packageRoot) {
|
|
405
635
|
try {
|
|
406
636
|
if (!packageRootHealthy(args.packageRoot, fs))
|
|
@@ -430,14 +660,83 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
430
660
|
}
|
|
431
661
|
const waitedMs = t - start;
|
|
432
662
|
if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
|
|
433
|
-
|
|
663
|
+
// Settling requires no foreign lock, so it is never held at the end here.
|
|
664
|
+
return { settled: true, waitedMs, sawLock, sawRetired, lockHeldAtEnd: false, lockedMs, lockTool };
|
|
434
665
|
}
|
|
435
|
-
|
|
436
|
-
|
|
666
|
+
// Give up when the TREE has had a full unlocked budget with no writer visible,
|
|
667
|
+
// OR a writer is STILL holding the lock and has held it past its own staleness
|
|
668
|
+
// ceiling. The lock ceiling is gated on a lock being held RIGHT NOW so that a
|
|
669
|
+
// ceiling of 0 (HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS=0, "do not extend under a
|
|
670
|
+
// lock") disables only the extension and does not collapse the ordinary
|
|
671
|
+
// deadlineMs settle when no lock exists — `lockedMs` is 0 ≥ 0 on every poll.
|
|
672
|
+
// `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
|
|
673
|
+
// matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
|
|
674
|
+
if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
|
|
675
|
+
return { settled: false, waitedMs, sawLock, sawRetired, lockHeldAtEnd: foreignLockHeld, lockedMs, lockTool };
|
|
437
676
|
}
|
|
677
|
+
// The first poll at which the wait has reached `deadlineMs` while a lock is
|
|
678
|
+
// still held is exactly where the pre-lock-aware wait gave up — announce the
|
|
679
|
+
// extension once, and only when we are genuinely going to keep waiting.
|
|
680
|
+
if (!lockExtendedFired && foreignLockHeld && waitedMs >= deadlineMs) {
|
|
681
|
+
lockExtendedFired = true;
|
|
682
|
+
args.onLockExtended?.();
|
|
683
|
+
}
|
|
684
|
+
prevT = t;
|
|
685
|
+
prevForeignLockHeld = foreignLockHeld;
|
|
438
686
|
await sleep(pollMs);
|
|
439
687
|
}
|
|
440
688
|
}
|
|
689
|
+
/**
|
|
690
|
+
* Whether packageRoot's own manifest is ABSENT (ENOENT) or name-mismatched — a
|
|
691
|
+
* mid-extraction rewrite signal (npm has renamed the dir aside and is re-writing
|
|
692
|
+
* package.json). A read error OTHER than ENOENT (EACCES/EPERM) is a benign
|
|
693
|
+
* permission fault, not a rewrite, so it returns false rather than a false signal.
|
|
694
|
+
*/
|
|
695
|
+
function manifestRewriteSignal(packageRoot, fs) {
|
|
696
|
+
let raw;
|
|
697
|
+
try {
|
|
698
|
+
raw = fs.readFileSync(path.join(packageRoot, "package.json"), "utf-8");
|
|
699
|
+
}
|
|
700
|
+
catch (err) {
|
|
701
|
+
return err?.code === "ENOENT";
|
|
702
|
+
}
|
|
703
|
+
try {
|
|
704
|
+
return JSON.parse(raw).name !== CLI_NAME;
|
|
705
|
+
}
|
|
706
|
+
catch {
|
|
707
|
+
return true; // present but unparseable → mid-write
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
/**
|
|
711
|
+
* Whether a global reinstall is PROVABLY rewriting the install tree right now —
|
|
712
|
+
* the positive writer signal the eval-throw gate requires before it will pay a
|
|
713
|
+
* settle wait. True iff (i) a fresh foreign update lock is held, (ii) packageRoot's
|
|
714
|
+
* manifest is absent or name-mismatched (mid-extraction), or (iii) a
|
|
715
|
+
* `.<leaf>-<hash>` retired sibling sits beside it. Crucially it is POSITIVE: a
|
|
716
|
+
* benign filesystem error that merely prevents READING the manifest or LISTING the
|
|
717
|
+
* parent (EACCES/EPERM) is NOT a signal, so a persistent third-party load-time
|
|
718
|
+
* defect on an otherwise-usable install never pays a spurious 90s wait. Every
|
|
719
|
+
* dependency is injectable so the recovery seam stays hermetic.
|
|
720
|
+
*/
|
|
721
|
+
export function installRewriteInProgress(args) {
|
|
722
|
+
const now = (args.now ?? Date.now)();
|
|
723
|
+
const fs = args.fs ?? nodeInstallTreeFs;
|
|
724
|
+
const isPidAlive = args.isPidAlive ?? defaultIsPidAlive;
|
|
725
|
+
// #570 renamed freshForeignLockHeld → freshForeignLock, which returns the lock
|
|
726
|
+
// info (or null) instead of a boolean; a fresh foreign lock is held iff non-null.
|
|
727
|
+
if (freshForeignLock(args.lockPath, now, isPidAlive, fs) !== null)
|
|
728
|
+
return true;
|
|
729
|
+
if (!args.packageRoot)
|
|
730
|
+
return false;
|
|
731
|
+
if (manifestRewriteSignal(args.packageRoot, fs))
|
|
732
|
+
return true;
|
|
733
|
+
try {
|
|
734
|
+
return retiredSiblingPresent(args.packageRoot, fs);
|
|
735
|
+
}
|
|
736
|
+
catch {
|
|
737
|
+
return false; // parent not listable — benign, not a rewrite signal
|
|
738
|
+
}
|
|
739
|
+
}
|
|
441
740
|
/** The fixed, input-free carrier message (grouping cardinality stays bounded). */
|
|
442
741
|
const INSTALL_TREE_TORN_MESSAGE = "hq-cli install tree was being rewritten while this command started";
|
|
443
742
|
/**
|
|
@@ -464,7 +763,11 @@ export class InstallTreeTornError extends Error {
|
|
|
464
763
|
waitedMs: init.waitedMs,
|
|
465
764
|
sawLock: init.sawLock,
|
|
466
765
|
sawRetired: init.sawRetired,
|
|
766
|
+
lockHeldAtEnd: init.lockHeldAtEnd ?? false,
|
|
767
|
+
lockedMs: init.lockedMs ?? 0,
|
|
768
|
+
lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
|
|
467
769
|
node: process.version,
|
|
770
|
+
errorName: boundedDiagnosticValue(init.classified.errorName ?? "", ERRORNAME_BYTES),
|
|
468
771
|
};
|
|
469
772
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
470
773
|
}
|
|
@@ -486,15 +789,26 @@ export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by
|
|
|
486
789
|
"timer, the desktop app, or another hq command) while this command started. " +
|
|
487
790
|
"Re-run your command; if it keeps failing, reinstall with " +
|
|
488
791
|
"`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
|
|
792
|
+
/**
|
|
793
|
+
* The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
|
|
794
|
+
* held the shared update lock past its own staleness ceiling. Telling the user to
|
|
795
|
+
* `npm i -g` here would collide with a live installer mid-rename — the exact race
|
|
796
|
+
* update-lock.ts exists to prevent — so this remedy tells them to let the running
|
|
797
|
+
* update finish and NEVER to reinstall over it. Interpolates NOTHING.
|
|
798
|
+
*/
|
|
799
|
+
export const INSTALL_TREE_LOCK_HELD_REMEDY = "another hq updater (the desktop app, the box's hq-cli-update timer, or another " +
|
|
800
|
+
"hq command) was still installing hq-cli when this command stopped waiting. Let " +
|
|
801
|
+
"that update finish, then re-run your command; do not reinstall while it is running.";
|
|
489
802
|
/**
|
|
490
803
|
* The single actionable stderr line for a captured torn-install failure: the
|
|
491
|
-
* fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
492
|
-
* interpolated value, never argv.
|
|
804
|
+
* outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
805
|
+
* interpolated value, never argv. A 'lock-held' outcome selects the "let the
|
|
806
|
+
* update finish" remedy (never the reinstall advice); every other outcome keeps
|
|
807
|
+
* {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
|
|
493
808
|
*/
|
|
494
809
|
export function installTreeTornStderrLine(err) {
|
|
810
|
+
const remedy = err.diagnostics.outcome === "lock-held" ? INSTALL_TREE_LOCK_HELD_REMEDY : INSTALL_TREE_TORN_REMEDY;
|
|
495
811
|
const specifier = err.diagnostics.specifier;
|
|
496
|
-
return specifier
|
|
497
|
-
? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
|
|
498
|
-
: INSTALL_TREE_TORN_REMEDY;
|
|
812
|
+
return specifier ? `${remedy} (missing: ${specifier})` : remedy;
|
|
499
813
|
}
|
|
500
814
|
//# sourceMappingURL=install-tree-torn.js.map
|
|
@@ -92,8 +92,8 @@ const KNOWN_ERROR_NAMES = new Set([
|
|
|
92
92
|
// (HQ-CLI-1A).
|
|
93
93
|
"QmdWorkdirMissingError",
|
|
94
94
|
// A torn-install failure the bounded settle-wait + single re-exec could not
|
|
95
|
-
// recover (HQ-CLI-1G/1H/1J/1K). Fingerprinted on its own branch below
|
|
96
|
-
// bounded `diagnostics.outcome`, so the whole family groups into ≤
|
|
95
|
+
// recover (HQ-CLI-1G/1H/1J/1K, HQ-CLI-1Y). Fingerprinted on its own branch below
|
|
96
|
+
// by the bounded `diagnostics.outcome`, so the whole family groups into ≤4 issues.
|
|
97
97
|
"InstallTreeTornError",
|
|
98
98
|
]);
|
|
99
99
|
/**
|
|
@@ -102,7 +102,7 @@ const KNOWN_ERROR_NAMES = new Set([
|
|
|
102
102
|
* the carrier's message is fixed, so this outcome IS the only discriminator.
|
|
103
103
|
*/
|
|
104
104
|
const KNOWN_INSTALL_TREE_OUTCOMES = new Set([
|
|
105
|
-
"guarded", "unsettled", "reexec-failed",
|
|
105
|
+
"guarded", "unsettled", "reexec-failed", "lock-held",
|
|
106
106
|
]);
|
|
107
107
|
/** Fixed bucket for any error name outside the closed allowlist. */
|
|
108
108
|
const FALLBACK_ERROR_NAME = "other";
|
|
@@ -214,8 +214,8 @@ export function sentryFingerprintFor(err) {
|
|
|
214
214
|
}
|
|
215
215
|
// A torn-install failure carries neither an rpcCode nor an HTTP status; its
|
|
216
216
|
// only bounded discriminator is the recovery `diagnostics.outcome`. Keyed
|
|
217
|
-
// BEFORE the rpc/status branches so the family groups into ≤
|
|
218
|
-
//
|
|
217
|
+
// BEFORE the rpc/status branches so the family groups into ≤4 predictable issues
|
|
218
|
+
// (guarded / unsettled / reexec-failed / lock-held) rather than one fungible bucket.
|
|
219
219
|
if (rawName === "InstallTreeTornError") {
|
|
220
220
|
const diagnostics = record.diagnostics;
|
|
221
221
|
const outcome = diagnostics !== null && typeof diagnostics === "object"
|