@@ -7,14 +7,85 @@ import { rescript_exe } from "./common/bins.js";
77
88const 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