Node.jsで正しく子プロセスを殺す
Node.js で子プロセスを生み出す場合、Node.js というコマンドラインで起動するツールである都合上、たいていの場合は、Ctrl+C で止めることが多いはずですが、そうじゃない場合に子プロセスが生き残るよね、どうやって殺す?という話です。
たとえば
const childProcess = require('child_process')
childProcess.exec('sleep 10000')
このようなコードを動かしたとします。
別ターミナルで ps を見ると、
90837 ttys001    0:00.05 node test.js
90838 ttys001    0:00.00 sleep 10000
のように、2 つのプロセスが起動していることがわかります。
Ctrl+C でプロセスを止めると、この 2 つのプロセスは両方が終了しますが、もし kill 90837 のように、外部からプロセスを止めたらどうなるでしょうか。
% kill 90837
% ps
  PID TTY           TIME CMD
90838 ttys001    0:00.00 sleep 10000
というふうに子プロセスは生き残ってしまいます。
もとのターミナルでは
% node test.js
zsh: terminated  node test.js
%
というふうになって、親プロセスが終了させられています。
この事例のような sleep 10000 であれば、無害なので残っていても問題はありませんが、たとえば何かしらのサーバープロセスなどであれば、そのプロセスは起動しっぱなしになります。ポートや他リソースを専有し、動作し続けるのです。
子プロセスのプロセス ID を記録して殺す
const childProcess = require('child_process')
const cp = childProcess.exec('sleep 10000')
console.log(cp.pid)
const cleanup = () => {
  process.kill(cp.pid)
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('SIGQUIT', cleanup)
Node.js で子プロセスを起動した場合、子プロセスの PID を取得できます。終了時のハンドラ process.on(<signal>, cleanup) を設定しておけば PID で殺すことができます。
% kill 91140
% ps
  PID TTY           TIME CMD
子プロセスが子プロセスを生み出すような事例だとどうでしょうか?
子孫を殺す
サンプルとしては別になんでもいいのですが、今回は create-react-app を使ってみます。
% yarn create react-app hoge
% cd hoge
今度は、
const childProcess = require('child_process')
const cp = childProcess.exec('yarn start')
さきほどと違うのは起動コマンドです。
このコマンドを実行すると、3000 番ポートが空いていれば、ウェブブラウザ上に localhost:3000 が開いて、React のアプリが起動しているはずです。
% ps
  PID TTY           TIME CMD
94226 ttys001    0:00.06 node test.js
94227 ttys001    0:00.20 node /opt/homebrew/bin/yarn start
94228 ttys001    0:00.03 /opt/homebrew/Cellar/node/15.4.0/bin/node .../node_modules/.bin/react-scripts start
94229 ttys001    0:05.78 /opt/homebrew/Cellar/node/15.4.0/bin/node .../node_modules/react-scripts/scripts/start.js
node test.jsyarn startnode node_moudles/.bin/react-scripts startnode node_modules/react-scipts/scripts/start.js
子孫がたくさんいます。
この状態で node test.js を kill してみましょう。
erukiti@M1-Mac-mini child_process_test % kill 94226
erukiti@M1-Mac-mini child_process_test % ps
  PID TTY           TIME CMD
94227 ttys001    0:00.20 node /opt/homebrew/bin/yarn start
94228 ttys001    0:00.03 /opt/homebrew/Cellar/node/15.4.0/bin/node .../node_modules/.bin/react-scripts start
94229 ttys001    0:05.89 /opt/homebrew/Cellar/node/15.4.0/bin/node /Users/erukiti/work/_/hoge3/node_modules/react-scripts/scripts/start.js
node test.js だけが終了します。
それでは、先程の process.kill で子プロセスを殺してみましょう。
const childProcess = require('child_process')
const cp = childProcess.exec('yarn start')
console.log(cp.pid)
const cleanup = () => {
  process.kill(cp.pid)
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('SIGQUIT', cleanup)
この場合、react-scripts のサーバープロセスである node_modules/react-scripts/scripts/start.js と node test.js が生き残っています。
 % kill 95243
erukiti@M1-Mac-mini child_process_test % ps
  PID TTY           TIME CMD
95243 ttys001    0:00.07 node test.js
95246 ttys001    0:05.24 /opt/homebrew/Cellar/node/15.4.0/bin/node .../node_modules/react-scripts/scripts/start.js
もう一度 kill しようとすると、 Error: kill ESRCH というエラーが出ますが、これは process.kill(cp.pid) の対象がすでに死んでるためです。
さて、子孫を正しく殺す方法はどうすればいいのでしょうか?
OS 依存になるやり方であれば、プロセスグループという概念をもとに、まとめて一括で殺すことができますが、マルチプラットフォームでいきたいものです。
自分が生み出したプロセスの子孫一覧を取得できれば、安全に殺せるはずです。ps-tree という npm を使えば可能です。
yarn add ps-tree
どういうふうに一覧が取れるのでしょうか?`
const childProcess = require('child_process')
const psTree = require('ps-tree')
const cp = childProcess.exec('yarn start')
console.log(cp.pid)
psTree(cp.pid, (err, children) => {
  console.log(err)
  console.log(children)
})
このようなコードを書いてみましたが、このコードでは children が取れません。子プロセスを起動しようとしてすぐ psTree の部分に処理がいってしまうので、何かしらのウェイトが必要です。
react-scripts start であれば、起動していれば Compiled successfully! のような文字列が画面に出るので、それを捕まえてみましょう。
const childProcess = require('child_process')
const psTree = require('ps-tree')
const cp = childProcess.exec('yarn start')
cp.stdout.on('data', (data) => {
  process.stdout.write(data)
  if (data.includes('Compiled successfully!')) {
    psTree(cp.pid, (err, children) => {
      console.log(err)
      console.log(children)
    })
  }
})
console.log(cp.pid)
% node test.js
95511
yarn run v1.22.10
$ react-scripts start
...
Compiled successfully!
...
null
[
  {
    PPID: '95511',
    PID: '95512',
    STAT: 'S+',
    COMM: '/opt/homebrew/Cellar/node/15.4.0/bin/node'
  },
  {
    PPID: '95512',
    PID: '95513',
    STAT: 'U+',
    COMM: '/opt/homebrew/Cellar/node/15.4.0/bin/node'
  }
]
- 子プロセスは 95511
 - 95511 を親に持つ 95512 が生まれる
 - 95512 を親に持つ 95513 が生まれる
 
孫から順に殺していけばいいのでしょうか。もちろん安全に殺すならその方が望ましいですが、子孫は全部雑に殺してもいいのではないでしょうか?
const childProcess = require('child_process')
const psTree = require('ps-tree')
const pids = []
const cp = childProcess.exec('yarn start')
cp.stdout.on('data', (data) => {
  process.stdout.write(data)
  if (data.includes('Compiled successfully!')) {
    psTree(cp.pid, (err, children) => {
      console.log(children)
      children.forEach((child) => {
        pids.push(child.PID)
      })
    })
  }
})
console.log(cp.pid)
pids.push(cp.pid)
const cleanup = () => {
  console.log(pids)
  pids.forEach((pid) => {
    try {
      process.kill(pid)
    } catch (e) {
      // nice catch
    }
  })
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('SIGQUIT', cleanup)
無事、子孫を全員殺せました。process.kill を try/catch で囲ってあげると、殺すときにエラーが出ても問題ありません。
macOS 及び Linux (WSL2 含む)は問題なくこれでいけるようです。
ところが、Windows Native の場合、そもそも Node.js から利用できるシグナルの概念がないらしく powershell で kill xxxx とやると、いわゆる kill -9 xxx と同じように、強制終了になってしまい、せっかくのハンドラも実行されずに終了してしまうようです。
Electron の場合
Electron の起動プロセスだとどうなるでしょうか?
Electron でアプリケーションを作るときに、react-scripts などを使ってビルドしたものを動かしたいとなったとき、起動プロセスで、yarn start を fork したとして、終了時に正しく子孫は殺されるのでしょうか?
Electron においても、Ctrl+C でなら問題はありません。
ところが、Electron アプリ自体を落とす場合、シグナルとは別の終了方法で終了してしまうため、子孫は生き残ってしまいます。
yarn add electron
npx electron test.js
で、test.js は以下のように Electron の起動と、Electron 終了時の cleanup を追加します。
const childProcess = require('child_process')
const psTree = require('ps-tree')
const { app, BrowserWindow } = require('electron')
const pids = []
const cp = childProcess.exec('yarn start')
cp.stdout.on('data', (data) => {
  process.stdout.write(data)
  if (data.includes('Compiled successfully!')) {
    psTree(cp.pid, (err, children) => {
      console.log(children)
      children.forEach((child) => {
        pids.push(child.PID)
      })
    })
    app.whenReady().then(() => {
      const win = new BrowserWindow({ width: 800, height: 600 })
      win.loadURL('http://localhost:3000')
    })
  }
})
console.log(cp.pid)
pids.push(cp.pid)
const cleanup = () => {
  console.log(pids)
  pids.forEach((pid) => {
    try {
      process.kill(pid)
    } catch (e) {
      // nice catch
    }
  })
}
process.on('SIGINT', cleanup)
process.on('SIGTERM', cleanup)
process.on('SIGQUIT', cleanup)
process.on('uncaughtException', cleanup)
app.on('quit', cleanup) // これがなければ子孫は生き残ってしまう
子孫を正しく殺すためには app.on('quit', cleanup) が必要です。
まとめ
Node.js でコマンドラインツールを作ってるときは、大体 Ctrl+C で終了するため、子プロセスをバンバン生み出してそのまま普通に終了しても問題なかったのですが、シグナルで殺される、あるいは Electron アプリで子プロセスを動かす場合などは、ちゃんと子孫を探し出して殺さないと生き残ってしまいます。
物騒な単語が飛び交う記事になってしまいましたが、あくまでプロセスの終了の話です。最近流行りの鬼がどうたらとかそういうやつではありません。日光も平気なはずです。
Discussion