@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 +288 -0
- package/bin/rook.test.mjs +456 -0
- package/dist/cli.js +18712 -0
- package/dist/cli.js.map +1 -0
- package/package.json +56 -0
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
|
+
};
|