ccdeck 1.43.0 → 1.45.0

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.
@@ -0,0 +1,302 @@
1
+ // What bin/agent-dag.js does when the worker dies: bring it back, fetch a
2
+ // newer one, or let the whole thing stop — and, for a worker that was killed
3
+ // rather than exited, how to hand that on to whoever started the deck. Plus the
4
+ // question that now comes BEFORE any of that, since the fetch happens while the
5
+ // worker is still serving: whether this upgrade is worth attempting at all.
6
+ //
7
+ // It lives here rather than inline in the supervisor because the supervisor
8
+ // launches a real child process the moment it is imported, so the rule could
9
+ // not be checked any other way — and it is a rule with a race in it.
10
+ //
11
+ // The race: the worker exits 75 because the user clicked Restart, and Ctrl+C
12
+ // lands in the same instant. The supervisor's signal handler runs first and
13
+ // sets `stopping`, then the exit event arrives carrying 75 — and a supervisor
14
+ // that reads the code without asking whether it is still supposed to be
15
+ // running spawns a fresh deck AFTER the user stopped it. That deck prints
16
+ // `restarted → vX` over the shutdown and keeps serving until the handler's
17
+ // 2.5s retry timer happens to kill it; the 76 variant starts a whole npx
18
+ // registry fetch first. So `stopping` outranks the code, always.
19
+ //
20
+ // Ctrl+C reaches this process on every platform we support — POSIX delivers it
21
+ // to the foreground process group, and on Windows the console raises
22
+ // CTRL_C_EVENT for every process attached to it, which Node surfaces as
23
+ // 'SIGINT'. What differs is only how the child dies, which is not this
24
+ // function's business.
25
+
26
+ import { constants } from "node:os";
27
+
28
+ // Chosen because they mean nothing else here: the worker exits 0 normally and
29
+ // non-zero on failure, both of which must pass straight through.
30
+ export const RESTART_CODE = 75; // come back running the files on disk
31
+ export const UPGRADE_CODE = 76; // come back through npx, which fetches newer files
32
+
33
+ /**
34
+ * What a dead worker means.
35
+ *
36
+ * { relaunch: "disk" } — spawn it again from the files on disk
37
+ * { relaunch: "npx" } — spawn it again through npx, which fetches newer ones
38
+ * { relaunch: null, code } — stop, exiting with `code`
39
+ *
40
+ * `stopping` is a parameter rather than the supervisor's module-level flag for
41
+ * the same reason `spawnSpec` takes a platform: it is the input that decides
42
+ * the answer, and both answers have to be testable.
43
+ *
44
+ * A stop exits 0 for 75 and 76. Those two are a private protocol between the
45
+ * worker and its supervisor — nobody outside knows they mean "come back", and
46
+ * 75 is EX_TEMPFAIL to anything that reads sysexits, so handing either to the
47
+ * shell would report a failure the user did not have. Every other code is the
48
+ * worker's own verdict and passes through untouched.
49
+ */
50
+ export function workerExitAction(code, stopping) {
51
+ const ours = code === RESTART_CODE || code === UPGRADE_CODE;
52
+ if (ours && !stopping) return { relaunch: code === UPGRADE_CODE ? "npx" : "disk" };
53
+ return { relaunch: null, code: ours ? 0 : code ?? 0 };
54
+ }
55
+
56
+ /**
57
+ * How to report a worker that did not exit at all but was killed by a signal.
58
+ *
59
+ * { reraise: "SIGHUP", code: 129 } — die of the same signal; `code` is only
60
+ * the fallback if that somehow returns
61
+ * { reraise: null, code: 129 } — no signal to die of, exit with the number
62
+ *
63
+ * A supervisor that just exits 0 here tells the shell the deck stopped cleanly
64
+ * after somebody killed it. The status a caller expects is the shell's own
65
+ * convention, 128 + the signal number: 129 for SIGHUP, 143 for SIGTERM. On
66
+ * POSIX the honest way to produce it is to die of the signal rather than to
67
+ * exit with the arithmetic — only that also sets the "killed by a signal" bit
68
+ * every wait(2)-based caller reads, and `$?` comes out the same either way.
69
+ *
70
+ * Windows has no signals: a process there cannot die of one, and `process.kill`
71
+ * against ourselves would be a TerminateProcess with an unrelated status. So
72
+ * the number is all we can offer, and it is still better than 0 — the deck was
73
+ * killed, and whatever started it should be able to see that.
74
+ *
75
+ * The signal numbers come from `os.constants.signals`, which is also the table
76
+ * `process.kill` accepts, so a name missing from it is a name we could not
77
+ * re-raise anyway; it becomes a plain failure exit.
78
+ */
79
+ export function signalExitAction(signal, platform = process.platform, numbers = constants.signals) {
80
+ const n = numbers?.[signal];
81
+ if (typeof n !== "number") return { reraise: null, code: 1 };
82
+ return { reraise: platform === "win32" ? null : signal, code: 128 + n };
83
+ }
84
+
85
+ /**
86
+ * End this process the way the worker ended: killed by `signal`.
87
+ *
88
+ * The re-raise is the obvious half and on its own it does not work, which is
89
+ * the reason this is a function rather than one line at the call site. The
90
+ * supervisor traps SIGINT, SIGTERM and SIGHUP — and those are exactly the
91
+ * signals a worker is most likely to die of, since deck.js does not handle
92
+ * SIGHUP at all and registers the other two only after a boot that takes
93
+ * seconds. For all three the re-raise lands back in our own handler, which
94
+ * finds no child left and exits 0: `kill -HUP` on the deck was reported to the
95
+ * shell as a clean, successful stop. Dropping the handler first restores the
96
+ * default action, which is to die of the signal.
97
+ *
98
+ * `proc` is a parameter so the whole sequence can be driven in a test child.
99
+ */
100
+ /**
101
+ * What to say when the upgrade that just succeeded took this install with it,
102
+ * or null when that is not what happened.
103
+ *
104
+ * `npm i -g ccdeck` performed before #340 nests the deck inside a launcher
105
+ * package. Upgrading such an install now writes the deck itself over that
106
+ * launcher, and npm's reify removes everything that was underneath — including
107
+ * the directory this supervisor and its worker are running from. The process
108
+ * survives, because everything it needs is already loaded. The next spawn does
109
+ * not: bin/deck.js is a path, and npm has just deleted it.
110
+ *
111
+ * That reached the user as `could not start …/bin/deck.js: ENOENT` and an exit
112
+ * 1 — a dead deck and an errno, one click after an upgrade that reported
113
+ * success. Nothing was broken. The new version is on the machine, and the
114
+ * command they typed already points at it, because npm rewrote the bin shim in
115
+ * the same install. The only thing missing was a sentence saying so.
116
+ *
117
+ * Deliberately not a hand-off. This process could spawn the successor's worker
118
+ * instead, and that would be a new worker under an old supervisor — a pair
119
+ * nothing has ever tested together, decided at the worst possible moment. One
120
+ * command the user runs themselves is the smaller promise and the one that
121
+ * cannot go wrong.
122
+ *
123
+ * Pure, and given its two filesystem answers rather than asking for them,
124
+ * because the state it describes only ever exists in a process whose own files
125
+ * were deleted after it started — which no test can produce by spawning one.
126
+ */
127
+ export function replacedNote({ workerExists, moved, product = "ccdeck", command = "ccdeck" }) {
128
+ if (workerExists || !moved) return null;
129
+ return [
130
+ `${product}: the upgrade replaced this install, so it cannot restart in place.`,
131
+ ` the new version is in ${moved}`,
132
+ ` run \`${command}\` again to start it`,
133
+ ].join("\n");
134
+ }
135
+
136
+ export function dieOfSignal(signal, proc = process) {
137
+ const { reraise, code } = signalExitAction(signal, proc.platform);
138
+ if (reraise) {
139
+ proc.removeAllListeners(reraise);
140
+ try { proc.kill(proc.pid, reraise); } catch { /* fall through to the number */ }
141
+ }
142
+ // Reached on Windows, and on POSIX only when the signal is ignored or
143
+ // blocked — inherited dispositions survive spawn — so the kill above returns
144
+ // instead of ending us.
145
+ proc.exit(code);
146
+ }
147
+
148
+ // ── how often an upgrade that failed may be attempted again ──────────────────
149
+ //
150
+ // Reported from a terminal that had been left alone: the same version, the same
151
+ // npm error, the same teardown, four times over. Nothing anywhere remembered
152
+ // that this exact target had already failed, so every path back into the deck
153
+ // offered the identical attempt — and each one cost a real interruption.
154
+ //
155
+ // Five minutes is chosen against what actually fails. A registry blip or a
156
+ // version that has not finished propagating is gone well inside it; being
157
+ // offline, behind a proxy that blocks the registry, or on the broken npx shim
158
+ // of #184 is not, and no amount of retrying will change that. So the second
159
+ // attempt waits, and there is no third: the copyable command is already on
160
+ // screen beside the failure and is the honest escape hatch. A newer target
161
+ // resets both — that is a different question, and it deserves its own answer.
162
+ export const UPGRADE_COOLDOWN_MS = 300_000;
163
+ export const UPGRADE_MAX_ATTEMPTS = 2;
164
+
165
+ /** Whether a note on disk is a failure at all, and one about `target`.
166
+ *
167
+ * Both sides of the comparison can be null — the version check is cached for
168
+ * an hour and can be off entirely — and two unknowns count as the same target
169
+ * rather than as two different ones. Guessing "different" would hand back the
170
+ * unbounded retry this exists to end. A note from a deck older than this rule
171
+ * carries no target at all, which is genuinely unknown against a target we do
172
+ * know, so it is not held against a fresh version. */
173
+ function noteAbout(note, target) {
174
+ if (!note || typeof note.error !== "string" || !note.error) return false;
175
+ return (note.target ?? null) === (target ?? null);
176
+ }
177
+
178
+ /** How many attempts the note already stands for. A note written before this
179
+ * rule existed has no count and is exactly one failure. */
180
+ function attemptsOf(note) {
181
+ return Number.isInteger(note?.attempts) && note.attempts > 0 ? note.attempts : 1;
182
+ }
183
+
184
+ /** When the attempt behind the note actually failed, which is not when the note
185
+ * was last written: a refusal re-stamps `at` so the browser can tell it from
186
+ * the failure before it, and the cooldown must not be pushed out by being
187
+ * asked. */
188
+ function failedAtOf(note) {
189
+ if (typeof note?.failedAt === "number") return note.failedAt;
190
+ return typeof note?.at === "number" ? note.at : null;
191
+ }
192
+
193
+ /**
194
+ * Whether an upgrade to `target` may be attempted, given what the last one left
195
+ * behind.
196
+ *
197
+ * { allow: true, attempt: n } — go ahead; this is the nth
198
+ * { allow: false, reason: "cooling", waitMs, attempt }
199
+ * { allow: false, reason: "exhausted", attempt }
200
+ *
201
+ * Pure, and the note is a parameter rather than a read, because this is the
202
+ * rule that has to terminate an unattended loop and a rule worth a bug is worth
203
+ * a test.
204
+ */
205
+ export function upgradeAttempt({
206
+ note, target = null, now = Date.now(),
207
+ cooldownMs = UPGRADE_COOLDOWN_MS, maxAttempts = UPGRADE_MAX_ATTEMPTS,
208
+ } = {}) {
209
+ if (!noteAbout(note, target)) return { allow: true, attempt: 1 };
210
+ const attempt = attemptsOf(note);
211
+ if (attempt >= maxAttempts) return { allow: false, reason: "exhausted", attempt, waitMs: 0 };
212
+ const failedAt = failedAtOf(note);
213
+ // A clock that moved backwards — a laptop waking, an NTP correction — must
214
+ // not leave the deck cooling forever, so an unmeasurable wait counts as
215
+ // elapsed. The attempt cap is what makes that safe: it holds however the
216
+ // clock behaves, and it is the half that ends the loop.
217
+ const waited = typeof failedAt === "number" && now >= failedAt ? now - failedAt : cooldownMs;
218
+ if (waited < cooldownMs) {
219
+ return { allow: false, reason: "cooling", attempt, waitMs: cooldownMs - waited };
220
+ }
221
+ return { allow: true, attempt: attempt + 1 };
222
+ }
223
+
224
+ /** The refusal as the deck should state it — this is the whole of what the
225
+ * banner and the terminal say, so it has to name the version, say what already
226
+ * happened to it, and leave the user somewhere to go. The copyable command is
227
+ * already on screen next to it. */
228
+ export function upgradeRefusalText({ reason, waitMs = 0, attempt = 0 } = {}, target = null) {
229
+ const what = target ? `v${target}` : "this update";
230
+ if (reason === "exhausted") {
231
+ return `${what} failed to fetch ${attempt} times — not trying again from here; run the command yourself`;
232
+ }
233
+ const left = waitMs >= 60_000 ? `${Math.ceil(waitMs / 60_000)}m` : `${Math.max(1, Math.ceil(waitMs / 1000))}s`;
234
+ return `${what} failed to fetch a moment ago — waiting ${left} before trying again`;
235
+ }
236
+
237
+ /**
238
+ * Die when the process that started this one does — on all three operating
239
+ * systems (#702).
240
+ *
241
+ * The deck is two processes: bin/agent-dag.js supervises, bin/deck.js serves.
242
+ * The supervisor kills the worker on every path it knows about — a restart, an
243
+ * upgrade, a Ctrl+C, its own exit — and cannot kill it on the one path it never
244
+ * gets to run: being killed itself. SIGKILL cannot be handled, a crash runs no
245
+ * handler, and a `taskkill` without `/T` reaches only the process it names. On
246
+ * POSIX the worker is then re-parented to init and keeps its port, its temp
247
+ * directory and its 40-60 MB of RSS forever. 310 of those were alive on one
248
+ * development machine, the oldest a day and four hours old, every one a worker
249
+ * whose supervisor a test's teardown had SIGKILLed.
250
+ *
251
+ * WHY THE IPC CHANNEL, AND NOT ANY OF THE OBVIOUS ALTERNATIVES. The requirement
252
+ * is one signal meaning "whoever started me is gone", and the usual answers are
253
+ * each missing a platform:
254
+ *
255
+ * • `process.ppid === 1` — POSIX re-parents an orphan to init, so polling ppid
256
+ * works there and answers nothing on Windows, which does not re-parent at
257
+ * all: the ppid goes on naming a pid that no longer exists.
258
+ * • process groups and `kill(-pgid)` — a POSIX concept. Windows job objects
259
+ * are the nearest equivalent and Node exposes none of it.
260
+ * • a SIGTERM the parent sends on its way out — Windows delivers no signals to
261
+ * a Node process, so `process.on("SIGTERM")` there never fires; and a parent
262
+ * that was killed sends nothing anywhere.
263
+ *
264
+ * The channel is the one mechanism that behaves the same everywhere, because
265
+ * Node normalises it: a Unix socketpair on POSIX, a named pipe on Windows, both
266
+ * closed by the kernel when the process holding the other end stops existing,
267
+ * however it stopped. Node turns that close into a single `disconnect` event on
268
+ * the child's own `process`, and it arrives for a SIGKILL, a crash, a
269
+ * `taskkill /F`, a closed console window and an ordinary exit alike. That is the
270
+ * same reasoning the header above gives for Ctrl+C: what differs between
271
+ * platforms is how a process dies, not that this notices.
272
+ *
273
+ * The channel is not created for this. It already carries `{type:"listening"}`
274
+ * and the upgrade handshake; this only says what its closure means.
275
+ *
276
+ * ARMED ONLY WHERE THERE IS A PARENT TO DIE WITH — `process.send` is a function
277
+ * exactly when a channel was given. `node bin/deck.js` run by hand has none, and
278
+ * that is the same question `SUPERVISED` in bin/deck.js already asks before it
279
+ * offers /api/restart.
280
+ *
281
+ * WHAT IT DELIBERATELY DOES NOT COVER: a parent that calls `child.disconnect()`
282
+ * and stays alive would look identical from here. Nothing in this repo does
283
+ * that, and the alternative — an "are you still there" ping — is a second
284
+ * protocol to keep correct in exchange for a case that does not exist.
285
+ *
286
+ * Returns whether a leash was armed, so a caller can state it rather than infer
287
+ * it. `proc` is a parameter for the reason `workerExitAction` takes `stopping`:
288
+ * the behaviour has to be checkable without a second process.
289
+ */
290
+ export function dieWithParent(stop, proc = process) {
291
+ if (!proc || typeof proc.once !== "function" || typeof proc.send !== "function") return false;
292
+ let done = false;
293
+ proc.once("disconnect", () => {
294
+ if (done) return;
295
+ done = true;
296
+ // A stop that throws must still end the process: the whole point is that
297
+ // nothing is left behind, and there is no parent left to notice a child
298
+ // that failed to leave.
299
+ try { stop(); } catch { proc.exit?.(0); }
300
+ });
301
+ return true;
302
+ }