@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
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
mkdirSync,
|
|
4
|
+
rmSync,
|
|
5
|
+
writeFileSync,
|
|
6
|
+
chmodSync,
|
|
7
|
+
existsSync,
|
|
8
|
+
readFileSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { join, dirname } from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
import { spawnSync, spawn } from "node:child_process";
|
|
13
|
+
import { EventEmitter } from "node:events";
|
|
14
|
+
|
|
15
|
+
const BIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
function fakePkgRoot(tag) {
|
|
18
|
+
return join(BIN_DIR, "node_modules", "@testmuai", `rook-node-${tag}`);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function writeFakePackage(tag, binName) {
|
|
22
|
+
const root = fakePkgRoot(tag);
|
|
23
|
+
mkdirSync(join(root, "bin"), { recursive: true });
|
|
24
|
+
writeFileSync(
|
|
25
|
+
join(root, "package.json"),
|
|
26
|
+
JSON.stringify({ name: `@testmuai/rook-node-${tag}` }),
|
|
27
|
+
);
|
|
28
|
+
writeFileSync(join(root, "bin", binName), "#!/bin/sh\n");
|
|
29
|
+
return join(root, "bin", binName);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
describe("rook.cjs bundled runtime resolution", () => {
|
|
33
|
+
const originalPlatform = process.platform;
|
|
34
|
+
const originalArch = process.arch;
|
|
35
|
+
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
delete process.env.ROOK_SYSTEM_NODE;
|
|
38
|
+
// findBundledNode()'s require.resolve() caches a SUCCESSFUL resolution
|
|
39
|
+
// even after the resolved file is later deleted (verified directly:
|
|
40
|
+
// Node's Module._pathCache does not invalidate on filesystem changes).
|
|
41
|
+
// vi.resetModules() forces rook.cjs to be genuinely re-evaluated on the
|
|
42
|
+
// next import() (verified: without it, a plain `?t=` query on the
|
|
43
|
+
// import specifier does NOT force re-evaluation — the module runs once
|
|
44
|
+
// for the whole file) — but a stale-resolved SPECIFIER can still leak
|
|
45
|
+
// across tests within the same process even so, which is why "no
|
|
46
|
+
// platform package resolves" below deliberately probes a tag no other
|
|
47
|
+
// test in this file ever creates a real package for, rather than
|
|
48
|
+
// relying on re-evaluation alone to avoid the collision.
|
|
49
|
+
vi.resetModules();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
54
|
+
Object.defineProperty(process, "arch", { value: originalArch });
|
|
55
|
+
rmSync(join(BIN_DIR, "node_modules"), { recursive: true, force: true });
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("resolves the bundled node path when the platform package is installed (darwin-arm64)", async () => {
|
|
59
|
+
const expected = writeFakePackage("darwin-arm64", "node");
|
|
60
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
61
|
+
Object.defineProperty(process, "arch", { value: "arm64" });
|
|
62
|
+
|
|
63
|
+
const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
|
|
64
|
+
expect(findBundledNode()).toBe(expected);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("resolves the bundled node path on darwin-x64", async () => {
|
|
68
|
+
const expected = writeFakePackage("darwin-x64", "node");
|
|
69
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
70
|
+
Object.defineProperty(process, "arch", { value: "x64" });
|
|
71
|
+
|
|
72
|
+
const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
|
|
73
|
+
expect(findBundledNode()).toBe(expected);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("resolves the bundled node path on linux-arm64", async () => {
|
|
77
|
+
const expected = writeFakePackage("linux-arm64", "node");
|
|
78
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
79
|
+
Object.defineProperty(process, "arch", { value: "arm64" });
|
|
80
|
+
|
|
81
|
+
const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
|
|
82
|
+
expect(findBundledNode()).toBe(expected);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("resolves node.exe on win32/x64", async () => {
|
|
86
|
+
const expected = writeFakePackage("win-x64", "node.exe");
|
|
87
|
+
Object.defineProperty(process, "platform", { value: "win32" });
|
|
88
|
+
Object.defineProperty(process, "arch", { value: "x64" });
|
|
89
|
+
|
|
90
|
+
const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
|
|
91
|
+
expect(findBundledNode()).toBe(expected);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("returns null when no platform package resolves (linux-x64)", async () => {
|
|
95
|
+
// Deliberately linux-x64, not darwin-arm64: no other test in this file
|
|
96
|
+
// ever creates a real @testmuai/rook-node-linux-x64 package, so this
|
|
97
|
+
// is the only specifier require.resolve() ever sees for that exact
|
|
98
|
+
// string — guaranteeing a genuine, uncached MODULE_NOT_FOUND rather
|
|
99
|
+
// than accidentally observing a stale successful resolution left by an
|
|
100
|
+
// earlier test that happened to install the same-tagged package.
|
|
101
|
+
Object.defineProperty(process, "platform", { value: "linux" });
|
|
102
|
+
Object.defineProperty(process, "arch", { value: "x64" });
|
|
103
|
+
|
|
104
|
+
const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
|
|
105
|
+
expect(findBundledNode()).toBeNull();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("returns null when ROOK_SYSTEM_NODE=1, even though the platform package resolves", async () => {
|
|
109
|
+
writeFakePackage("darwin-arm64", "node");
|
|
110
|
+
process.env.ROOK_SYSTEM_NODE = "1";
|
|
111
|
+
Object.defineProperty(process, "platform", { value: "darwin" });
|
|
112
|
+
Object.defineProperty(process, "arch", { value: "arm64" });
|
|
113
|
+
|
|
114
|
+
const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
|
|
115
|
+
expect(findBundledNode()).toBeNull();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("returns null on an unsupported platform, even if a same-named package would resolve", async () => {
|
|
119
|
+
writeFakePackage("darwin-arm64", "node");
|
|
120
|
+
Object.defineProperty(process, "platform", { value: "aix" });
|
|
121
|
+
|
|
122
|
+
const { findBundledNode } = await import("./rook.cjs?t=" + Date.now());
|
|
123
|
+
expect(findBundledNode()).toBeNull();
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
// shouldForward is a pure function of its (signal, platform) arguments
|
|
128
|
+
// (unlike findBundledNode, which reads process.platform/arch directly), so
|
|
129
|
+
// no Object.defineProperty override or module reset is needed to exercise
|
|
130
|
+
// every branch.
|
|
131
|
+
describe("rook.cjs shouldForward", () => {
|
|
132
|
+
it("does not forward SIGINT or SIGHUP on win32 — child.kill() there is always an unconditional hard kill (SIGINT would duplicate a shutdown the child already gets natively) or throws outright (SIGHUP isn't in libuv's supported set on Windows)", async () => {
|
|
133
|
+
const { shouldForward } = await import("./rook.cjs");
|
|
134
|
+
expect(shouldForward("SIGINT", "win32")).toBe(false);
|
|
135
|
+
expect(shouldForward("SIGHUP", "win32")).toBe(false);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("still forwards SIGTERM on win32 — the one signal with no native Windows delivery path to the child at all", async () => {
|
|
139
|
+
const { shouldForward } = await import("./rook.cjs");
|
|
140
|
+
expect(shouldForward("SIGTERM", "win32")).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("forwards SIGINT, SIGTERM, and SIGHUP on POSIX platforms, unchanged from before the win32 fix", async () => {
|
|
144
|
+
const { shouldForward } = await import("./rook.cjs");
|
|
145
|
+
for (const signal of ["SIGINT", "SIGTERM", "SIGHUP"]) {
|
|
146
|
+
expect(shouldForward(signal, "darwin")).toBe(true);
|
|
147
|
+
expect(shouldForward(signal, "linux")).toBe(true);
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// wireSignalForwarding is the actual behavioral fix — shouldForward alone
|
|
153
|
+
// only proves which signals are eligible, not that the surrounding
|
|
154
|
+
// signaled/spawned/killFailed control flow reacts to them correctly. A
|
|
155
|
+
// fake child (plain EventEmitter + a stubbed kill()) and a fake process
|
|
156
|
+
// object (records what it's told rather than doing it) let these run
|
|
157
|
+
// fully deterministically and in-process: driving the real `process`
|
|
158
|
+
// through this code would register real signal listeners on the test
|
|
159
|
+
// runner and could call its real process.exit()/process.kill(), which is
|
|
160
|
+
// exactly why the OS-level tests further down spawn a genuine subprocess
|
|
161
|
+
// instead. Here we don't need real OS signal delivery or real process
|
|
162
|
+
// lifecycle — only that the function reacts to the events correctly.
|
|
163
|
+
describe("rook.cjs wireSignalForwarding", () => {
|
|
164
|
+
function fakeChild() {
|
|
165
|
+
const child = new EventEmitter();
|
|
166
|
+
child.killCalls = [];
|
|
167
|
+
child.kill = vi.fn((signal) => {
|
|
168
|
+
child.killCalls.push(signal);
|
|
169
|
+
});
|
|
170
|
+
return child;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function fakeProcess() {
|
|
174
|
+
const listeners = new Map();
|
|
175
|
+
const events = [];
|
|
176
|
+
return {
|
|
177
|
+
events,
|
|
178
|
+
pid: 4242,
|
|
179
|
+
on(signal, fn) {
|
|
180
|
+
if (!listeners.has(signal)) listeners.set(signal, new Set());
|
|
181
|
+
listeners.get(signal).add(fn);
|
|
182
|
+
},
|
|
183
|
+
removeListener(signal, fn) {
|
|
184
|
+
listeners.get(signal)?.delete(fn);
|
|
185
|
+
},
|
|
186
|
+
emit(signal) {
|
|
187
|
+
for (const fn of listeners.get(signal) ?? []) fn(signal);
|
|
188
|
+
},
|
|
189
|
+
exit(code) {
|
|
190
|
+
events.push({ type: "exit", code });
|
|
191
|
+
},
|
|
192
|
+
kill(pid, signal) {
|
|
193
|
+
events.push({ type: "kill", pid, signal });
|
|
194
|
+
},
|
|
195
|
+
stderr: {
|
|
196
|
+
write(text) {
|
|
197
|
+
events.push({ type: "stderr", text });
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
it("falls back to the system Node path even when a signal was forwarded just before the spawn failure is detected — the race a naive signaled-only check would hang on", async () => {
|
|
204
|
+
const { wireSignalForwarding } = await import("./rook.cjs");
|
|
205
|
+
const child = fakeChild();
|
|
206
|
+
const proc = fakeProcess();
|
|
207
|
+
const onSpawnFailure = vi.fn();
|
|
208
|
+
|
|
209
|
+
wireSignalForwarding(child, {
|
|
210
|
+
bundledNode: "/fake/node",
|
|
211
|
+
entry: "/fake/entry.js",
|
|
212
|
+
platform: "linux",
|
|
213
|
+
proc,
|
|
214
|
+
onSpawnFailure,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
// A signal arrives and is forwarded before the child has ever
|
|
218
|
+
// confirmed it actually spawned.
|
|
219
|
+
proc.emit("SIGTERM");
|
|
220
|
+
expect(child.killCalls).toEqual(["SIGTERM"]);
|
|
221
|
+
|
|
222
|
+
// The async spawn failure is now detected — no 'spawn' event was ever
|
|
223
|
+
// emitted, so this can only be a genuine spawn failure, not a failed
|
|
224
|
+
// kill, regardless of the signal that already landed.
|
|
225
|
+
child.emit("error", new Error("spawn ENOENT"));
|
|
226
|
+
|
|
227
|
+
expect(onSpawnFailure).toHaveBeenCalledTimes(1);
|
|
228
|
+
expect(onSpawnFailure).toHaveBeenCalledWith("/fake/entry.js");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("does not launch a second CLI instance when a signal delivery fails after the child is confirmed alive — waits for the real exit instead", async () => {
|
|
232
|
+
const { wireSignalForwarding } = await import("./rook.cjs");
|
|
233
|
+
const child = fakeChild();
|
|
234
|
+
const proc = fakeProcess();
|
|
235
|
+
const onSpawnFailure = vi.fn();
|
|
236
|
+
|
|
237
|
+
wireSignalForwarding(child, {
|
|
238
|
+
bundledNode: "/fake/node",
|
|
239
|
+
entry: "/fake/entry.js",
|
|
240
|
+
platform: "linux",
|
|
241
|
+
proc,
|
|
242
|
+
onSpawnFailure,
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
child.emit("spawn");
|
|
246
|
+
proc.emit("SIGTERM");
|
|
247
|
+
expect(child.killCalls).toEqual(["SIGTERM"]);
|
|
248
|
+
|
|
249
|
+
// The kill attempt itself failed (delivered as 'error', not a throw) —
|
|
250
|
+
// the child may still be alive.
|
|
251
|
+
child.emit("error", new Error("kill EPERM"));
|
|
252
|
+
expect(onSpawnFailure).not.toHaveBeenCalled();
|
|
253
|
+
|
|
254
|
+
// The child genuinely exits later (the forwarded signal landing after
|
|
255
|
+
// all, or the child dying on its own) — normal cleanup must still run,
|
|
256
|
+
// proving settled was never latched by the branch above.
|
|
257
|
+
child.emit("exit", null, "SIGTERM");
|
|
258
|
+
expect(proc.events).toContainEqual({
|
|
259
|
+
type: "kill",
|
|
260
|
+
pid: proc.pid,
|
|
261
|
+
signal: "SIGTERM",
|
|
262
|
+
});
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
it("force-exits on the next signal after a kill attempt has already failed, instead of swallowing every later signal forever", async () => {
|
|
266
|
+
const { wireSignalForwarding } = await import("./rook.cjs");
|
|
267
|
+
const child = fakeChild();
|
|
268
|
+
child.kill = vi.fn(() => {
|
|
269
|
+
throw new Error("unsupported signal");
|
|
270
|
+
});
|
|
271
|
+
const proc = fakeProcess();
|
|
272
|
+
|
|
273
|
+
wireSignalForwarding(child, {
|
|
274
|
+
bundledNode: "/fake/node",
|
|
275
|
+
entry: "/fake/entry.js",
|
|
276
|
+
platform: "win32",
|
|
277
|
+
proc,
|
|
278
|
+
onSpawnFailure: vi.fn(),
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
proc.emit("SIGTERM"); // shouldForward("SIGTERM", "win32") is true; kill() throws
|
|
282
|
+
expect(proc.events.some((e) => e.type === "exit")).toBe(false);
|
|
283
|
+
|
|
284
|
+
proc.emit("SIGTERM"); // killFailed already true — give up and force-exit
|
|
285
|
+
expect(proc.events).toContainEqual({ type: "exit", code: 1 });
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("listens for SIGINT on win32 but never calls child.kill() for it — the observe-without-forwarding path", async () => {
|
|
289
|
+
const { wireSignalForwarding } = await import("./rook.cjs");
|
|
290
|
+
const child = fakeChild();
|
|
291
|
+
const proc = fakeProcess();
|
|
292
|
+
|
|
293
|
+
wireSignalForwarding(child, {
|
|
294
|
+
bundledNode: "/fake/node",
|
|
295
|
+
entry: "/fake/entry.js",
|
|
296
|
+
platform: "win32",
|
|
297
|
+
proc,
|
|
298
|
+
onSpawnFailure: vi.fn(),
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
proc.emit("SIGINT");
|
|
302
|
+
expect(child.killCalls).toEqual([]);
|
|
303
|
+
});
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
// Spawns rook.cjs as a genuine subprocess (via the real system Node, not
|
|
307
|
+
// import()) rather than calling main() in-process — main() calls
|
|
308
|
+
// process.exit()/process.kill() on every path, which would kill the test
|
|
309
|
+
// runner itself. This is also the only way to observe what a real caller
|
|
310
|
+
// sees: Node's own spawnSync result.signal, not a shell's collapsed exit
|
|
311
|
+
// code, which cannot tell "exited 143" and "killed by SIGTERM" apart.
|
|
312
|
+
describe("rook.cjs signal propagation (real subprocess)", () => {
|
|
313
|
+
const originalPlatform = process.platform;
|
|
314
|
+
const originalArch = process.arch;
|
|
315
|
+
|
|
316
|
+
beforeEach(() => {
|
|
317
|
+
delete process.env.ROOK_SYSTEM_NODE;
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
afterEach(() => {
|
|
321
|
+
Object.defineProperty(process, "platform", { value: originalPlatform });
|
|
322
|
+
Object.defineProperty(process, "arch", { value: originalArch });
|
|
323
|
+
rmSync(join(BIN_DIR, "node_modules"), { recursive: true, force: true });
|
|
324
|
+
rmSync(join(BIN_DIR, "child.pid"), { force: true });
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
// Both fixtures below need this file's own platform/arch (spawnSync's
|
|
328
|
+
// child inherits the real host, unlike the Object.defineProperty
|
|
329
|
+
// overrides used above, which only affect the current in-process
|
|
330
|
+
// module). Only darwin/linux are covered — there's no POSIX self-kill
|
|
331
|
+
// shell fixture for a win32 host, and this is exercising main()'s
|
|
332
|
+
// signal-handling logic, not per-OS behavior.
|
|
333
|
+
function hostTag() {
|
|
334
|
+
if (process.platform === "darwin" && process.arch === "arm64")
|
|
335
|
+
return "darwin-arm64";
|
|
336
|
+
if (process.platform === "darwin" && process.arch === "x64")
|
|
337
|
+
return "darwin-x64";
|
|
338
|
+
if (process.platform === "linux" && process.arch === "arm64")
|
|
339
|
+
return "linux-arm64";
|
|
340
|
+
if (process.platform === "linux" && process.arch === "x64")
|
|
341
|
+
return "linux-x64";
|
|
342
|
+
return null;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function writeExecutableFixture(tag, script) {
|
|
346
|
+
const root = fakePkgRoot(tag);
|
|
347
|
+
mkdirSync(join(root, "bin"), { recursive: true });
|
|
348
|
+
writeFileSync(
|
|
349
|
+
join(root, "package.json"),
|
|
350
|
+
JSON.stringify({ name: `@testmuai/rook-node-${tag}` }),
|
|
351
|
+
);
|
|
352
|
+
const binPath = join(root, "bin", "node");
|
|
353
|
+
writeFileSync(binPath, script);
|
|
354
|
+
chmodSync(binPath, 0o755);
|
|
355
|
+
return binPath;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
it("propagates a bundled runtime's signal death instead of collapsing to exit 1", () => {
|
|
359
|
+
const tag = hostTag();
|
|
360
|
+
if (!tag) return; // no POSIX fixture for this host — see hostTag()
|
|
361
|
+
writeExecutableFixture(tag, "#!/bin/sh\nkill -TERM $$\n");
|
|
362
|
+
|
|
363
|
+
const result = spawnSync(process.execPath, [join(BIN_DIR, "rook.cjs")], {
|
|
364
|
+
cwd: BIN_DIR,
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
expect(result.signal).toBe("SIGTERM");
|
|
368
|
+
expect(result.status).toBeNull();
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
// Positive control for the test above: proves the fixture/spawn harness
|
|
372
|
+
// itself works and that a normal (non-signal) exit still passes its real
|
|
373
|
+
// code straight through unchanged — so the signal test failing "for
|
|
374
|
+
// real" (status stays null, no SIGTERM observed) can't be mistaken for a
|
|
375
|
+
// broken fixture rather than a broken fix.
|
|
376
|
+
it("exits with the bundled runtime's real exit code on a normal exit", () => {
|
|
377
|
+
const tag = hostTag();
|
|
378
|
+
if (!tag) return;
|
|
379
|
+
writeExecutableFixture(tag, "#!/bin/sh\nexit 7\n");
|
|
380
|
+
|
|
381
|
+
const result = spawnSync(process.execPath, [join(BIN_DIR, "rook.cjs")], {
|
|
382
|
+
cwd: BIN_DIR,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
expect(result.signal).toBeNull();
|
|
386
|
+
expect(result.status).toBe(7);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
// The two tests above cover a signal death starting IN the child and
|
|
390
|
+
// propagating UP to the trampoline. This one covers the other direction:
|
|
391
|
+
// a signal sent to the trampoline's own PID (docker stop, systemd
|
|
392
|
+
// KillMode=process, timeout(1), a supervisor targeting the advertised
|
|
393
|
+
// PID) has to be forwarded DOWN to the bundled child, or the child is
|
|
394
|
+
// left running, orphaned, with inherited stdio — reproduced directly
|
|
395
|
+
// before the fix (kill -TERM to just the trampoline's PID left the
|
|
396
|
+
// fixture child alive afterward). Needs async spawn(), not spawnSync():
|
|
397
|
+
// the trampoline must still be running when the signal arrives so there
|
|
398
|
+
// is something to send it to.
|
|
399
|
+
it("forwards a signal sent to just the trampoline's PID down to the bundled child, instead of orphaning it", async () => {
|
|
400
|
+
const tag = hostTag();
|
|
401
|
+
if (!tag) return;
|
|
402
|
+
const childPidFile = join(BIN_DIR, "child.pid");
|
|
403
|
+
rmSync(childPidFile, { force: true });
|
|
404
|
+
writeExecutableFixture(
|
|
405
|
+
tag,
|
|
406
|
+
`#!/bin/sh\necho $$ > ${JSON.stringify(childPidFile)}\nsleep 30\n`,
|
|
407
|
+
);
|
|
408
|
+
|
|
409
|
+
const trampoline = spawn(process.execPath, [join(BIN_DIR, "rook.cjs")], {
|
|
410
|
+
cwd: BIN_DIR,
|
|
411
|
+
stdio: "ignore",
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
const childPid = await waitForFileContent(childPidFile, 5000);
|
|
415
|
+
expect(childPid).toBeTruthy();
|
|
416
|
+
|
|
417
|
+
const exited = new Promise((resolve) => trampoline.on("exit", resolve));
|
|
418
|
+
// kill(pid) (no leading dash) always targets exactly that one
|
|
419
|
+
// process, never its process group — unlike an interactive
|
|
420
|
+
// terminal's Ctrl-C, which reaches the whole group and would mask
|
|
421
|
+
// this bug even without the fix.
|
|
422
|
+
process.kill(trampoline.pid, "SIGTERM");
|
|
423
|
+
await exited;
|
|
424
|
+
// Give the forwarded signal a moment to actually land before checking.
|
|
425
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
426
|
+
|
|
427
|
+
expect(isAlive(Number(childPid))).toBe(false);
|
|
428
|
+
});
|
|
429
|
+
});
|
|
430
|
+
|
|
431
|
+
function waitForFileContent(path, timeoutMs) {
|
|
432
|
+
const deadline = Date.now() + timeoutMs;
|
|
433
|
+
return new Promise((resolve, reject) => {
|
|
434
|
+
const poll = () => {
|
|
435
|
+
if (existsSync(path)) {
|
|
436
|
+
resolve(readFileSync(path, "utf8").trim());
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
if (Date.now() > deadline) {
|
|
440
|
+
reject(new Error(`timed out waiting for ${path}`));
|
|
441
|
+
return;
|
|
442
|
+
}
|
|
443
|
+
setTimeout(poll, 20);
|
|
444
|
+
};
|
|
445
|
+
poll();
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function isAlive(pid) {
|
|
450
|
+
try {
|
|
451
|
+
process.kill(pid, 0);
|
|
452
|
+
return true;
|
|
453
|
+
} catch {
|
|
454
|
+
return false;
|
|
455
|
+
}
|
|
456
|
+
}
|