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

1"""Stop signals and the thread dump — what a real `fm` run wires up. 

2 

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. 

6 

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. 

15 

16The event and its binding are separate. There is one internal notion, "stop 

17requested", and only the binding is per-platform: 

18 

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) 

23 

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. 

28 

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. 

38 

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""" 

43 

44from __future__ import annotations 

45 

46import contextlib 

47import os 

48import signal 

49import sys 

50from collections.abc import Callable, Generator 

51from types import FrameType 

52from typing import Any 

53 

54from footman import _paths 

55 

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 

62 

63# Brand-scoped through `_paths`: `FOOTMAN_STACKS_AFTER` for stock footman, 

64# `ACME_STACKS_AFTER` for a branded CLI. 

65STACKS_AFTER = "STACKS_AFTER" 

66 

67 

68class Stop(BaseException): 

69 """A stop was asked for by signal. 

70 

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 """ 

76 

77 def __init__(self, word: str, code: int) -> None: 

78 super().__init__(word) 

79 self.word = word 

80 self.code = code 

81 

82 

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 ) 

93 

94 

95# Latched for the run: the first stop wins and later ones are ignored. 

96_stopping = False 

97 

98 

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) 

111 

112 return stop 

113 

114 

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 

131 

132 

133def _arm_dump() -> bool: 

134 """Wire the stack dump: the key where there is one, a timer either way. 

135 

136 Returns whether the timer was armed, so the way out cancels only a timer 

137 this run started. 

138 """ 

139 import faulthandler 

140 

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 

155 

156 

157def _disarm_dump(timer: bool) -> None: 

158 import faulthandler 

159 

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) 

166 

167 

168@contextlib.contextmanager 

169def installed() -> Generator[None]: 

170 """Wire the signals for one run, and put back what was there before. 

171 

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)