Python自体がクラッシュしてしまう場合のデバッグの仕方

TL;DR

faulthandler.enable() を使おう。 これを実行した部分以降は、Python自体がクラッシュするような状況(=シグナルが飛んできた など) になると、 スタックトレースが標準エラーに出力された上でクラッシュするようになる。

背景

macOSで requests.getを使うスクリプトをデーモン化したところ、何も出力せずにクラッシュするようになって困った。 タチの悪いことに、このクラッシュはデーモン化した時だけに起こる。

(環境は Python 3.11, macOS 12.6.5)

from daemon import DaemonContext
from daemon.pidfile import PIDLockFile
import requests
import time


pid = PIDLockFile(path.join(home_dir, 'pidfile_watcher'))
fp  = open("out.log", "a+")
fpe = open("err.log", "a+")

with DaemonContext(
  pidfile=pid,
  stdout=fp,
  stderr=fpe
):
  while True:
    response = requests.get("https://example.com");
    print(response.text)
    time.sleep(60*10)

fp.close()
fpe.close()

printをあっちゃこっちゃに仕込んだり”コンソール.app”を起動したりして1時間ほど調べまくった結果、 どうやらrequests.get()Segmentation Fault していそうだということがわかった。

なぜそんなに時間がかかったのか

DaemonContextに標準エラーの向き先を渡しているにもかかわらず、 Python自体がクラッシュするような状況ではログが全く出力されなかったからである。
また、コンソール.appで見つけたクラッシュレポートは、自分でプログラムの全てを書いていない限り、 あまり役に立たない。ここで「全て」とは、Pythonスクリプトに加えて使ったパッケージやPythonそのものも含まれてくる。
クラッシュレポートは文字通り全てのコールスタックを出力する (= 情報が多すぎる) ので、 よほどPythonそのものに関わっていなければ、何がどれに対応するのか解読するのは難しいだろう。

この時点では自分に残されたデバッグ方法は print を仕込みまくることだけだった。 出力されなくなったprintのタイミングがクラッシュのタイミングだ。
手動で二分法的に調査するなど多少の工夫はできるだろうが、 最初自分はDaemonContextstdoutにファイルを指定しておらず、printの出力がロストするような状況だったため、 結構面倒だった。

解決法

faulthandler.enable() が正解だった。

https://docs.python.org/3/library/faulthandler.html

faulthandler — Dump the Python traceback

This module contains functions to dump Python tracebacks explicitly, on a fault, after a timeout, or on a user signal. Call faulthandler.enable() to install fault handlers for the SIGSEGV, SIGFPE, ...

この関数を呼び出した後は、シグナルが飛んできた場合などのPython自体が異常終了する状況になった時、 そのままクラッシュする前にスタックトレースを標準エラーに流してくれる。 (例えばmacOSでのSegmentation faultはSIGSEGVが飛ぶため、この関数の機能が発動する。)
この関数で、確実に「どこでクラッシュしたのか」を掴むことができた。

ちなみに

先ほどのスクリプトのクラッシュはmacOSにおいて不可避らしい。

https://github.com/python/cpython/issues/74570

Segfault on OSX with 3.6.1 · Issue #74570 · python/cpython

BPO 30385 Nosy @ronaldoussoren, @ned-deily, @Birne94, @PythonCoderAS Superseder bpo-24273: _scproxy.so causes EXC_BAD_ACCESS (SIGSEGV) Files request_multiprocessing_crash.py: crashing program Note:...

回避するには、プロキシを使えなくする必要がある。 どうやら、macOSのプロキシの設定を取得する機能が 「フォークに対して安全ではなく」、segmentaion faultを起こしてしまうらしい。

プロキシを使わないようにするには、環境変数 no_proxy* を設定すれば良い。

import os
os.environ['no_proxy'] = "*"