Skip to content

Commit 535a8bd

Browse files
authored
Refactor CLI to use spawn for better signal handling in watch mode (#7844)
* Refactor CLI to use spawn for better signal handling in watch mode * Add changelog
1 parent 2904146 commit 535a8bd

File tree

2 files changed

+81
-9
lines changed

2 files changed

+81
-9
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
#### :nail_care: Polish
3636

3737
- Improve error message for trying to define a type inside a function. https://github.com/rescript-lang/rescript/pull/7843
38+
- Refactor CLI to use spawn for better signal handling in watch mode. https://github.com/rescript-lang/rescript/pull/7844
3839

3940
#### :house: Internal
4041

cli/rescript.js

Lines changed: 80 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,85 @@ import { rescript_exe } from "./common/bins.js";
77

88
const args = process.argv.slice(2);
99

10-
try {
11-
child_process.execFileSync(rescript_exe, args, {
12-
stdio: "inherit",
13-
});
14-
} catch (err) {
15-
if (err.status !== undefined) {
16-
process.exit(err.status); // Pass through the exit code
10+
// We intentionally use spawn (async) instead of execFileSync (sync) here.
11+
// Rationale:
12+
// - execFileSync blocks Node's event loop, so Ctrl+C (SIGINT) causes Node to
13+
// exit immediately without giving us a chance to forward the signal to the
14+
// child and wait for its cleanup. In watch mode, the Rust watcher prints
15+
// "Exiting..." on SIGINT and performs cleanup; with execFileSync that output
16+
// may appear after the shell prompt and sometimes requires an extra keypress.
17+
// - spawn lets us install signal handlers, forward them to the child, and then
18+
// exit the parent with the correct status only after the child has exited.
19+
const child = child_process.spawn(rescript_exe, args, {
20+
stdio: "inherit",
21+
});
22+
23+
// Map POSIX signal names to conventional exit status numbers so we can
24+
// reproduce the usual 128 + signal behavior when exiting due to a signal.
25+
/** @type {Record<string, number>} */
26+
const signalToNumber = { SIGINT: 2, SIGTERM: 15, SIGHUP: 1, SIGQUIT: 3 };
27+
28+
let forwardedSignal = false;
29+
/**
30+
* @param {NodeJS.Signals} signal
31+
*/
32+
const handleSignal = (signal) => {
33+
// Intercept the signal in the parent, forward it to the child, and let the
34+
// child perform its own cleanup. This ensures ordered shutdown in watch mode.
35+
// Guard against double-forwarding since terminals or OSes can deliver
36+
// multiple signals (e.g., repeated Ctrl+C).
37+
// Prevent Node from exiting immediately; forward to child first
38+
if (forwardedSignal) return;
39+
forwardedSignal = true;
40+
try {
41+
if (child.exitCode === null && child.signalCode == null) {
42+
child.kill(signal);
43+
}
44+
} catch {
45+
// best effort
46+
}
47+
};
48+
49+
process.on("SIGINT", handleSignal);
50+
process.on("SIGTERM", handleSignal);
51+
process.on("SIGHUP", handleSignal);
52+
process.on("SIGQUIT", handleSignal);
53+
54+
// Cross-platform note:
55+
// - On Unix, Ctrl+C sends SIGINT to the process group; we also explicitly
56+
// forward it to the child to be robust.
57+
// - On Windows, Node maps kill('SIGINT'/'SIGTERM') to console control events;
58+
// the Rust watcher (via the ctrlc crate) handles these and exits cleanly.
59+
60+
// Ensure no orphaned process if parent exits unexpectedly
61+
process.on("exit", () => {
62+
if (child.exitCode === null && child.signalCode == null) {
63+
try {
64+
child.kill("SIGTERM");
65+
} catch {
66+
// ignore
67+
}
68+
}
69+
});
70+
71+
child.on("exit", (code, signal) => {
72+
process.removeListener("SIGINT", handleSignal);
73+
process.removeListener("SIGTERM", handleSignal);
74+
process.removeListener("SIGHUP", handleSignal);
75+
process.removeListener("SIGQUIT", handleSignal);
76+
77+
// If the child exited due to a signal, emulate the conventional exit status
78+
// (128 + signalNumber). Otherwise, pass through the child's numeric exit code.
79+
if (signal) {
80+
const n = signalToNumber[signal];
81+
process.exit(typeof n === "number" ? 128 + n : 1);
1782
} else {
18-
process.exit(1); // Generic error
83+
process.exit(typeof code === "number" ? code : 0);
1984
}
20-
}
85+
});
86+
87+
// Surface spawn errors (e.g., executable not found) and exit with failure.
88+
child.on("error", (err) => {
89+
console.error(err?.message ?? String(err));
90+
process.exit(1);
91+
});

0 commit comments

Comments
 (0)