From 78a371249dfa6c808a260c923eb499ed097cda1a Mon Sep 17 00:00:00 2001 From: David Leong <116610336+leongdl@users.noreply.github.com> Date: Thu, 3 Sep 2026 14:55:21 -0700 Subject: [PATCH] test: Make the "Trapped" signal handler signal-safe `test_notify_ends_process` fails intermittently. Seen on `Python (macos-latest, 3.13)` while every other leg of the same matrix passed on the same commit, including all six Windows legs and macOS 3.14: RuntimeError: reentrant call inside <_io.BufferedWriter name=''> Process pid 2878 exited with code: 1 assert 'Trapped' in [...] The four `app_20s_run*.py` support scripts print and flush stdout in their main loop, and their signal handler does the same. When the signal lands while the interpreter is inside the loop's `sys.stdout.flush()`, the handler re-enters the same `BufferedWriter`, CPython raises, "Trapped" never reaches stdout, and the child exits non-zero -- so the test's `assert "Trapped" in all_messages` fails. The handler now writes with `os.write(1, ...)`, which is unbuffered and cannot re-enter. Ordering is unaffected: every `print` in these scripts is flushed on the next line, so nothing is sitting in the buffer for the handler's output to appear ahead of. Reproduced deterministically rather than by waiting for the flake. Giving the child a pipe nobody drains parks its main loop inside a blocked `sys.stdout.flush()`, after which any SIGTERM lands inside the flush. Against the current scripts that yields exit 1, the reentrant `RuntimeError`, and no "Trapped" -- matching CI. Against the patched scripts, "Trapped" is present and there is no `RuntimeError`. All four copies are fixed, not just the one CI happened to hit: `app_20s_run.py` and `app_20s_run_ignore_signal.py` under both `sessions_v0` and `sessions_v1` were byte-identical in this handler. An earlier attempt at a reproduction found a *different* race -- SIGTERM arriving before `signal.signal()` had run, giving rc=-15 with no handler at all. That one is not reachable from this test, which waits for "Log from test 0" before calling `notify()`, so it is deliberately not addressed here. test_subprocess.py, test_runner_base.py and test_wrap_cancelation.py give an identical result before and after (6 failed, 189 passed); those 6 are pre-existing gracetime failures on a machine with no bare `python` on PATH. Signed-off-by: David Leong <116610336+leongdl@users.noreply.github.com> --- test/openjd/sessions_v0/support_files/app_20s_run.py | 11 +++++++++-- .../support_files/app_20s_run_ignore_signal.py | 11 +++++++++-- test/openjd/sessions_v1/support_files/app_20s_run.py | 11 +++++++++-- .../support_files/app_20s_run_ignore_signal.py | 11 +++++++++-- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/test/openjd/sessions_v0/support_files/app_20s_run.py b/test/openjd/sessions_v0/support_files/app_20s_run.py index e384616d..e7005c78 100644 --- a/test/openjd/sessions_v0/support_files/app_20s_run.py +++ b/test/openjd/sessions_v0/support_files/app_20s_run.py @@ -7,14 +7,21 @@ # Hook SIGTERM (posix) or CTRL_BREAK_EVENT (windows) and print "Trapped" # and exit if we get the signal +import os import signal import sys import time def hook(handle, frame): - print("Trapped") - sys.stdout.flush() + # os.write(2) rather than print() + flush(): a signal handler can interrupt + # the main loop *inside* sys.stdout.flush(), and re-entering the same + # BufferedWriter raises + # RuntimeError: reentrant call inside <_io.BufferedWriter name=''> + # which loses this message and exits non-zero. write(2) is unbuffered, so it + # cannot re-enter. Every print here is flushed immediately, so nothing is + # sitting in the buffer for this to appear out of order with. + os.write(1, b"Trapped\n") sys.exit(1) diff --git a/test/openjd/sessions_v0/support_files/app_20s_run_ignore_signal.py b/test/openjd/sessions_v0/support_files/app_20s_run_ignore_signal.py index 111421e6..f263cb85 100644 --- a/test/openjd/sessions_v0/support_files/app_20s_run_ignore_signal.py +++ b/test/openjd/sessions_v0/support_files/app_20s_run_ignore_signal.py @@ -2,14 +2,21 @@ # As app_20s_run.py except it does not exit when it gets a SIGTERM/SIGBREAK +import os import signal import sys import time def hook(handle, frame): - print("Trapped") - sys.stdout.flush() + # os.write(2) rather than print() + flush(): a signal handler can interrupt + # the main loop *inside* sys.stdout.flush(), and re-entering the same + # BufferedWriter raises + # RuntimeError: reentrant call inside <_io.BufferedWriter name=''> + # which loses this message and exits non-zero. write(2) is unbuffered, so it + # cannot re-enter. Every print here is flushed immediately, so nothing is + # sitting in the buffer for this to appear out of order with. + os.write(1, b"Trapped\n") if sys.platform.startswith("win"): diff --git a/test/openjd/sessions_v1/support_files/app_20s_run.py b/test/openjd/sessions_v1/support_files/app_20s_run.py index e384616d..e7005c78 100644 --- a/test/openjd/sessions_v1/support_files/app_20s_run.py +++ b/test/openjd/sessions_v1/support_files/app_20s_run.py @@ -7,14 +7,21 @@ # Hook SIGTERM (posix) or CTRL_BREAK_EVENT (windows) and print "Trapped" # and exit if we get the signal +import os import signal import sys import time def hook(handle, frame): - print("Trapped") - sys.stdout.flush() + # os.write(2) rather than print() + flush(): a signal handler can interrupt + # the main loop *inside* sys.stdout.flush(), and re-entering the same + # BufferedWriter raises + # RuntimeError: reentrant call inside <_io.BufferedWriter name=''> + # which loses this message and exits non-zero. write(2) is unbuffered, so it + # cannot re-enter. Every print here is flushed immediately, so nothing is + # sitting in the buffer for this to appear out of order with. + os.write(1, b"Trapped\n") sys.exit(1) diff --git a/test/openjd/sessions_v1/support_files/app_20s_run_ignore_signal.py b/test/openjd/sessions_v1/support_files/app_20s_run_ignore_signal.py index 111421e6..f263cb85 100644 --- a/test/openjd/sessions_v1/support_files/app_20s_run_ignore_signal.py +++ b/test/openjd/sessions_v1/support_files/app_20s_run_ignore_signal.py @@ -2,14 +2,21 @@ # As app_20s_run.py except it does not exit when it gets a SIGTERM/SIGBREAK +import os import signal import sys import time def hook(handle, frame): - print("Trapped") - sys.stdout.flush() + # os.write(2) rather than print() + flush(): a signal handler can interrupt + # the main loop *inside* sys.stdout.flush(), and re-entering the same + # BufferedWriter raises + # RuntimeError: reentrant call inside <_io.BufferedWriter name=''> + # which loses this message and exits non-zero. write(2) is unbuffered, so it + # cannot re-enter. Every print here is flushed immediately, so nothing is + # sitting in the buffer for this to appear out of order with. + os.write(1, b"Trapped\n") if sys.platform.startswith("win"):