WindowsではCtrl-cでPythonのスクリプトを即時中断できないことがある

CPythonの既知の不具合に遭遇した。処理系本体ではなく、標準ライブラリの問題のようだ。

界隈では有名な話かもしれないが、忘れないように個人的なメモを残しておく。

具体的には、標準ライブラリのsocketを使用して自前でUDPパケットを受信する、以下のようなスクリプトで遭遇した。

#!/usr/bin/env python3

import socket

BIND_ADDR = '127.0.0.1'
BIND_PORT = 41214

try:
    for ai in socket.getaddrinfo(BIND_ADDR, BIND_PORT, socket.AF_UNSPEC,
                                 socket.SOCK_DGRAM):
        family, socktype, protocol, _, sockaddr = ai
        with socket.socket(family, socktype, protocol) as sock:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.bind(sockaddr)

            print(f'listening on {sockaddr[0]}:{sockaddr[1]}')

            while True:
                data, addr = sock.recvfrom(65535)
                print(f'receive from {addr[0]}:{addr[1]} : {repr(data)}')
except KeyboardInterrupt:
    print('KeyboardInterrupt')

このスクリプトをコンソールで実行した時、macOS 11やUbuntu 20.04のPython 3.8と、Raspberry Pi OS bullseyeのPython 3.9では、Ctrl-cで終了した。しかしMicrosoft StoreからインストールしたPython 3.10 (amd64) では、Ctrl-cを入力しても即座には終了しなかった。

(1年前に、このスクリプトと同等の処理をCygwinPython 3.6で実行した際には、正しくCtrl-cで終了した記憶がある。なので、純粋なWindows版ビルドで発生する現象だと思う)

実験した感じでは、socket.recvfrom()ブロッキングしている最中にCtrl-cが入力された場合、入力された時点では何も起きない。Ctrl-c入力後、パケットを受信してsocket.recvfrom()によるブロッキングが解除された時点で、ようやくKeyboardInterruptが発生するようだ。

この挙動は既知の不具合のようで、GitHubのCPythonのリポジトリSIGINT blocked by socket operations like recv on Windows #85609というIssueがあった。Winsockの仕様(実装?)に起因する制約のようだ。*1

Winsock絡みということなので、今回はMicrosoft Store版でしか挙動を確認していないが、おそらく公式サイトで配布されているWindows版バイナリでも同様の症状が見られるのではないかと思う。

この問題の回避策の1つは、タイムアウトモードないし非ブロッキングモードのソケットを使うことだ。常にブロッキングさせるのではなく、例えば100ミリ秒から500ミリ秒ぐらいの短い周期でブロッキングが解除されるようにしておけば、解除された時にCtrl-cの入力に起因するKeyboardInterruptが発生して、スクリプトが停止する。

今回、問題に遭遇したスクリプトでは、お手軽なのはsocket.settimeout()を使用してタイムアウトモードを使う方法だった。

#!/usr/bin/env python3

import socket

BIND_ADDR = '127.0.0.1'
BIND_PORT = 41214

try:
    for ai in socket.getaddrinfo(BIND_ADDR, BIND_PORT, socket.AF_UNSPEC,
                                 socket.SOCK_DGRAM):
        family, socktype, protocol, _, sockaddr = ai
        with socket.socket(family, socktype, protocol) as sock:
            sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
            sock.bind(sockaddr)

            # Workaround to handle keyboard interrupt on Windows.
            sock.settimeout(0.2)

            print(f'listening on {sockaddr[0]}:{sockaddr[1]}')

            while True:
                try:
                    data, addr = sock.recvfrom(65535)
                except socket.timeout:
                    continue
                print(f'receive from {addr[0]}:{addr[1]} : {repr(data)}')
except KeyboardInterrupt:
    print('KeyboardInterrupt')

もう1つの回避策は、いきなりsocket.recvfrom()TCPの場合はsocket.accept())を呼び出すのではなく、selectselectorsを使用して、ソケットが読み込み可能になるまで待ってから処理を行う、というものだ。

読み込み可能になるまで待つ周期を100ミリ秒から500ミリ秒ぐらいの短い時間にしておけば、タイムアウト後にCtrl-cに起因するKeyboardInterruptが発生して、スクリプトが停止する。

標準ライブラリのsocketserverではこの方法が採用されている。つまりserve_forever()の引数poll_intervalは、selectorsを使用してソケットが読み込み可能になるまで待つ周期だ。この引数に妙に大きな値を指定しなければ、Ctrl-c入力によるスクリプトの停止がいい感じに機能してくれる(引数poll_intervalのデフォルト値は0.5秒(500ミリ秒)だ)。

そういう意味でも、TCP/UDP通信のサーバ側を実装する際には、最初はsocketではなくsocketserverを使うことを検討しろ、ということなのかもしれない。

*1:このIssueには記載されていないが、WindowsにはPOSIXのシグナルがない(だからSIGINTなどの一部のシグナルのみをランタイム側でシミュレートしている)という事情も絡んでいるのかもしれない。