Coverage for src/footman/_signals.py: 99%
75 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-06 02:59 +0000
1"""Stop signals and the thread dump — what a real `fm` run wires up.
3Two jobs share a file because both are about what arrives on a signal, and
4both are installed at one point: the top of `_app.run`, past the completion
5hot path, which must stay stdlib-only and import-free of the framework.
7**Stopping.** A supervisor asks for a stop with a signal, and the senders are
8ordinary: `timeout`, `docker stop`, `kill`, a cancelled CI job, systemd, k8s.
9Left at its default disposition SIGTERM kills footman where it stands — no
10receipt, no `--json` envelope, and every child orphaned, because a spawned
11child leads its own process group precisely so the terminal's signals do
12*not* reach it. So a stop signal raises `Stop` in the main thread, which
13unwinds to the same place Ctrl-C does and inherits the reap, the receipt and
14the envelope by construction; only the word and the exit code differ.
16The event and its binding are separate. There is one internal notion, "stop
17requested", and only the binding is per-platform:
19 POSIX SIGTERM -> 143, SIGHUP -> 129
20 Windows SIGBREAK -> 143 (CTRL_BREAK_EVENT, what a cancelled CI job
21 sends; the SIGTERM constant there only ever arrives through
22 TerminateProcess, which no program in any language can catch)
24The codes are footman's contract rather than the OS's, so they are the same
25number everywhere — 130 for Ctrl-C already is, despite Windows having no
26128+N convention, and special-casing a platform would be the leak. SIGKILL
27and `taskkill /F` stay uncatchable; nothing here pretends otherwise.
29**The dump.** SIGQUIT (Ctrl-\\) writes every thread's stack to stderr and lets
30the run carry on, the convention the JVM and Go already taught. footman is a
31thread-pool runner and hangs are its recurring bug class, so pressing it
32twice and watching whether the frames moved is how you tell a deadlock from
33slow progress. It is diagnostic, not a shutdown, and stays out of the stop
34set. Windows has no SIGQUIT and both of its catchable signals are spoken for,
35so the same dump is also armed on a timer by `FOOTMAN_STACKS_AFTER` — and
36nobody is at a keyboard when CI hangs anyway, so the platform missing the key
37gets the form that actually helps there.
39`faulthandler` is a builtin module (no file I/O to import) and writes to
40stderr, which stays correct under `--json` because stdout carries the
41document.
42"""
44from __future__ import annotations
46import contextlib
47import os
48import signal
49import sys
50from collections.abc import Callable, Generator
51from types import FrameType
52from typing import Any
54from footman import _paths
56# 128 + the signal's number, which is what a shell prints for a process the
57# OS killed — the same number, reached by exiting rather than by dying, so a
58# `timeout`-wrapped `fm` reads as it always did while the run still gets to
59# clean up on the way out.
60TERMINATED = 143
61HUNG_UP = 129
63# Brand-scoped through `_paths`: `FOOTMAN_STACKS_AFTER` for stock footman,
64# `ACME_STACKS_AFTER` for a branded CLI.
65STACKS_AFTER = "STACKS_AFTER"
68class Stop(BaseException):
69 """A stop was asked for by signal.
71 A `BaseException`, like `KeyboardInterrupt`: the `except BaseException`
72 arms in the scheduler and the fan-out are what reap the in-flight
73 process trees, and a task body's `except Exception` must no more swallow
74 a supervisor's stop than it swallows Ctrl-C.
75 """
77 def __init__(self, word: str, code: int) -> None:
78 super().__init__(word)
79 self.word = word
80 self.code = code
83def _bindings() -> tuple[tuple[int, str, int], ...]:
84 """Which signals mean "stop" here, and what each one exits as."""
85 if sys.platform == "win32":
86 # Only SIGBREAK: binding the Windows SIGTERM constant would install a
87 # handler for something that never arrives.
88 return ((signal.SIGBREAK, "terminated", TERMINATED),)
89 return (
90 (signal.SIGTERM, "terminated", TERMINATED),
91 (signal.SIGHUP, "hung up", HUNG_UP),
92 )
95# Latched for the run: the first stop wins and later ones are ignored.
96_stopping = False
99def _handler(word: str, code: int) -> Callable[[int, FrameType | None], None]:
100 def stop(_signum: int, _frame: FrameType | None) -> None:
101 global _stopping
102 # A supervisor often sends more than one — a cancelled GitHub job
103 # sends SIGINT and then SIGTERM — and a second raise landing inside
104 # the reap would abandon it half done, orphaning exactly the trees
105 # this exists to collect. The shutdown is bounded by the kill grace,
106 # and the supervisor's SIGKILL is the real backstop.
107 if _stopping:
108 return
109 _stopping = True
110 raise Stop(word, code)
112 return stop
115def _seconds_from_env() -> float | None:
116 """How long to wait before dumping stacks, or None when nobody asked."""
117 name = _paths.env_var(STACKS_AFTER)
118 raw = os.environ.get(name, "").strip()
119 if not raw:
120 return None
121 try:
122 seconds = float(raw)
123 except ValueError:
124 seconds = 0.0
125 if seconds <= 0:
126 # Somebody reaching for this is already staring at a hang; a typo
127 # that quietly does nothing would cost them the next hour.
128 sys.stderr.write(f"{name}={raw}: expected seconds, so no stacks are timed\n")
129 return None
130 return seconds
133def _arm_dump() -> bool:
134 """Wire the stack dump: the key where there is one, a timer either way.
136 Returns whether the timer was armed, so the way out cancels only a timer
137 this run started.
138 """
139 import faulthandler
141 if sys.platform != "win32":
142 # `chain=False` and no exit: dump and keep running, so pressing it
143 # again shows whether the frames moved. A stderr that pytest replaced
144 # with a buffer has no file descriptor to write to, which is a reason
145 # to skip the dump and no reason at all to fail the run.
146 with contextlib.suppress(AttributeError, ValueError, OSError, RuntimeError):
147 faulthandler.register(
148 signal.SIGQUIT, file=sys.stderr, all_threads=True, chain=False
149 )
150 seconds = _seconds_from_env()
151 if seconds is None:
152 return False
153 faulthandler.dump_traceback_later(seconds, repeat=True, file=sys.stderr, exit=False)
154 return True
157def _disarm_dump(timer: bool) -> None:
158 import faulthandler
160 if timer:
161 faulthandler.cancel_dump_traceback_later()
162 if sys.platform != "win32":
163 # `unregister` puts back whatever handler `register` displaced.
164 with contextlib.suppress(AttributeError, ValueError, OSError, RuntimeError):
165 faulthandler.unregister(signal.SIGQUIT)
168@contextlib.contextmanager
169def installed() -> Generator[None]:
170 """Wire the signals for one run, and put back what was there before.
172 Restoring matters because `run()` is a library call as much as an entry
173 point: an embedding program keeps its own handlers, and a suite that ran
174 a hundred invocations leaves the process as it found it. A nested run
175 (a task body driving a `Runner`) is on a worker thread, where
176 `signal.signal` refuses outright — it installs nothing and the outer
177 run's wiring keeps standing, which is the right answer anyway.
178 """
179 global _stopping
180 _stopping = False
181 timer = False
182 previous: list[tuple[int, Any]] = []
183 try:
184 for signum, word, code in _bindings():
185 with contextlib.suppress(ValueError, OSError):
186 previous.append((signum, signal.signal(signum, _handler(word, code))))
187 timer = _arm_dump()
188 yield
189 finally:
190 _disarm_dump(timer)
191 for signum, handler in reversed(previous):
192 # `None` is what Python reports for a handler installed from C,
193 # which it cannot put back — better left alone than crashed on.
194 if handler is not None: 194 ↛ 191line 194 didn't jump to line 191 because the condition on line 194 was always true
195 with contextlib.suppress(ValueError, OSError):
196 signal.signal(signum, handler)