[Windows] PythonでプロセスにCTRL_C_EVENTを送る(厳しい)

WindowsでPopenしたプロセスに対してCTRL_C_EVENTを送りたかったが、実現が結構厳しい話。

厳しい理由とちょっと強引な解決方法についてまとめておく。開発環境は以下の通り。

開発環境

実現方法の検討

1. Popenインスタンス.send_signal()でSIGINT送信

signalライブラリの公式リファレンスには、下記の通りSIGINTの説明がある。

signal --- Set handlers for asynchronous events — Python 3.12.3 ドキュメント signal.SIGINT
Interrupt from keyboard (CTRL + C).

Default action is to raise KeyboardInterrupt.

であるならば、単純に下記のようなコードで良さそうである。

import signal
import subprocess
import time

cmd = ["python", "sub.py"]
proc = subprocess.Popen(cmd)
time.sleep(2)
# SIGINTの送信
proc.send_signal(signal.SIGINT)

しかしながら、上記コードを実行するとValueError: Unsupported signal: 2、つまりはSIGINT(この値は2)のシグナル送信は対応されていないということ。 Unix/Linuxでは問題なく動作するようであるが、Windowsではそうもいかないらしい。

2. os.killsignal.CTRL_C_EVENTを送信する

同じくsignalライブラリにはCTRL_C_EVENTのシグナルが存在し、公式リファレンスには下記説明がある。

https://docs.python.org/ja/3/library/signal.html#signal.CTRL_C_EVENT signal.CTRL_C_EVENT
CTRL+C キーストロークに該当するシグナル。このシグナルは os.kill() でだけ利用できます。

利用可能な環境: Windows

今回実現したいCTRL+Cの送信そのものである。実際にメインプログラムとサブプロセスのプログラムを2つ用意して、os.kill()を試してみる。

【main.py】

import os
import signal
import subprocess
import time

cmd = ["python", "sub.py"]
proc = subprocess.Popen(cmd)
# 少し待ってからKILL
time.sleep(2)
os.kill(proc.pid, signal.CTRL_C_EVENT)

# プロセスのKILL後に別の処理
while True:
    time.sleep(1)
    print("main処理")

【sub.py】

import time

try:
    # 30秒で終了するプログラム
    for i in range(60):
        print(f"loop{i+1}")
        time.sleep(1)
except KeyboardInterrupt:
    print("KeyboardInterrupt発火")

【実行結果】

$ python main.py 
loop1
loop2
loop3
Traceback (most recent call last):
KeyboardInterrupt発火
  File "D:\workspace\main.py", line 14, in <module>
    time.sleep(1)
KeyboardInterrupt

ちゃんとサブプロセス側のKeyboardInterrupt例外が発生していることは確認できるが、メインプロセス側までKeyboardInterruptが波及し、メイン処理を実行できずに終了してしまっていることがわかる。 このあたりの挙動について非常に詳しく回答されているページがある。

python - How to handle a signal.SIGINT on a Windows OS machine? - Stack Overflow

上記によれば、「Pythonos.kill()はWindowsAPIのGenerateConsoleCtrlEvent()を呼んで、CTRL_C_EVENTを送信」しているとのこと。

そしてGenerateConsoleCtrlEventリファレンスによれば、「呼び出し元のプロセスとコンソールを共有するコンソール・プロセス・グループに、指定されたシグナルを送る。」と最初に明記されている。したがって、今回の動作は元から想定されていた通りということだ。

となると、Popenのcreationflagsにsubprocess.CREATE_NEW_PROCESS_GROUPを渡してプロセスグループを分けてみようと思いつく。

3. Popen(..., creationflags=CREATE_NEW_PROCESS_GROUP)してからos.kill()

実はPopenのsend_signal()リファレンスには下記言及がある。

Note: On Windows, SIGTERM is an alias for terminate(). CTRL_C_EVENT and CTRL_BREAK_EVENT can be sent to processes started with a creationflags parameter which includes CREATE_NEW_PROCESS_GROUP.

おー!プロセスグループ作ってから、CTRL_C_EVENT送っても良さそうな感じ!と思えるだろう。しかし、MSDNのリファレンスには下記のように書かれているのだ。

if a process in the group creates a new console, that process does not receive the signal, nor do its descendants.

(DeepL翻訳) グループ内のプロセスが新しいコンソールを作成した場合、そのプロセスはシグナルを受け取らず、その子孫もシグナルを受け取らない。

つまり、CREATE_NEW_PROCESS_GROUPでPopenしたプロセスにはCTRL_C_EVENTは届かないよ、ということだ。 実際に、Popen部分のみ下記のように編集して実行したところ、何も起きずにサブプロセスは実行され続けた。

proc = subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_PROCESS_GROUP)

なお、このAPIで遅れるもう一つのイベントCTRL_BREAK_EVENTについては、CREATE_NEW_PROCESS_GROUP状況下においても送ることができる。ただし、当然だがKeyboardInterruptの発火はない。

4. じゃあどうするか?(解決策

こちらの回答の方策を取る。 stackoverflow.com

別のプログラム(ここでは、ctrl_c.pyとする)を用意し、メインプログラムでPopenしたプロセスのコンソールにアタッチしてCTRL_C_EVENTを送信する。 3つのソースコードは下記の通り。

【main.py】

import subprocess
import sys
import time

cmd = ["python", "sub.py"]
# CREATE_NEW_CONSOLEフラグは必須
proc = subprocess.Popen(cmd, creationflags=subprocess.CREATE_NEW_CONSOLE)

time.sleep(2)
# プロセスのPIDをコマンドライン引数として引き渡し
subprocess.check_call([sys.executable, "ctrl_c.py", str(proc.pid)])

# プロセスのKILL後に別の処理
while True:
    time.sleep(1)
    print("main処理")

【sub.py】

import time

try:
    # 30秒で終了するプログラム
    for i in range(60):
        print(f"loop{i+1}")
        time.sleep(1)
except KeyboardInterrupt:
    print("KeyboardInterrupt発火")
    # 別コンソールになるため、結果を確認できるように標準入力でブロッキング
    input()

【ctrl_c.py】

import ctypes
import sys

kernel = ctypes.windll.kernel32

pid = int(sys.argv[1])
kernel.FreeConsole()
# コンソールのアタッチ
kernel.AttachConsole(pid)
kernel.SetConsoleCtrlHandler(None, 1)
# CTRL_C_EVENTの送信
kernel.GenerateConsoleCtrlEvent(0, 0)
sys.exit(0)

main.pyの実行により、PopenしたプロセスでKeyboardInterruptの発火を確認し、かつメイン処理も走り続けることが確認できた。 なお、ctrl_c.pyのプログラムについては、multiprocessing.Process()等で動作させることも可能なようだ。(回答へのコメント)

扱っていないSetConsoleCtrlHandler()等の話を含めた解説は、下記回答が非常に参考になる。

stackoverflow.com

むすび

Linuxと比べて実装が非常にややこしくなるが、実現可能であることが確認できた。カーネル周りの話は本当に入り組んでるね。。。