diff --git a/api/crash_visibility.py b/api/crash_visibility.py index 85ecfb20..46e54812 100644 --- a/api/crash_visibility.py +++ b/api/crash_visibility.py @@ -268,8 +268,11 @@ def install_crash_visibility( sigusr1 = getattr(signal, "SIGUSR1", None) if sigusr1 is not None: try: + # A diagnostic signal must not inherit SIGUSR1's default + # terminate action after the dump. Chaining here made a + # hang probe kill the server and trigger service restarts. faulthandler.register( - sigusr1, file=_FAULT_STREAM, all_threads=True, chain=True + sigusr1, file=_FAULT_STREAM, all_threads=True, chain=False ) except Exception: pass diff --git a/tests/test_issue4633_crash_visibility.py b/tests/test_issue4633_crash_visibility.py index c7767f91..8fa318fe 100644 --- a/tests/test_issue4633_crash_visibility.py +++ b/tests/test_issue4633_crash_visibility.py @@ -23,8 +23,12 @@ No live server socket is required — the helpers are exercised directly. """ import faulthandler import logging +import os +import signal +import subprocess import sys import threading +from pathlib import Path import pytest @@ -122,6 +126,41 @@ def test_install_is_idempotent(fresh_hooks): assert cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False) is False +@pytest.mark.skipif(not hasattr(signal, "SIGUSR1"), reason="SIGUSR1 is POSIX-only") +def test_sigusr1_stack_dump_does_not_terminate_process(): + """The diagnostic signal must dump stacks without chaining to SIG_DFL. + + Chaining invokes SIGUSR1's default action after faulthandler writes the dump, + terminating the WebUI process and prompting service managers to restart it. + Exercise the real signal in a child so a regression cannot kill pytest. + """ + script = """ +import os +import signal +from api.crash_visibility import install_crash_visibility + +# An ignored SIGUSR1 disposition survives exec on POSIX. Force the production +# default so chain=True would terminate this child after writing the dump. +signal.signal(signal.SIGUSR1, signal.SIG_DFL) +install_crash_visibility() +os.kill(os.getpid(), signal.SIGUSR1) +print('survived-sigusr1', flush=True) +""" + completed = subprocess.run( + [sys.executable, "-c", script], + cwd=os.fspath(Path(__file__).resolve().parents[1]), + capture_output=True, + text=True, + timeout=10, + check=False, + ) + + assert completed.returncode == 0, completed.stderr + assert "survived-sigusr1" in completed.stdout + assert "Current thread" in completed.stderr + assert "most recent call first" in completed.stderr + + # ── 2. threading.excepthook ───────────────────────────────────────────────── def test_thread_excepthook_installed(fresh_hooks): stream, _ = fresh_hooks