294 lines
11 KiB
Python
294 lines
11 KiB
Python
"""Regression coverage for issue #4633: crash visibility hardening.
|
|
|
|
server.py was exiting SILENTLY after 9-16h — no traceback, no shutdown-audit
|
|
line, no core dump; the log just stopped mid-request. It runs a
|
|
ThreadingHTTPServer with daemon_threads=True, so an unhandled exception in a
|
|
request/SSE handler thread could terminate work with nothing recorded, and
|
|
faulthandler was not enabled so a native crash left nothing at all.
|
|
|
|
These tests assert the diagnostic hardening in api/crash_visibility.py (wired
|
|
into server.main()):
|
|
|
|
* faulthandler is enabled after install (native crash → C-level traceback);
|
|
* threading.excepthook is installed and a real daemon-thread exception is
|
|
logged (thread name + traceback) instead of vanishing;
|
|
* sys.excepthook is installed and logs uncaught main-thread exceptions;
|
|
* an atexit exit-audit breadcrumb is registered;
|
|
* server.py wires install_crash_visibility() into main().
|
|
|
|
The paired MEMORY root-cause is fixed separately in #4765; this is purely
|
|
crash VISIBILITY.
|
|
|
|
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
|
|
|
|
import api.crash_visibility as cv
|
|
|
|
|
|
@pytest.fixture
|
|
def fresh_hooks(tmp_path):
|
|
"""Reset crash-visibility global state and restore process hooks after.
|
|
|
|
Installing crash visibility mutates the process-wide threading.excepthook,
|
|
sys.excepthook and faulthandler state, so snapshot and restore them to keep
|
|
the rest of the suite hermetic.
|
|
"""
|
|
saved_thread_hook = threading.excepthook
|
|
saved_sys_hook = sys.excepthook
|
|
saved_installed = cv._INSTALLED
|
|
saved_stream = cv._FAULT_STREAM
|
|
saved_prev_thread = cv._PREV_THREAD_HOOK
|
|
saved_prev_sys = cv._PREV_SYS_HOOK
|
|
was_fh_enabled = faulthandler.is_enabled()
|
|
saved_atexit_register = cv.atexit.register
|
|
|
|
# Start from stock hooks so the module records the *stock* hook as "prev"
|
|
# and never chains into pytest's own thread-exception collector during the
|
|
# test (which would otherwise double-report and can fail the test).
|
|
threading.excepthook = threading.__excepthook__
|
|
sys.excepthook = sys.__excepthook__
|
|
cv._INSTALLED = False
|
|
cv._FAULT_STREAM = None
|
|
cv._PREV_THREAD_HOOK = None
|
|
cv._PREV_SYS_HOOK = None
|
|
# Prevent tests from leaking real atexit handlers into the pytest session:
|
|
# the fixture resets _INSTALLED each test, so every install() would register
|
|
# a fresh (real) _exit_audit that fires at interpreter shutdown. Capture
|
|
# them instead. The dedicated exit-audit test re-patches this itself.
|
|
cv.atexit.register = lambda fn: fn
|
|
|
|
stream_path = tmp_path / "faultstream.log"
|
|
stream = stream_path.open("w", encoding="utf-8")
|
|
try:
|
|
yield stream, stream_path
|
|
finally:
|
|
try:
|
|
stream.flush()
|
|
except Exception:
|
|
pass
|
|
# Detach faulthandler from the about-to-close temp file before closing.
|
|
try:
|
|
faulthandler.disable()
|
|
except Exception:
|
|
pass
|
|
try:
|
|
stream.close()
|
|
except Exception:
|
|
pass
|
|
if was_fh_enabled:
|
|
try:
|
|
faulthandler.enable()
|
|
except Exception:
|
|
pass
|
|
threading.excepthook = saved_thread_hook
|
|
sys.excepthook = saved_sys_hook
|
|
cv._INSTALLED = saved_installed
|
|
cv._FAULT_STREAM = saved_stream
|
|
cv._PREV_THREAD_HOOK = saved_prev_thread
|
|
cv._PREV_SYS_HOOK = saved_prev_sys
|
|
cv.atexit.register = saved_atexit_register
|
|
|
|
|
|
def _read(path):
|
|
try:
|
|
return path.read_text(encoding="utf-8")
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
# ── 1. faulthandler ─────────────────────────────────────────────────────────
|
|
def test_faulthandler_enabled_after_install(fresh_hooks):
|
|
stream, _ = fresh_hooks
|
|
|
|
installed = cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False)
|
|
|
|
assert installed is True
|
|
assert cv.is_installed() is True
|
|
# faulthandler must be active so a native segfault/abort dumps a C-level
|
|
# traceback of every thread instead of vanishing (the #4633 failure mode).
|
|
assert faulthandler.is_enabled() is True
|
|
|
|
|
|
def test_install_is_idempotent(fresh_hooks):
|
|
stream, _ = fresh_hooks
|
|
assert cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False) is True
|
|
# Second call must be a no-op (no stacked hooks / duplicate atexit handlers).
|
|
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
|
|
cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False)
|
|
assert threading.excepthook is cv.thread_excepthook
|
|
|
|
|
|
def test_daemon_thread_exception_is_logged(fresh_hooks, caplog):
|
|
stream, stream_path = fresh_hooks
|
|
cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False)
|
|
|
|
def _boom():
|
|
raise ValueError("simulated SSE handler crash 4633")
|
|
|
|
caplog.set_level(logging.ERROR, logger="server")
|
|
|
|
t = threading.Thread(target=_boom, name="sse-handler-42", daemon=True)
|
|
t.start()
|
|
t.join(timeout=5)
|
|
assert not t.is_alive()
|
|
|
|
stream.flush()
|
|
written = _read(stream_path)
|
|
logged = "\n".join(r.getMessage() for r in caplog.records)
|
|
|
|
# Diagnostic must land in the fault stream (which the bootstrap redirects
|
|
# into the WebUI log file) AND be visible via the logging subsystem.
|
|
for haystack in (written, logged):
|
|
assert "[crash-visibility] uncaught exception in thread" in haystack
|
|
assert "sse-handler-42" in haystack
|
|
# The stream copy carries the full traceback + the original exception text.
|
|
assert "ValueError" in written
|
|
assert "simulated SSE handler crash 4633" in written
|
|
|
|
|
|
def test_thread_excepthook_ignores_clean_exit_and_never_raises(fresh_hooks):
|
|
stream, stream_path = fresh_hooks
|
|
cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False)
|
|
|
|
# A normal thread teardown passes exc_type=None; must produce nothing and
|
|
# must not raise.
|
|
Args = type(
|
|
"Args",
|
|
(),
|
|
{"exc_type": None, "exc_value": None, "exc_traceback": None, "thread": None},
|
|
)
|
|
cv.thread_excepthook(Args())
|
|
|
|
# Wholly malformed args must also be swallowed without raising.
|
|
cv.thread_excepthook(object())
|
|
|
|
stream.flush()
|
|
assert "uncaught exception in thread" not in _read(stream_path)
|
|
|
|
|
|
# ── 3. sys.excepthook ───────────────────────────────────────────────────────
|
|
def test_sys_excepthook_installed(fresh_hooks):
|
|
stream, _ = fresh_hooks
|
|
cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False)
|
|
assert sys.excepthook is cv.main_excepthook
|
|
|
|
|
|
def test_main_thread_exception_is_logged(fresh_hooks, caplog):
|
|
stream, stream_path = fresh_hooks
|
|
cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False)
|
|
|
|
caplog.set_level(logging.CRITICAL, logger="server")
|
|
try:
|
|
raise RuntimeError("simulated main-thread fatal 4633")
|
|
except RuntimeError:
|
|
cv.main_excepthook(*sys.exc_info())
|
|
|
|
stream.flush()
|
|
written = _read(stream_path)
|
|
logged = "\n".join(r.getMessage() for r in caplog.records)
|
|
|
|
for haystack in (written, logged):
|
|
assert "[crash-visibility] uncaught exception on main thread" in haystack
|
|
assert "RuntimeError" in written
|
|
assert "simulated main-thread fatal 4633" in written
|
|
|
|
|
|
def test_keyboard_interrupt_not_treated_as_crash(fresh_hooks, caplog):
|
|
stream, stream_path = fresh_hooks
|
|
cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False)
|
|
|
|
caplog.set_level(logging.DEBUG, logger="server")
|
|
try:
|
|
raise KeyboardInterrupt()
|
|
except KeyboardInterrupt:
|
|
cv.main_excepthook(*sys.exc_info())
|
|
|
|
stream.flush()
|
|
# Ctrl-C is intentional; it must not be logged as an uncaught crash.
|
|
assert "uncaught exception on main thread" not in _read(stream_path)
|
|
assert all("crash-visibility" not in r.getMessage() for r in caplog.records)
|
|
|
|
|
|
# ── 4. exit audit ───────────────────────────────────────────────────────────
|
|
def test_exit_audit_registered_and_emits(fresh_hooks, monkeypatch, caplog):
|
|
stream, stream_path = fresh_hooks
|
|
|
|
registered = []
|
|
monkeypatch.setattr(cv.atexit, "register", lambda fn: registered.append(fn) or fn)
|
|
|
|
cv.install_crash_visibility(stream=stream, register_sigusr1_dump=False)
|
|
assert cv._exit_audit in registered
|
|
|
|
caplog.set_level(logging.INFO, logger="server")
|
|
cv._exit_audit() # simulate interpreter exit unwinding
|
|
|
|
stream.flush()
|
|
written = _read(stream_path)
|
|
logged = "\n".join(r.getMessage() for r in caplog.records)
|
|
for haystack in (written, logged):
|
|
assert "[crash-visibility] process exit" in haystack
|
|
|
|
|
|
# ── 5. server.py wiring ─────────────────────────────────────────────────────
|
|
def test_server_imports_and_calls_install(fresh_hooks):
|
|
import server
|
|
|
|
assert hasattr(server, "install_crash_visibility")
|
|
assert server.install_crash_visibility is cv.install_crash_visibility
|
|
|
|
from tests.conftest import SERVER_SCRIPT
|
|
|
|
src = SERVER_SCRIPT.read_text(encoding="utf-8")
|
|
assert "from api.crash_visibility import install_crash_visibility" in src
|
|
assert "install_crash_visibility()" in src
|