@indigoai-us/hq-cli 5.109.10 → 5.109.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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.109.11] — 2026-09-12
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- A torn-install recovery no longer gives up after 90 seconds while another hq
|
|
10
|
+
updater is still legitimately installing hq-cli. When a command starts just as
|
|
11
|
+
the desktop app (or the box's `hq-cli-update` timer, or another `hq`) is
|
|
12
|
+
rewriting the install, the recovery waits for that writer to finish — bounded by
|
|
13
|
+
the shared install lock's own 10-minute staleness window rather than the 90-second
|
|
14
|
+
settle deadline — and then re-runs once on the healed tree, so the common case
|
|
15
|
+
(an install that finishes within the window) no longer fails at all. A short
|
|
16
|
+
second line explains the pause while it waits. If a writer holds the lock past
|
|
17
|
+
that ceiling, the command still fails once, but with a "let the update finish,
|
|
18
|
+
then re-run — do not reinstall while it is running" remedy and a distinct
|
|
19
|
+
`lock-held` outcome, instead of advice to reinstall over a live installer
|
|
20
|
+
(Sentry HQ-CLI-1Y, HQ-CLI-23). The ceiling is overridable with
|
|
21
|
+
`HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS`.
|
|
22
|
+
|
|
5
23
|
## [5.109.10] — 2026-09-12
|
|
6
24
|
|
|
7
25
|
### Fixed
|
|
@@ -35,6 +35,24 @@ export declare const DEFAULT_INSTALL_SETTLE_TIMEOUT_MS = 90000;
|
|
|
35
35
|
export declare const INSTALL_TREE_WAIT_NOTICE = "hq: the hq-cli install is being updated underneath this command; waiting for it to finish\u2026";
|
|
36
36
|
/** Resolve the settle deadline from the environment (0 = evaluate once; default on invalid). */
|
|
37
37
|
export declare function resolveSettleTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
38
|
+
/** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
|
|
39
|
+
export declare const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
|
|
40
|
+
/**
|
|
41
|
+
* Default ceiling (ms) on the time the settle wait spends under a fresh foreign
|
|
42
|
+
* lock held by a live cooperating writer — the update-lock contract's own
|
|
43
|
+
* staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
|
|
44
|
+
*/
|
|
45
|
+
export declare const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS: number;
|
|
46
|
+
/**
|
|
47
|
+
* The single dim line emitted once when the wait continues PAST the settle
|
|
48
|
+
* deadline because a live updater still holds the shared install lock — so a human
|
|
49
|
+
* knows the pause is a running update, not a hang. Never a remedy, never an exit
|
|
50
|
+
* code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
|
|
51
|
+
* synchronize on it.
|
|
52
|
+
*/
|
|
53
|
+
export declare const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish\u2026";
|
|
54
|
+
/** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
|
|
55
|
+
export declare function resolveSettleLockTimeoutMs(env: NodeJS.ProcessEnv): number;
|
|
38
56
|
/** Minimal view of a `spawnSync` result the seam relies on. */
|
|
39
57
|
export interface RecoverySpawnResult {
|
|
40
58
|
status: number | null;
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
import { spawnSync } from "node:child_process";
|
|
20
20
|
import { resolveRunningInstall } from "./utils/version-gate.js";
|
|
21
21
|
import { stringDerivedPackageRoot } from "./utils/hq-roots.js";
|
|
22
|
-
import { updateLockPath } from "./utils/update-lock.js";
|
|
22
|
+
import { updateLockPath, UPDATE_LOCK_STALE_MS } from "./utils/update-lock.js";
|
|
23
23
|
import { classifyModuleNotFound, InstallTreeTornError, waitForInstallTreeSettled, } from "./utils/install-tree-torn.js";
|
|
24
24
|
/**
|
|
25
25
|
* Set on the re-exec'd child so it can NEVER wait or re-exec again — distinct
|
|
@@ -46,6 +46,31 @@ export function resolveSettleTimeoutMs(env) {
|
|
|
46
46
|
return DEFAULT_INSTALL_SETTLE_TIMEOUT_MS;
|
|
47
47
|
return Number.parseInt(raw.trim(), 10);
|
|
48
48
|
}
|
|
49
|
+
/** Operator override for the settle wait's LOCK ceiling (time under a live writer). */
|
|
50
|
+
export const INSTALL_SETTLE_LOCK_TIMEOUT_ENV = "HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS";
|
|
51
|
+
/**
|
|
52
|
+
* Default ceiling (ms) on the time the settle wait spends under a fresh foreign
|
|
53
|
+
* lock held by a live cooperating writer — the update-lock contract's own
|
|
54
|
+
* staleness window. Overridable by {@link INSTALL_SETTLE_LOCK_TIMEOUT_ENV}.
|
|
55
|
+
*/
|
|
56
|
+
export const DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS = UPDATE_LOCK_STALE_MS;
|
|
57
|
+
/**
|
|
58
|
+
* The single dim line emitted once when the wait continues PAST the settle
|
|
59
|
+
* deadline because a live updater still holds the shared install lock — so a human
|
|
60
|
+
* knows the pause is a running update, not a hang. Never a remedy, never an exit
|
|
61
|
+
* code; worded distinctly from {@link INSTALL_TREE_WAIT_NOTICE} so the e2e can
|
|
62
|
+
* synchronize on it.
|
|
63
|
+
*/
|
|
64
|
+
export const INSTALL_TREE_LOCK_WAIT_NOTICE = "hq: another hq updater is still installing hq-cli; waiting for it to finish…";
|
|
65
|
+
/** Resolve the lock ceiling from the environment (0 = do not extend; default on invalid). */
|
|
66
|
+
export function resolveSettleLockTimeoutMs(env) {
|
|
67
|
+
const raw = env[INSTALL_SETTLE_LOCK_TIMEOUT_ENV];
|
|
68
|
+
if (typeof raw !== "string" || raw.trim() === "")
|
|
69
|
+
return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
|
|
70
|
+
if (!/^\d+$/.test(raw.trim()))
|
|
71
|
+
return DEFAULT_INSTALL_SETTLE_LOCK_TIMEOUT_MS;
|
|
72
|
+
return Number.parseInt(raw.trim(), 10);
|
|
73
|
+
}
|
|
49
74
|
/**
|
|
50
75
|
* Register commands, recovering once from a torn-install module-resolution
|
|
51
76
|
* failure. On a clean registration this returns `{}` and touches nothing else.
|
|
@@ -90,17 +115,27 @@ export async function registerCommandsWithRecovery(args) {
|
|
|
90
115
|
packageRoot,
|
|
91
116
|
lockPath,
|
|
92
117
|
deadlineMs: resolveSettleTimeoutMs(env),
|
|
118
|
+
lockWaitMs: resolveSettleLockTimeoutMs(env),
|
|
119
|
+
onLockExtended: () => stderr.write(`${INSTALL_TREE_LOCK_WAIT_NOTICE}\n`),
|
|
93
120
|
});
|
|
94
121
|
if (!result.settled) {
|
|
95
122
|
throw new InstallTreeTornError({
|
|
96
123
|
cause: err,
|
|
97
124
|
classified,
|
|
98
125
|
packageRoot,
|
|
99
|
-
|
|
126
|
+
// A fresh foreign lock still held at the end means a live cooperating
|
|
127
|
+
// writer held the shared lock past its own ceiling — report 'lock-held'
|
|
128
|
+
// (its remedy tells the user to let the update finish, never to reinstall
|
|
129
|
+
// over it). Otherwise the tree genuinely never settled with no writer
|
|
130
|
+
// visible, which keeps meaning 'unsettled'.
|
|
131
|
+
outcome: result.lockHeldAtEnd ? "lock-held" : "unsettled",
|
|
100
132
|
attempt: 1,
|
|
101
133
|
waitedMs: result.waitedMs,
|
|
102
134
|
sawLock: result.sawLock,
|
|
103
135
|
sawRetired: result.sawRetired,
|
|
136
|
+
lockHeldAtEnd: result.lockHeldAtEnd,
|
|
137
|
+
lockedMs: result.lockedMs,
|
|
138
|
+
lockTool: result.lockTool,
|
|
104
139
|
});
|
|
105
140
|
}
|
|
106
141
|
const spawn = deps.spawn ?? spawnSync;
|
|
@@ -137,6 +137,15 @@ export interface InstallTreeFs {
|
|
|
137
137
|
* Any filesystem error reads as "not present" rather than throwing.
|
|
138
138
|
*/
|
|
139
139
|
export declare function installTargetPresent(target: ModuleErrorTarget, fs?: InstallTreeFs): boolean;
|
|
140
|
+
/**
|
|
141
|
+
* Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
|
|
142
|
+
* foreign lock held by a live pid. Equal to the shared update-lock contract's own
|
|
143
|
+
* staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
|
|
144
|
+
* this package runs 10 minutes"), so the wait trusts a live cooperating writer for
|
|
145
|
+
* exactly as long as the contract legitimises holding the lock, and never longer.
|
|
146
|
+
* Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
|
|
147
|
+
*/
|
|
148
|
+
export declare const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS: number;
|
|
140
149
|
export interface WaitForInstallTreeSettledArgs {
|
|
141
150
|
target: ModuleErrorTarget;
|
|
142
151
|
/** The running install's package dir (from resolveRunningInstall), or null. */
|
|
@@ -150,6 +159,22 @@ export interface WaitForInstallTreeSettledArgs {
|
|
|
150
159
|
pollMs?: number;
|
|
151
160
|
quietMs?: number;
|
|
152
161
|
deadlineMs: number;
|
|
162
|
+
/**
|
|
163
|
+
* Ceiling on the CUMULATIVE time observed under a FRESH foreign lock held by a
|
|
164
|
+
* live pid. Locked time does NOT consume `deadlineMs` (the budget for the tree
|
|
165
|
+
* to settle once no cooperating writer is visible); it is bounded separately by
|
|
166
|
+
* this ceiling so the wait can never hang. Defaults to
|
|
167
|
+
* {@link DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS}.
|
|
168
|
+
*/
|
|
169
|
+
lockWaitMs?: number;
|
|
170
|
+
/**
|
|
171
|
+
* Invoked exactly once — at the first poll where the total wait has reached
|
|
172
|
+
* `deadlineMs` while a fresh foreign lock is still held (the moment the
|
|
173
|
+
* pre-lock-aware wait would have given up). Lets the seam print a one-line
|
|
174
|
+
* notice that the pause is a live updater, not a hang. Never invoked when no
|
|
175
|
+
* lock is seen or the lock clears before the deadline.
|
|
176
|
+
*/
|
|
177
|
+
onLockExtended?: () => void;
|
|
153
178
|
}
|
|
154
179
|
export interface WaitForInstallTreeSettledResult {
|
|
155
180
|
settled: boolean;
|
|
@@ -158,20 +183,46 @@ export interface WaitForInstallTreeSettledResult {
|
|
|
158
183
|
sawLock: boolean;
|
|
159
184
|
/** An npm retired/staging sibling (`.hq-cli-<rand>`) was observed at least once. */
|
|
160
185
|
sawRetired: boolean;
|
|
186
|
+
/** A fresh foreign lock was held on the FINAL poll of an unsettled wait. */
|
|
187
|
+
lockHeldAtEnd: boolean;
|
|
188
|
+
/** Cumulative wall time observed under a fresh foreign lock held by a live pid. */
|
|
189
|
+
lockedMs: number;
|
|
190
|
+
/** The bounded `tool` of the last fresh foreign lock seen, or "" if none. */
|
|
191
|
+
lockTool: string;
|
|
161
192
|
}
|
|
162
193
|
/**
|
|
163
194
|
* Poll until the install tree has been continuously READY for `quietMs`, or the
|
|
164
|
-
*
|
|
165
|
-
* (ii) the missing target is present, and — when `packageRoot` is known —
|
|
166
|
-
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and
|
|
167
|
-
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* bounded
|
|
195
|
+
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
196
|
+
* pid, (ii) the missing target is present, and — when `packageRoot` is known —
|
|
197
|
+
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
|
|
198
|
+
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
199
|
+
* its condition "not ready" rather than throwing.
|
|
200
|
+
*
|
|
201
|
+
* The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
|
|
202
|
+
* cooperating writer (the desktop app, the box's update timer, another `hq`)
|
|
203
|
+
* cannot make a legitimately-in-progress reinstall look like a torn install:
|
|
204
|
+
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
205
|
+
* by `deadlineMs`. That is the budget for the TREE to settle once no writer is
|
|
206
|
+
* visible; with no lock ever seen, unlocked time equals total wait time and
|
|
207
|
+
* the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
|
|
208
|
+
* `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
|
|
209
|
+
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
210
|
+
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
211
|
+
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
212
|
+
* that ceiling is reached with the lock still held, the result is unsettled
|
|
213
|
+
* with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
|
|
214
|
+
* outcome and remedy rather than telling the user to reinstall over a live
|
|
215
|
+
* writer.
|
|
216
|
+
* Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
|
|
217
|
+
* unlocked bucket by whether a fresh foreign lock was held when the interval
|
|
218
|
+
* began, so a starved event loop only makes the wait coarser, never unbounded. A
|
|
219
|
+
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
220
|
+
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
221
|
+
* lockWaitMs`), so the caller can never hang.
|
|
171
222
|
*/
|
|
172
223
|
export declare function waitForInstallTreeSettled(args: WaitForInstallTreeSettledArgs): Promise<WaitForInstallTreeSettledResult>;
|
|
173
224
|
/** The recovery OUTCOME, which — not the dialect — decides capture. */
|
|
174
|
-
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed";
|
|
225
|
+
export type InstallTreeTornOutcome = "guarded" | "unsettled" | "reexec-failed" | "lock-held";
|
|
175
226
|
export type InstallTreeTornDiagnostics = {
|
|
176
227
|
dialect: ModuleErrorDialect;
|
|
177
228
|
code: ModuleNotFoundCode;
|
|
@@ -183,6 +234,12 @@ export type InstallTreeTornDiagnostics = {
|
|
|
183
234
|
waitedMs: number;
|
|
184
235
|
sawLock: boolean;
|
|
185
236
|
sawRetired: boolean;
|
|
237
|
+
/** A fresh foreign lock was still held when the wait gave up (→ 'lock-held'). */
|
|
238
|
+
lockHeldAtEnd: boolean;
|
|
239
|
+
/** Cumulative wall time the wait spent under a fresh foreign lock. */
|
|
240
|
+
lockedMs: number;
|
|
241
|
+
/** The bounded `tool` of the last fresh foreign lock seen, or "". */
|
|
242
|
+
lockTool: string;
|
|
186
243
|
node: string;
|
|
187
244
|
};
|
|
188
245
|
export interface InstallTreeTornInit {
|
|
@@ -195,6 +252,12 @@ export interface InstallTreeTornInit {
|
|
|
195
252
|
waitedMs: number;
|
|
196
253
|
sawLock: boolean;
|
|
197
254
|
sawRetired: boolean;
|
|
255
|
+
/** Whether a fresh foreign lock was held when the wait gave up (default false). */
|
|
256
|
+
lockHeldAtEnd?: boolean;
|
|
257
|
+
/** Cumulative time under a fresh foreign lock (default 0). */
|
|
258
|
+
lockedMs?: number;
|
|
259
|
+
/** The last fresh foreign lock's `tool`, bounded on construction (default ""). */
|
|
260
|
+
lockTool?: string;
|
|
198
261
|
}
|
|
199
262
|
/**
|
|
200
263
|
* A torn-install failure that stays VISIBLE in Sentry with bounded, hq-derived
|
|
@@ -221,10 +284,20 @@ export declare function installTreeTornCaptureContext(err: InstallTreeTornError)
|
|
|
221
284
|
* appended by {@link installTreeTornStderrLine}.
|
|
222
285
|
*/
|
|
223
286
|
export declare const INSTALL_TREE_TORN_REMEDY: string;
|
|
287
|
+
/**
|
|
288
|
+
* The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
|
|
289
|
+
* held the shared update lock past its own staleness ceiling. Telling the user to
|
|
290
|
+
* `npm i -g` here would collide with a live installer mid-rename — the exact race
|
|
291
|
+
* update-lock.ts exists to prevent — so this remedy tells them to let the running
|
|
292
|
+
* update finish and NEVER to reinstall over it. Interpolates NOTHING.
|
|
293
|
+
*/
|
|
294
|
+
export declare const INSTALL_TREE_LOCK_HELD_REMEDY: string;
|
|
224
295
|
/**
|
|
225
296
|
* The single actionable stderr line for a captured torn-install failure: the
|
|
226
|
-
* fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
227
|
-
* interpolated value, never argv.
|
|
297
|
+
* outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
298
|
+
* interpolated value, never argv. A 'lock-held' outcome selects the "let the
|
|
299
|
+
* update finish" remedy (never the reinstall advice); every other outcome keeps
|
|
300
|
+
* {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
|
|
228
301
|
*/
|
|
229
302
|
export declare function installTreeTornStderrLine(err: InstallTreeTornError): string;
|
|
230
303
|
//# sourceMappingURL=install-tree-torn.d.ts.map
|
|
@@ -34,11 +34,13 @@ 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;
|
|
42
44
|
const ESM_PACKAGE_RE = /^Cannot find package '([^']+)' imported from (.+)$/s;
|
|
43
45
|
const CJS_MODULE_RE = /^Cannot find module '([^']+)'/s;
|
|
44
46
|
const ESM_MODULE_IMPORTED_RE = /^Cannot find module '([^']+)' imported from (.+)$/s;
|
|
@@ -332,6 +334,15 @@ export function installTargetPresent(target, fs = nodeInstallTreeFs) {
|
|
|
332
334
|
return false;
|
|
333
335
|
}
|
|
334
336
|
}
|
|
337
|
+
/**
|
|
338
|
+
* Default ceiling on the CUMULATIVE time the wait may spend observing a FRESH
|
|
339
|
+
* foreign lock held by a live pid. Equal to the shared update-lock contract's own
|
|
340
|
+
* staleness window ({@link UPDATE_LOCK_STALE_MS} — "no healthy npm install -g of
|
|
341
|
+
* this package runs 10 minutes"), so the wait trusts a live cooperating writer for
|
|
342
|
+
* exactly as long as the contract legitimises holding the lock, and never longer.
|
|
343
|
+
* Overridable by the seam (env HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS).
|
|
344
|
+
*/
|
|
345
|
+
export const DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS = UPDATE_LOCK_STALE_MS;
|
|
335
346
|
/** The scope/leaf split of CLI_NAME, e.g. `@indigoai-us` / `hq-cli`. */
|
|
336
347
|
function cliNameParts() {
|
|
337
348
|
const slash = CLI_NAME.indexOf("/");
|
|
@@ -339,26 +350,32 @@ function cliNameParts() {
|
|
|
339
350
|
return { scope: null, leaf: CLI_NAME };
|
|
340
351
|
return { scope: CLI_NAME.slice(0, slash), leaf: CLI_NAME.slice(slash + 1) };
|
|
341
352
|
}
|
|
342
|
-
/**
|
|
343
|
-
|
|
353
|
+
/**
|
|
354
|
+
* The FRESH update lock held by a DIFFERENT live pid, or null. A non-null result
|
|
355
|
+
* blocks readiness AND extends the wait under the lock ceiling; the lock file is
|
|
356
|
+
* only ever READ. Freshness is decided by {@link isLockStale} exactly as before
|
|
357
|
+
* (parseable, `startedAt` younger than {@link UPDATE_LOCK_STALE_MS}, pid alive),
|
|
358
|
+
* and our own pid never counts.
|
|
359
|
+
*/
|
|
360
|
+
function freshForeignLock(lockPath, nowMs, isPidAlive, fs) {
|
|
344
361
|
let raw;
|
|
345
362
|
try {
|
|
346
363
|
raw = fs.readFileSync(lockPath, "utf-8");
|
|
347
364
|
}
|
|
348
365
|
catch {
|
|
349
|
-
return
|
|
366
|
+
return null; // no lock (ENOENT) or unreadable — not a fresh foreign holder
|
|
350
367
|
}
|
|
351
368
|
if (isLockStale(raw, nowMs, isPidAlive))
|
|
352
|
-
return
|
|
369
|
+
return null;
|
|
353
370
|
try {
|
|
354
371
|
const info = JSON.parse(raw);
|
|
355
372
|
if (info.pid === process.pid)
|
|
356
|
-
return
|
|
373
|
+
return null; // our own lock never blocks us
|
|
374
|
+
return info;
|
|
357
375
|
}
|
|
358
376
|
catch {
|
|
359
|
-
return
|
|
377
|
+
return null; // isLockStale already treats unparseable as stale, unreachable
|
|
360
378
|
}
|
|
361
|
-
return true;
|
|
362
379
|
}
|
|
363
380
|
/** Whether an npm retired/staging sibling (`.<leaf>-*`) sits beside packageRoot. */
|
|
364
381
|
function retiredSiblingPresent(packageRoot, fs) {
|
|
@@ -375,13 +392,33 @@ function packageRootHealthy(packageRoot, fs) {
|
|
|
375
392
|
}
|
|
376
393
|
/**
|
|
377
394
|
* 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
|
|
395
|
+
* wait exhausts its bounds. READY means: (i) no FRESH update lock held by another
|
|
396
|
+
* pid, (ii) the missing target is present, and — when `packageRoot` is known —
|
|
397
|
+
* (iii) `<packageRoot>/package.json` parses with `name === CLI_NAME` and (iv) no
|
|
398
|
+
* `.<leaf>-<rand>` retired sibling remains beside it. Every filesystem error makes
|
|
399
|
+
* its condition "not ready" rather than throwing.
|
|
400
|
+
*
|
|
401
|
+
* The wait is LOCK-AWARE and bounded on TWO independent axes, so a live
|
|
402
|
+
* cooperating writer (the desktop app, the box's update timer, another `hq`)
|
|
403
|
+
* cannot make a legitimately-in-progress reinstall look like a torn install:
|
|
404
|
+
* - UNLOCKED time — wall time observed with no fresh foreign lock — is bounded
|
|
405
|
+
* by `deadlineMs`. That is the budget for the TREE to settle once no writer is
|
|
406
|
+
* visible; with no lock ever seen, unlocked time equals total wait time and
|
|
407
|
+
* the behaviour is byte-for-byte the pre-lock-aware one (unsettled at exactly
|
|
408
|
+
* `deadlineMs`, and `deadlineMs === 0` evaluates readiness exactly once).
|
|
409
|
+
* - LOCKED time — wall time observed under a fresh foreign lock held by a live
|
|
410
|
+
* pid — is bounded SEPARATELY by `lockWaitMs` (default the lock contract's own
|
|
411
|
+
* 10-minute staleness window) and does NOT consume the settle budget. When
|
|
412
|
+
* that ceiling is reached with the lock still held, the result is unsettled
|
|
413
|
+
* with `lockHeldAtEnd` true so the seam can report a distinct `lock-held`
|
|
414
|
+
* outcome and remedy rather than telling the user to reinstall over a live
|
|
415
|
+
* writer.
|
|
416
|
+
* Each poll's REAL elapsed interval (now() deltas) is attributed to the locked or
|
|
417
|
+
* unlocked bucket by whether a fresh foreign lock was held when the interval
|
|
418
|
+
* began, so a starved event loop only makes the wait coarser, never unbounded. A
|
|
419
|
+
* stale lock (dead pid, older than the staleness window, unparseable) or our own
|
|
420
|
+
* pid never extends anything. The whole wait is bounded (≤ `deadlineMs +
|
|
421
|
+
* lockWaitMs`), so the caller can never hang.
|
|
385
422
|
*/
|
|
386
423
|
export async function waitForInstallTreeSettled(args) {
|
|
387
424
|
const now = args.now ?? Date.now;
|
|
@@ -391,16 +428,32 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
391
428
|
const pollMs = args.pollMs ?? 250;
|
|
392
429
|
const quietMs = args.quietMs ?? 1500;
|
|
393
430
|
const deadlineMs = args.deadlineMs;
|
|
431
|
+
const lockWaitMs = args.lockWaitMs ?? DEFAULT_INSTALL_SETTLE_LOCK_WAIT_MS;
|
|
394
432
|
const start = now();
|
|
395
433
|
let readySince = null;
|
|
396
434
|
let sawLock = false;
|
|
397
435
|
let sawRetired = false;
|
|
436
|
+
let lockedMs = 0;
|
|
437
|
+
let unlockedMs = 0;
|
|
438
|
+
let lockTool = "";
|
|
439
|
+
let prevT = start;
|
|
440
|
+
let prevForeignLockHeld = false;
|
|
441
|
+
let lockExtendedFired = false;
|
|
398
442
|
for (;;) {
|
|
399
443
|
const t = now();
|
|
400
|
-
|
|
401
|
-
|
|
444
|
+
// Attribute the interval that just elapsed [prevT, t) to the bucket for the
|
|
445
|
+
// lock state that held when it BEGAN (real now() deltas, never nominal pollMs).
|
|
446
|
+
if (prevForeignLockHeld)
|
|
447
|
+
lockedMs += t - prevT;
|
|
448
|
+
else
|
|
449
|
+
unlockedMs += t - prevT;
|
|
450
|
+
const lock = freshForeignLock(args.lockPath, t, isPidAlive, fs);
|
|
451
|
+
const foreignLockHeld = lock !== null;
|
|
452
|
+
if (foreignLockHeld) {
|
|
402
453
|
sawLock = true;
|
|
403
|
-
|
|
454
|
+
lockTool = boundedDiagnosticValue(lock.tool, LOCK_TOOL_BYTES);
|
|
455
|
+
}
|
|
456
|
+
let ready = !foreignLockHeld && installTargetPresent(args.target, fs);
|
|
404
457
|
if (ready && args.packageRoot) {
|
|
405
458
|
try {
|
|
406
459
|
if (!packageRootHealthy(args.packageRoot, fs))
|
|
@@ -430,11 +483,29 @@ export async function waitForInstallTreeSettled(args) {
|
|
|
430
483
|
}
|
|
431
484
|
const waitedMs = t - start;
|
|
432
485
|
if (ready && (deadlineMs === 0 || t - readySince >= quietMs)) {
|
|
433
|
-
|
|
486
|
+
// Settling requires no foreign lock, so it is never held at the end here.
|
|
487
|
+
return { settled: true, waitedMs, sawLock, sawRetired, lockHeldAtEnd: false, lockedMs, lockTool };
|
|
488
|
+
}
|
|
489
|
+
// Give up when the TREE has had a full unlocked budget with no writer visible,
|
|
490
|
+
// OR a writer is STILL holding the lock and has held it past its own staleness
|
|
491
|
+
// ceiling. The lock ceiling is gated on a lock being held RIGHT NOW so that a
|
|
492
|
+
// ceiling of 0 (HQ_INSTALL_SETTLE_LOCK_TIMEOUT_MS=0, "do not extend under a
|
|
493
|
+
// lock") disables only the extension and does not collapse the ordinary
|
|
494
|
+
// deadlineMs settle when no lock exists — `lockedMs` is 0 ≥ 0 on every poll.
|
|
495
|
+
// `deadlineMs` 0 makes the unlocked test fire on the first poll (evaluate-once),
|
|
496
|
+
// matching the pre-lock-aware contract; `lockHeldAtEnd` distinguishes the causes.
|
|
497
|
+
if (unlockedMs >= deadlineMs || (foreignLockHeld && lockedMs >= lockWaitMs)) {
|
|
498
|
+
return { settled: false, waitedMs, sawLock, sawRetired, lockHeldAtEnd: foreignLockHeld, lockedMs, lockTool };
|
|
434
499
|
}
|
|
435
|
-
|
|
436
|
-
|
|
500
|
+
// The first poll at which the wait has reached `deadlineMs` while a lock is
|
|
501
|
+
// still held is exactly where the pre-lock-aware wait gave up — announce the
|
|
502
|
+
// extension once, and only when we are genuinely going to keep waiting.
|
|
503
|
+
if (!lockExtendedFired && foreignLockHeld && waitedMs >= deadlineMs) {
|
|
504
|
+
lockExtendedFired = true;
|
|
505
|
+
args.onLockExtended?.();
|
|
437
506
|
}
|
|
507
|
+
prevT = t;
|
|
508
|
+
prevForeignLockHeld = foreignLockHeld;
|
|
438
509
|
await sleep(pollMs);
|
|
439
510
|
}
|
|
440
511
|
}
|
|
@@ -464,6 +535,9 @@ export class InstallTreeTornError extends Error {
|
|
|
464
535
|
waitedMs: init.waitedMs,
|
|
465
536
|
sawLock: init.sawLock,
|
|
466
537
|
sawRetired: init.sawRetired,
|
|
538
|
+
lockHeldAtEnd: init.lockHeldAtEnd ?? false,
|
|
539
|
+
lockedMs: init.lockedMs ?? 0,
|
|
540
|
+
lockTool: boundedDiagnosticValue(init.lockTool ?? "", LOCK_TOOL_BYTES),
|
|
467
541
|
node: process.version,
|
|
468
542
|
};
|
|
469
543
|
Object.setPrototypeOf(this, new.target.prototype);
|
|
@@ -486,15 +560,26 @@ export const INSTALL_TREE_TORN_REMEDY = "the hq-cli install was being updated by
|
|
|
486
560
|
"timer, the desktop app, or another hq command) while this command started. " +
|
|
487
561
|
"Re-run your command; if it keeps failing, reinstall with " +
|
|
488
562
|
"`npm i -g @indigoai-us/hq-cli` (or `pnpm add -g @indigoai-us/hq-cli`).";
|
|
563
|
+
/**
|
|
564
|
+
* The fixed, input-free remedy for the 'lock-held' outcome: a cooperating writer
|
|
565
|
+
* held the shared update lock past its own staleness ceiling. Telling the user to
|
|
566
|
+
* `npm i -g` here would collide with a live installer mid-rename — the exact race
|
|
567
|
+
* update-lock.ts exists to prevent — so this remedy tells them to let the running
|
|
568
|
+
* update finish and NEVER to reinstall over it. Interpolates NOTHING.
|
|
569
|
+
*/
|
|
570
|
+
export const INSTALL_TREE_LOCK_HELD_REMEDY = "another hq updater (the desktop app, the box's hq-cli-update timer, or another " +
|
|
571
|
+
"hq command) was still installing hq-cli when this command stopped waiting. Let " +
|
|
572
|
+
"that update finish, then re-run your command; do not reinstall while it is running.";
|
|
489
573
|
/**
|
|
490
574
|
* 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.
|
|
575
|
+
* outcome's fixed remedy plus the bounded, hq-derived missing specifier — the only
|
|
576
|
+
* interpolated value, never argv. A 'lock-held' outcome selects the "let the
|
|
577
|
+
* update finish" remedy (never the reinstall advice); every other outcome keeps
|
|
578
|
+
* {@link INSTALL_TREE_TORN_REMEDY} byte-for-byte.
|
|
493
579
|
*/
|
|
494
580
|
export function installTreeTornStderrLine(err) {
|
|
581
|
+
const remedy = err.diagnostics.outcome === "lock-held" ? INSTALL_TREE_LOCK_HELD_REMEDY : INSTALL_TREE_TORN_REMEDY;
|
|
495
582
|
const specifier = err.diagnostics.specifier;
|
|
496
|
-
return specifier
|
|
497
|
-
? `${INSTALL_TREE_TORN_REMEDY} (missing: ${specifier})`
|
|
498
|
-
: INSTALL_TREE_TORN_REMEDY;
|
|
583
|
+
return specifier ? `${remedy} (missing: ${specifier})` : remedy;
|
|
499
584
|
}
|
|
500
585
|
//# 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"
|