Skip to content

Commit 85366b1

Browse files
authored
Merge pull request #204 from gadget-inc/signal-process-group-later
Signal process group later
2 parents 3c9620b + d331864 commit 85366b1

File tree

7 files changed

+270
-32
lines changed

7 files changed

+270
-32
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "wds",
3-
"version": "0.23.0",
3+
"version": "0.24.0",
44
"author": "Harry Brundage",
55
"license": "MIT",
66
"bin": {

spec/Supervisor.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,135 @@ test("it can load a commonjs module inside a directory that contains a dot when
130130

131131
await childExit(child);
132132
}, 10000);
133+
134+
const runSignalOrderTest = async (signal: NodeJS.Signals) => {
135+
const binPath = path.join(dirname, "../pkg/wds.bin.js");
136+
const scriptPath = path.join(dirname, "fixtures/src/signal-order.ts");
137+
138+
const child = spawn("node", [binPath, scriptPath], {
139+
stdio: ["ignore", "pipe", "pipe"],
140+
env: process.env,
141+
});
142+
143+
let stdout = "";
144+
let isReady = false;
145+
const onData = (data: Buffer) => {
146+
stdout += data.toString();
147+
if (!isReady && stdout.includes("parent:ready") && stdout.includes("grandchild:ready")) {
148+
isReady = true;
149+
}
150+
};
151+
child.stdout?.on("data", onData);
152+
153+
await new Promise<void>((resolve) => {
154+
const checkReady = () => (isReady ? resolve() : setTimeout(checkReady, 50));
155+
checkReady();
156+
});
157+
158+
const wdsPid = child.pid;
159+
160+
161+
child.kill(signal);
162+
163+
await childExit(child);
164+
165+
// give any SIGKILLs time to propagate
166+
await new Promise((resolve) => setTimeout(resolve, 250));
167+
168+
let parentPid: number | undefined;
169+
let grandchildPid: number | undefined;
170+
const lines = stdout.split(/\r?\n/).filter(Boolean).map((line) => {
171+
if (line.includes("parent:ready")) {
172+
parentPid = parseInt(line.split(":")[2]);
173+
return `parent:ready`;
174+
} else if (line.includes("grandchild:ready")) {
175+
grandchildPid = parseInt(line.split(":")[2]);
176+
return `grandchild:ready`;
177+
}
178+
return line;
179+
});
180+
181+
return { lines, wdsPid, parentPid, grandchildPid };
182+
}
183+
184+
const isPidAlive = (pid: number) => {
185+
try {
186+
process.kill(pid, 0);
187+
return true;
188+
} catch (e) {
189+
if (e.code === "ESRCH") {
190+
return false;
191+
}
192+
throw e;
193+
}
194+
}
195+
196+
test("it kills the child first, then the process group on stop", async () => {
197+
const { lines, wdsPid, parentPid, grandchildPid } = await runSignalOrderTest("SIGTERM");
198+
expect(wdsPid).toBeDefined();
199+
expect(parentPid).toBeDefined();
200+
expect(grandchildPid).toBeDefined();
201+
expect(parentPid).not.toBe(wdsPid);
202+
expect(grandchildPid).not.toBe(wdsPid);
203+
expect(parentPid).not.toBe(grandchildPid);
204+
205+
expect(isPidAlive(wdsPid!)).toBe(false);
206+
expect(isPidAlive(parentPid!)).toBe(false);
207+
expect(isPidAlive(grandchildPid!)).toBe(false);
208+
209+
expect(lines).toMatchInlineSnapshot(`
210+
[
211+
"parent:ready",
212+
"grandchild:ready",
213+
"parent:exit-SIGTERM",
214+
"grandchild:sigterm",
215+
"parent:grandchild-exit",
216+
]
217+
`);
218+
}, 20000);
219+
220+
test("it kills grandchildren if they have not shutdown by the time the parent process exits", async () => {
221+
const { lines, wdsPid, parentPid, grandchildPid } = await runSignalOrderTest("SIGINT");
222+
expect(wdsPid).toBeDefined();
223+
expect(parentPid).toBeDefined();
224+
expect(grandchildPid).toBeDefined();
225+
expect(parentPid).not.toBe(wdsPid);
226+
expect(grandchildPid).not.toBe(wdsPid);
227+
expect(parentPid).not.toBe(grandchildPid);
228+
229+
expect(isPidAlive(wdsPid!)).toBe(false);
230+
expect(isPidAlive(parentPid!)).toBe(false);
231+
expect(isPidAlive(grandchildPid!)).toBe(false);
232+
233+
expect(lines).toMatchInlineSnapshot(`
234+
[
235+
"parent:ready",
236+
"grandchild:ready",
237+
"parent:exit-SIGINT",
238+
"grandchild:sigint",
239+
"parent:exit-timeout",
240+
]
241+
`);
242+
}, 20000);
243+
244+
test("it kills the whole process group if the child process doesn't exit before the timeout", async () => {
245+
const { lines, wdsPid, parentPid, grandchildPid } = await runSignalOrderTest("SIGQUIT");
246+
expect(wdsPid).toBeDefined();
247+
expect(parentPid).toBeDefined();
248+
expect(grandchildPid).toBeDefined();
249+
expect(parentPid).not.toBe(wdsPid);
250+
expect(grandchildPid).not.toBe(wdsPid);
251+
expect(parentPid).not.toBe(grandchildPid);
252+
253+
expect(isPidAlive(wdsPid!)).toBe(false);
254+
expect(isPidAlive(parentPid!)).toBe(false);
255+
expect(isPidAlive(grandchildPid!)).toBe(false);
256+
257+
expect(lines).toMatchInlineSnapshot(`
258+
[
259+
"parent:ready",
260+
"grandchild:ready",
261+
"parent:sigquit",
262+
]
263+
`);
264+
}, 20000);

spec/fixtures/src/grandchild.js

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
console.log(`grandchild:ready:${process.pid}`);
2+
3+
process.on("SIGINT", () => {
4+
// swallow SIGINT and log
5+
// let wds kill this process after a timeout
6+
console.log("grandchild:sigint");
7+
});
8+
9+
process.on("SIGQUIT", () => {
10+
console.log("grandchild:sigquit");
11+
});
12+
13+
process.on("SIGTERM", () => {
14+
console.log("grandchild:sigterm");
15+
process.exit(0);
16+
});
17+
18+
// Keep alive
19+
setInterval(() => {}, 1e9);
20+
21+

spec/fixtures/src/signal-order.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import { spawn } from "child_process";
2+
import * as path from "path";
3+
4+
// Spawn a long-lived grandchild that logs when it gets SIGTERM
5+
// Resolve from project root (wds runs child with cwd at project root)
6+
const grandchildPath = path.resolve(process.cwd(), "spec/fixtures/src/grandchild.js");
7+
const grandchild = spawn("node", [grandchildPath], {
8+
stdio: ["ignore", "inherit", "inherit"],
9+
});
10+
11+
console.log(`parent:ready:${process.pid}`);
12+
13+
const exit = (signal: NodeJS.Signals) => {
14+
console.log(`parent:exit-${signal}`);
15+
16+
grandchild.once("exit", () => {
17+
console.log("parent:grandchild-exit");
18+
process.exit(0);
19+
});
20+
21+
setTimeout(() => {
22+
console.log("parent:exit-timeout");
23+
process.exit(0);
24+
}, 2000);
25+
26+
grandchild.kill(signal);
27+
}
28+
29+
process.on("SIGTERM", () => exit("SIGTERM"));
30+
process.on("SIGINT", () => exit("SIGINT"));
31+
process.on("SIGQUIT", () => {
32+
console.log("parent:sigquit");
33+
});
34+
35+
// Keep the process alive indefinitely until signaled
36+
setInterval(() => {}, 1e9);
37+
38+

src/Project.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ export class Project {
1717
supervisor!: Supervisor;
1818
watched = new PathTrie();
1919

20+
private shuttingDown?: Promise<void>;
21+
2022
constructor(readonly workspaceRoot: string, readonly config: ProjectConfig, readonly compiler: Compiler) {}
2123

2224
addShutdownCleanup(cleanup: () => void) {
@@ -62,11 +64,19 @@ export class Project {
6264
}
6365

6466
async shutdown(code: number, signal: NodeJS.Signals = "SIGTERM") {
65-
await this.supervisor.stop(signal);
66-
for (const cleanup of this.cleanups) {
67-
cleanup();
67+
if (this.shuttingDown) {
68+
return await this.shuttingDown;
6869
}
69-
process.exit(code);
70+
71+
this.shuttingDown = (async () => {
72+
await this.supervisor.stop(signal);
73+
for (const cleanup of this.cleanups) {
74+
cleanup();
75+
}
76+
process.exit(code);
77+
})();
78+
79+
return await this.shuttingDown;
7080
}
7181

7282
watchFile(path: string) {

src/Supervisor.ts

Lines changed: 60 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,56 +1,70 @@
11
import type { ChildProcess, StdioOptions } from "child_process";
22
import { spawn } from "child_process";
3-
import { EventEmitter, once } from "events";
4-
import { setTimeout } from "timers/promises";
3+
import { EventEmitter } from "events";
54
import type { Project } from "./Project.js";
65
import type { RunOptions } from "./ProjectConfig.js";
76
import { log } from "./utils.js";
87

98
/** */
109
export class Supervisor extends EventEmitter {
1110
process!: ChildProcess;
11+
private stopping?: Promise<void>;
12+
1213
constructor(readonly argv: string[], readonly socketPath: string, readonly options: RunOptions, readonly project: Project) {
1314
super();
1415
}
1516

1617
/**
1718
* Stop the process with a given signal, then SIGKILL after a timeout
18-
* Kills the whole process group so that any subprocesses of the process are also killed
19+
* First signals only the ref'd process; once it exits, signal the rest of the process group.
20+
* Falls back to SIGKILL on the group if the ref'd process doesn't exit in time.
1921
* See https://azimi.me/2014/12/31/kill-child_process-node-js.html for more information
2022
*/
2123
async stop(signal: NodeJS.Signals = "SIGTERM") {
22-
// if we never started the child process, we don't need to do anything
23-
if (!this.process || !this.process.pid) return;
24+
if (this.stopping) {
25+
return await this.stopping;
26+
}
2427

25-
// if the child process has already exited, we don't need to do anything
26-
if (this.process.exitCode !== null) return;
28+
this.stopping = (async () => {
29+
// if we never started the child process, we don't need to do anything
30+
if (!this.process || !this.process.pid) return;
2731

28-
const ref = this.process;
29-
const exit = once(ref, "exit");
30-
this.kill(signal);
32+
// if the child process has already exited, we don't need to do anything
33+
if (this.process.exitCode !== null) return;
3134

32-
await Promise.race([exit, setTimeout(5000)]);
33-
if (!ref.killed) {
34-
this.kill("SIGKILL", ref.pid);
35-
}
36-
}
35+
// signal the child process and give if time to gracefully close, allow the child process
36+
// the chance to attempt a graceful shutdown of any child processes it may have spawned
37+
// once the child process exits, SIGKILL the rest of the process group that haven't exited yet
38+
// if the child process doesn't exit in time, SIGKILL the whole process group
39+
const ref = this.process;
40+
const refPid = ref.pid;
3741

38-
kill(signal: NodeJS.Signals = "SIGKILL", pid = this.process?.pid) {
39-
if (pid) {
40-
try {
41-
process.kill(-pid, signal);
42-
} catch (error: any) {
43-
if (error.code == "ESRCH" || error.code == "EPERM") {
44-
// process can't be found or can't be killed again, its already dead
45-
} else {
46-
throw error;
47-
}
42+
log.debug(`stopping process ${refPid} with signal ${signal}`);
43+
44+
this.kill(signal, refPid, false);
45+
46+
const exited = await Promise.race([
47+
new Promise<boolean>((resolve) => ref.once("exit", () => resolve(true))),
48+
new Promise<boolean>((resolve) => setTimeout(() => resolve(false), 5000)),
49+
]);
50+
51+
if (exited) {
52+
log.debug(`process ${refPid} exited successfully, killing process group`);
53+
} else {
54+
log.debug(`process ${refPid} did not exit in time, killing process group`);
4855
}
49-
}
56+
57+
this.kill("SIGKILL", refPid, true);
58+
})();
59+
60+
return await this.stopping;
5061
}
5162

5263
restart() {
53-
this.kill();
64+
if (this.process?.pid) {
65+
log.debug(`restarting process group ${this.process.pid} with SIGKILL`);
66+
this.kill();
67+
}
5468

5569
const stdio: StdioOptions = [null, "inherit", "inherit"];
5670
if (!this.options.terminalCommands) {
@@ -96,4 +110,23 @@ export class Supervisor extends EventEmitter {
96110

97111
return this.process;
98112
}
113+
114+
private kill(signal: NodeJS.Signals = "SIGKILL", pid = this.process?.pid, group = true) {
115+
if (!pid) return;
116+
if (group) {
117+
log.debug(`killing process group ${pid} with signal ${signal}`);
118+
} else {
119+
log.debug(`killing process ${pid} with signal ${signal}`);
120+
}
121+
try {
122+
if (group) {
123+
process.kill(-pid, signal);
124+
} else {
125+
process.kill(pid, signal);
126+
}
127+
} catch (error: any) {
128+
log.debug(`error killing process ${pid} with signal ${signal}: ${error.message}`);
129+
if (error.code !== "ESRCH" && error.code !== "EPERM") throw error;
130+
}
131+
}
99132
}

src/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,10 @@ export const wds = async (options: RunOptions) => {
203203
log.debug(`process ${process.pid} got SIGTERM`);
204204
void project.shutdown(0, "SIGTERM");
205205
});
206+
process.on("SIGQUIT", () => {
207+
log.debug(`process ${process.pid} got SIGQUIT`);
208+
void project.shutdown(0, "SIGQUIT");
209+
});
206210

207211
project.supervisor.process.on("exit", (code, signal) => {
208212
const logShutdown = (explanation: string) => {

0 commit comments

Comments
 (0)