@testmuai/rook 0.1.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.
package/bin/rook.cjs ADDED
@@ -0,0 +1,288 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * Entry point. Resolves a bundled Node runtime (@testmuai/rook-node-*,
5
+ * Task 3) when one is installed alongside this package — public npm/brew
6
+ * installs carry it via optionalDependencies, internal installs don't
7
+ * declare it at all. Falls back to the invoking process's own Node
8
+ * otherwise, which is the only path internal @lambdatestincprivate/rook
9
+ * installs ever take.
10
+ */
11
+
12
+ const { spawn } = require("node:child_process");
13
+ const { join } = require("node:path");
14
+ const { existsSync } = require("node:fs");
15
+ const { pathToFileURL } = require("node:url");
16
+
17
+ // Every platform LISTENS for all three signals — that part does not vary.
18
+ // On Windows this matters even for signals we never forward (see
19
+ // shouldForward below): a signal with zero listeners falls through to
20
+ // Windows' own default console-control handler, which calls ExitProcess()
21
+ // on the trampoline near-instantly. libuv assigns every non-detached
22
+ // spawned child (this one included — confirmed directly against libuv's
23
+ // uv_spawn source) to a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
24
+ // so that ExitProcess() closes the job and force-kills the child too — the
25
+ // same abrupt-kill-cuts-off-graceful-shutdown problem this file exists to
26
+ // avoid, just relocated from `child.kill()` to the OS and, since no JS runs
27
+ // first, likely to land faster. A registered listener — even one whose
28
+ // body does nothing — suppresses that default handler and keeps the
29
+ // trampoline (and therefore the job) alive long enough for the child,
30
+ // which received the same native event independently, to actually finish
31
+ // its own shutdown and hit this file's normal 'exit' handling.
32
+ const OBSERVED_SIGNALS = ["SIGINT", "SIGTERM", "SIGHUP"];
33
+
34
+ // What differs on win32 is whether the listener's body actually calls
35
+ // child.kill() once invoked:
36
+ // - SIGINT: Windows' console already broadcasts Ctrl-C (CTRL_C_EVENT) to
37
+ // every process sharing the console, parent and child alike, so the
38
+ // child already gets its own native chance to shut down. Windows'
39
+ // child.kill() is always an unconditional TerminateProcess (confirmed
40
+ // against Node's child_process docs) — never something the child could
41
+ // catch — so actually calling it here would only force a redundant hard
42
+ // kill over whatever graceful handling the child was already doing on
43
+ // its own. Listen (see OBSERVED_SIGNALS above), but don't forward.
44
+ // - SIGHUP: not in libuv's supported set on win32 at all — child.kill()
45
+ // would throw synchronously (UV_EINVAL/UV_ENOSYS) instead of failing
46
+ // softly. Node only emits SIGHUP on Windows when the console window
47
+ // closes, and unconditionally tears the whole process tree down ~10s
48
+ // later regardless of any handler, so there's nothing to gain by
49
+ // forwarding it even if it didn't throw. Listen, don't forward.
50
+ // - SIGTERM: Node's own docs are explicit that "'SIGTERM' is not
51
+ // supported on Windows, it can be listened on" — there is no native OS
52
+ // mechanism that delivers a real, externally-triggered SIGTERM to a
53
+ // Windows process's JS layer at all (docker stop / taskkill / a
54
+ // supervisor targeting just this PID all fall back to TerminateProcess,
55
+ // which bypasses every JS handler, this one included). The one case
56
+ // where this listener can still genuinely fire is a cooperating
57
+ // Node.js parent that spawned this trampoline as its own tracked child
58
+ // and later calls that child handle's own `.kill('SIGTERM')` — for
59
+ // that narrow case, forwarding is still the only way the bundled child
60
+ // would ever learn of it, and a hard kill still beats leaving it
61
+ // orphaned. Forward it; it's harmless when it doesn't fire and correct
62
+ // when it does.
63
+ const WINDOWS_PASSIVE_SIGNALS = new Set(["SIGINT", "SIGHUP"]);
64
+
65
+ function shouldForward(signal, platform) {
66
+ return !(platform === "win32" && WINDOWS_PASSIVE_SIGNALS.has(signal));
67
+ }
68
+
69
+ function platformTag() {
70
+ const plat = process.platform;
71
+ const arch = process.arch;
72
+ if (plat === "darwin" && (arch === "arm64" || arch === "x64"))
73
+ return arch === "arm64" ? "darwin-arm64" : "darwin-x64";
74
+ if (plat === "linux" && (arch === "arm64" || arch === "x64"))
75
+ return arch === "arm64" ? "linux-arm64" : "linux-x64";
76
+ if (plat === "win32" && arch === "x64") return "win-x64";
77
+ return null;
78
+ }
79
+
80
+ function findBundledNode() {
81
+ if (process.env.ROOK_SYSTEM_NODE === "1") return null;
82
+ const tag = platformTag();
83
+ if (!tag) return null;
84
+ const binName = tag === "win-x64" ? "node.exe" : "node";
85
+ try {
86
+ const nodePath = require.resolve(
87
+ `@testmuai/rook-node-${tag}/bin/${binName}`,
88
+ );
89
+ // require.resolve() caches a successful resolution and keeps returning
90
+ // it even after the file is deleted (verified directly against Node's
91
+ // module resolver) — existsSync() here is the actual guard against a
92
+ // stale path, not a redundant check.
93
+ return existsSync(nodePath) ? nodePath : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ function main() {
100
+ const entry = join(__dirname, "..", "dist", "cli.js");
101
+ const bundledNode = findBundledNode();
102
+
103
+ if (bundledNode) {
104
+ runViaBundledNode(bundledNode, entry);
105
+ return;
106
+ }
107
+
108
+ runViaSystemNode(entry);
109
+ }
110
+
111
+ // spawnSync (the original implementation) blocks the whole event loop for
112
+ // as long as the child runs. Node's signal handling is event-loop-driven
113
+ // (libuv delivers the OS signal via a self-pipe that a process.on(signal)
114
+ // listener only sees once the loop is free to process it) — so no JS
115
+ // signal handler could ever run while blocked inside spawnSync, and a
116
+ // SIGTERM sent to just this process's PID (docker stop, systemd
117
+ // KillMode=process, timeout(1), a supervisor targeting the advertised PID
118
+ // rather than the whole process group) killed the trampoline via default
119
+ // disposition while the bundled-node child kept running, orphaned, with
120
+ // inherited stdio. Reproduced directly: sending SIGTERM to the trampoline's
121
+ // own PID left the child alive and running afterward. Async spawn() keeps
122
+ // the event loop alive so a listener can actually fire and forward the
123
+ // same signal down to the child before this process itself goes away.
124
+ function runViaBundledNode(bundledNode, entry) {
125
+ const child = spawn(bundledNode, [entry, ...process.argv.slice(2)], {
126
+ stdio: "inherit",
127
+ });
128
+ wireSignalForwarding(child, {
129
+ bundledNode,
130
+ entry,
131
+ platform: process.platform,
132
+ proc: process,
133
+ onSpawnFailure: runViaSystemNode,
134
+ });
135
+ }
136
+
137
+ // Split out from runViaBundledNode so tests can exercise this control flow
138
+ // against a fake child/process double — main() and runViaBundledNode()
139
+ // call process.exit()/process.kill() on real failure paths, which would
140
+ // take down the test runner itself if driven in-process (see the real
141
+ // subprocess tests further down, and rook.test.mjs's own note on why they
142
+ // spawn a genuine OS process instead of calling main() directly). `proc`
143
+ // and `onSpawnFailure` default to the real globals in production and are
144
+ // swapped for doubles in tests.
145
+ function wireSignalForwarding(
146
+ child,
147
+ { bundledNode, entry, platform, proc, onSpawnFailure },
148
+ ) {
149
+ // Only set once child.kill() has genuinely been attempted for a signal
150
+ // that shouldForward() approved — the platform-passive no-op signals
151
+ // (SIGINT/SIGHUP on win32) never touch this. Together with `spawned`
152
+ // below, it lets the 'error' handler tell a real kill failure (child
153
+ // exists, may still be alive) apart from a genuine spawn failure (child
154
+ // never existed) — see that handler for why conflating them is unsafe.
155
+ let signaled = false;
156
+ // A kill that failed once and left the child unresponsive would
157
+ // otherwise swallow every later signal forever (report-and-wait, with
158
+ // no guaranteed 'exit' ever coming). Bound that: once a kill attempt has
159
+ // failed, the next forwarded signal gives up on the graceful path
160
+ // entirely and force-exits the trampoline itself, mirroring the
161
+ // press-Ctrl-C-twice-to-force-quit convention most CLIs already use.
162
+ let killFailed = false;
163
+ // 'spawn' (Node 15+) fires only once the OS process genuinely exists —
164
+ // unlike 'error', which fires for a spawn failure too. Gating the
165
+ // 'error' handler's "this was probably a failed kill" branch on this
166
+ // closes a real race: an async spawn failure (the exec pipe reporting a
167
+ // truncated/corrupted bundled binary, for instance) can be detected in
168
+ // the same event-loop turn a signal happens to arrive in, which would
169
+ // otherwise set `signaled` first and make a genuine spawn failure look
170
+ // like a failed kill — and since no process ever existed, the 'exit'
171
+ // that branch waits for would then never come, hanging the trampoline
172
+ // forever instead of falling back.
173
+ let spawned = false;
174
+ child.on("spawn", () => {
175
+ spawned = true;
176
+ });
177
+
178
+ const forward = (signal) => {
179
+ if (!shouldForward(signal, platform)) return;
180
+ if (killFailed) {
181
+ proc.exit(1);
182
+ }
183
+ signaled = true;
184
+ try {
185
+ child.kill(signal);
186
+ } catch (err) {
187
+ // A signal libuv doesn't support on this platform (e.g. SIGHUP on
188
+ // win32, on Node versions where that still throws instead of
189
+ // failing softly) throws synchronously. The child is still alive;
190
+ // report rather than crash the trampoline uncaught.
191
+ killFailed = true;
192
+ proc.stderr.write(
193
+ `Failed to deliver ${signal} to the bundled Node runtime (${bundledNode}): ${err.message}\n`,
194
+ );
195
+ }
196
+ };
197
+ for (const signal of OBSERVED_SIGNALS) proc.on(signal, forward);
198
+
199
+ let settled = false;
200
+ const stopForwarding = () => {
201
+ for (const signal of OBSERVED_SIGNALS) proc.removeListener(signal, forward);
202
+ };
203
+
204
+ // Node's docs warn 'exit' may or may not also fire after 'error' —
205
+ // settled guards against handling either failure/completion path twice.
206
+ child.on("error", (err) => {
207
+ if (settled) return;
208
+ if (signaled && spawned) {
209
+ // The child definitely exists (confirmed via 'spawn'), so this
210
+ // 'error' can only be a failed child.kill(), not a spawn failure —
211
+ // the child may still be alive, and its real 'exit' event is still
212
+ // to come. Report and return without marking settled, so that
213
+ // eventual 'exit' still drives normal cleanup (re-raising the
214
+ // signal / exiting with the child's code) instead of a second CLI
215
+ // instance getting launched alongside a first one that never
216
+ // actually died.
217
+ killFailed = true;
218
+ proc.stderr.write(
219
+ `Failed to deliver a signal to the bundled Node runtime (${bundledNode}): ${err.message}\n`,
220
+ );
221
+ return;
222
+ }
223
+ settled = true;
224
+ stopForwarding();
225
+ proc.stderr.write(
226
+ `Failed to run the bundled Node runtime (${bundledNode}): ${err.message}\n`,
227
+ );
228
+ // fall through to the system-Node path instead of exiting — an
229
+ // unusable bundled runtime (lost exec bit, truncated install, dangling
230
+ // symlink) should not silently kill the CLI with no message
231
+ onSpawnFailure(entry);
232
+ });
233
+
234
+ child.on("exit", (code, signal) => {
235
+ if (settled) return;
236
+ settled = true;
237
+ stopForwarding();
238
+ if (signal) {
239
+ // The child died from a signal (forwarded from above, or one it hit
240
+ // on its own — an internal crash, OOM-kill), not a normal exit —
241
+ // code is null in this case. Re-raise the same signal against
242
+ // ourselves rather than collapsing to exit code 1: default
243
+ // disposition then terminates this process the same way, so a
244
+ // caller inspecting our exit status (or a shell's $?/WIFSIGNALED)
245
+ // sees the real cause instead of an indistinguishable generic
246
+ // failure. process.kill() isn't a guaranteed terminator by itself —
247
+ // nothing stops a future change from registering a handler for this
248
+ // same signal elsewhere in the process — so the explicit return
249
+ // makes this function's own control flow correct regardless,
250
+ // instead of relying on the process dying before the next line runs.
251
+ proc.kill(proc.pid, signal);
252
+ return;
253
+ }
254
+ proc.exit(code === null ? 1 : code);
255
+ });
256
+ }
257
+
258
+ function runViaSystemNode(entry) {
259
+ const major = Number(process.versions.node.split(".")[0]);
260
+ if (major < 20) {
261
+ process.stderr.write(
262
+ `rook requires Node 20 or newer (found ${process.versions.node}).\n`,
263
+ );
264
+ process.exit(1);
265
+ }
266
+
267
+ import(pathToFileURL(entry).href).catch((err) => {
268
+ process.stderr.write(
269
+ `Failed to start rook: ${err && err.stack ? err.stack : err}\n`,
270
+ );
271
+ process.exit(1);
272
+ });
273
+ }
274
+
275
+ // Guards main()'s process.exit()/dynamic-import side effects so this file
276
+ // can be import()'d from rook.test.mjs (or anything else) without actually
277
+ // launching the CLI — only a direct invocation (the npm bin symlink, or
278
+ // `node bin/rook.cjs`) has require.main === module.
279
+ if (require.main === module) {
280
+ main();
281
+ }
282
+
283
+ module.exports = {
284
+ platformTag,
285
+ findBundledNode,
286
+ shouldForward,
287
+ wireSignalForwarding,
288
+ };