|
1 | 1 | import type { ChildProcess, StdioOptions } from "child_process"; |
2 | 2 | import { spawn } from "child_process"; |
3 | | -import { EventEmitter, once } from "events"; |
4 | | -import { setTimeout } from "timers/promises"; |
| 3 | +import { EventEmitter } from "events"; |
5 | 4 | import type { Project } from "./Project.js"; |
6 | 5 | import type { RunOptions } from "./ProjectConfig.js"; |
7 | 6 | import { log } from "./utils.js"; |
8 | 7 |
|
9 | 8 | /** */ |
10 | 9 | export class Supervisor extends EventEmitter { |
11 | 10 | process!: ChildProcess; |
| 11 | + private stopping?: Promise<void>; |
| 12 | + |
12 | 13 | constructor(readonly argv: string[], readonly socketPath: string, readonly options: RunOptions, readonly project: Project) { |
13 | 14 | super(); |
14 | 15 | } |
15 | 16 |
|
16 | 17 | /** |
17 | 18 | * 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. |
19 | 21 | * See https://azimi.me/2014/12/31/kill-child_process-node-js.html for more information |
20 | 22 | */ |
21 | 23 | 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 | + } |
24 | 27 |
|
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; |
27 | 31 |
|
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; |
31 | 34 |
|
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; |
37 | 41 |
|
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`); |
48 | 55 | } |
49 | | - } |
| 56 | + |
| 57 | + this.kill("SIGKILL", refPid, true); |
| 58 | + })(); |
| 59 | + |
| 60 | + return await this.stopping; |
50 | 61 | } |
51 | 62 |
|
52 | 63 | restart() { |
53 | | - this.kill(); |
| 64 | + if (this.process?.pid) { |
| 65 | + log.debug(`restarting process group ${this.process.pid} with SIGKILL`); |
| 66 | + this.kill(); |
| 67 | + } |
54 | 68 |
|
55 | 69 | const stdio: StdioOptions = [null, "inherit", "inherit"]; |
56 | 70 | if (!this.options.terminalCommands) { |
@@ -96,4 +110,23 @@ export class Supervisor extends EventEmitter { |
96 | 110 |
|
97 | 111 | return this.process; |
98 | 112 | } |
| 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 | + } |
99 | 132 | } |
0 commit comments