The Watchdog
A stall makes no sound
Volume 4 handed you a system: a registry of every subsystem, a core that takes its listener, brain and voice as arguments, a preflight check that refuses to start on a broken config, and a cable to a microcontroller that answers in twelve-byte frames. Every stage in that system is a blocking call. She waits for the microphone to fill a buffer, waits for the transcriber to return text, waits for the model to finish generating, waits for the board to reply. Waiting is the normal state of the pipeline.
So consider what happens when one of those waits never ends. A model that hangs on a half-loaded weight file, a socket whose other end went away without closing, a serial read on a port the kernel removed when someone bumped the cable. There is no exception, no traceback, no exit code, no log line. The process is alive, the thread is running, the prompt does not come back. Watch the terminal and a wedged assistant looks exactly like one thinking hard about a difficult question.
The first fix everyone reaches for is a timeout on each call, and it disappoints for three reasons. You have to guess a number per call, and the guess has to sit above the slowest healthy run of that stage or it fires on a model that was only being slow. It guards exactly the call you wrapped, so every new stage is a fresh unguarded place to freeze. And a timeout argument is a request to the library: plenty of native extensions block inside C code where no Python timer can reach them.
Embedded engineering settled this decades ago, on chips with no operating system to complain to. A small independent timer runs beside the program and must be fed at intervals; when the feeding stops, the timer resets the chip. That mechanism ports into Python with no hardware at all, and it carries the rule this volume opens with: liveness is proven continuously by the healthy path, never asked for by the worried one, and silence is the alarm. Each finished turn leaves a timestamp behind. A background thread compares that timestamp to the clock. Nothing asks the frozen stage a question, because a frozen stage cannot answer.
A heartbeat says the code reached a particular line recently. It says nothing about the quality of what came out. A transcriber that returns an empty string for every utterance will feed the watchdog forever, cheerfully, at full speed. Freezes and wrong answers are separate failure classes with separate detectors: the watchdog owns the first, and the output checks you already write own the second. The silence gate in chapter 13 that refuses to transcribe a near-empty buffer is that second kind, and the two together cover more ground than either alone.
One timestamp, one thread, one alarm
# labs/watchdog.py
import time
class Watchdog:
def __init__(self, name: str, timeout: float = 30.0) -> None:
self.name = name
self.timeout = timeout
self._last_beat = time.monotonic()
def heartbeat(self) -> None:
self._last_beat = time.monotonic()
def silence(self) -> float:
return time.monotonic() - self._last_beat
def is_alive(self) -> bool:
return self.silence() <= self.timeout
if __name__ == "__main__":
wd = Watchdog("llm", timeout=0.5)
print(f"fresh beat: silence={wd.silence():.2f}s alive={wd.is_alive()}")
time.sleep(0.6)
print(f"after 0.6s: silence={wd.silence():.2f}s alive={wd.is_alive()}")
$ uv run python -m labs.watchdog
fresh beat: silence=0.00s alive=True
after 0.6s: silence=0.60s alive=False
A boolean flag would need somebody to set it to false, and the one component that
knows it is stuck is the one component that has stopped executing. Storing the moment
of the last heartbeat turns the question into subtraction, and subtraction needs no
cooperation from anyone. Note the clock: time.monotonic() counts seconds
since an arbitrary point and only ever goes up, so a clock correction landing
mid-conversation cannot make the silence negative or push it past the limit. The
scheduler in chapter 19 chose that clock for the same reason. Your second figure may
read 0.60 or 0.61, since sleep promises a floor and not a ceiling.
import threading
from typing import Callable
class Watchdog:
def __init__(self, name: str, timeout: float = 30.0,
on_timeout: Callable[[str, float], None] | None = None,
poll: float = 0.1) -> None:
self.name = name
self.timeout = timeout
self.poll = poll
self.on_timeout = on_timeout or self._report
self._last_beat = time.monotonic()
self._running = False
self._thread: threading.Thread | None = None
def _report(self, name: str, silence: float) -> None:
print(f"[watchdog] {name} silent for {silence:.1f}s -- recovering")
def _watch(self) -> None:
while self._running:
silence = self.silence()
if silence > self.timeout:
self.on_timeout(self.name, silence)
self._last_beat = time.monotonic() # re-arm: one alarm per stall
time.sleep(self.poll)
def start(self) -> None:
self._running = True
self._thread = threading.Thread(
target=self._watch, name=f"watchdog-{self.name}", daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if __name__ == "__main__":
wd = Watchdog("llm", timeout=0.5)
wd.start()
time.sleep(0.75) # feed it nothing
wd.stop()
print("main thread done")
$ uv run python -m labs.watchdog
[watchdog] llm silent for 0.6s -- recovering
main thread done
Three decisions live in that small class. daemon=True means the watch
thread never keeps the process alive; when your main thread returns, Python tears the
watcher down instead of hanging on exit forever, and a supervisor that prevents
shutdown is its own kind of failure. The callback takes the name and the measured
silence, so a recovery routine can log which stage went quiet and for how long instead
of announcing that something, somewhere, stopped. And the assignment to
_last_beat immediately after firing is the load-bearing line of the whole
chapter: it re-arms the timer so a single stall produces a single alarm. Remove it and
you get the failure box below.
def fake_turn(wd: Watchdog) -> None:
"""Stand-in for capture, transcribe, think, speak."""
wd.heartbeat()
time.sleep(0.05)
if __name__ == "__main__":
stalls: list[tuple[str, float]] = []
wd = Watchdog("llm", timeout=0.5,
on_timeout=lambda name, s: stalls.append((name, s)))
wd.start()
for i in range(3):
fake_turn(wd)
print(f"turn {i + 1} answered")
time.sleep(0.2)
print(f"healthy run: {len(stalls)} stalls recorded")
time.sleep(1.05) # the stage wedges; no more heartbeats
wd.stop()
print(f"after the stall: {len(stalls)} stalls recorded")
for name, seconds in stalls:
print(f" {name} silent {seconds:.1f}s")
$ uv run python -m labs.watchdog
turn 1 answered
turn 2 answered
turn 3 answered
healthy run: 0 stalls recorded
after the stall: 2 stalls recorded
llm silent 0.6s
llm silent 0.5s
Both numbers are predictable, and predicting them is how you learn to size a timeout. During the healthy run a heartbeat lands every 0.25 seconds against a 0.5 second limit, so the silence never reaches the threshold and the count stays at zero. Then the feeding stops with 0.25 seconds already on the clock, and 1.05 more brings the total silence to 1.3. The first alarm fires just past 0.5, the re-arm restarts the window, the second fires just past 1.1, and a third would need 1.7 seconds that the run never gets to. Recording the events in a list instead of printing them makes the behaviour something a test can assert, and timing bugs that only appear under a stall are miserable to reproduce by hand. Your figures will land on 0.5 or 0.6 depending on where the poll falls.
Wiring it to something that can actually break
# labs/watchdog.py -- full file
import threading
import time
from typing import Callable
class Watchdog:
"""Fires on_timeout(name, silence) when heartbeats stop arriving."""
def __init__(self, name: str, timeout: float = 30.0,
on_timeout: Callable[[str, float], None] | None = None,
poll: float = 0.1) -> None:
self.name = name
self.timeout = timeout
self.poll = poll
self.on_timeout = on_timeout or self._report
self._last_beat = time.monotonic()
self._running = False
self._thread: threading.Thread | None = None
def _report(self, name: str, silence: float) -> None:
print(f"[watchdog] {name} silent for {silence:.1f}s -- recovering")
def heartbeat(self) -> None:
self._last_beat = time.monotonic()
def silence(self) -> float:
return time.monotonic() - self._last_beat
def is_alive(self) -> bool:
return self.silence() <= self.timeout
def _watch(self) -> None:
while self._running:
silence = self.silence()
if silence > self.timeout:
self.on_timeout(self.name, silence)
self._last_beat = time.monotonic() # re-arm: one alarm per stall
time.sleep(self.poll)
def start(self) -> None:
self._running = True
self._thread = threading.Thread(
target=self._watch, name=f"watchdog-{self.name}", daemon=True)
self._thread.start()
def stop(self) -> None:
self._running = False
if self._thread is not None:
self._thread.join(timeout=2 * self.poll)
self._thread = None
stop() is cooperative, and the join says so out loud. Setting
_running to false does not interrupt anything; the watch thread notices
on its next trip around the loop, which is at most one poll away, and the join waits
that long before giving up. A caller who wants the watcher provably finished before
closing a file gets it. Nobody who forgets to call stop() is punished,
because the daemon flag handles process exit anyway.
# labs/supervised_link.py
import threading
import time
import serial
from labs.hardware_link import PORT, command, open_link
from labs.watchdog import Watchdog
class SupervisedLink:
"""The serial link, plus a watchdog and a reopen path."""
def __init__(self, port: str = PORT, timeout: float = 5.0) -> None:
self.port = port
self.link = open_link(port)
self.needs_reopen = threading.Event()
self.watchdog = Watchdog("link", timeout=timeout, on_timeout=self._flag)
self.watchdog.start()
print(f"[link] {port} open")
def _flag(self, name: str, silence: float) -> None:
print(f"[watchdog] {name} silent for {silence:.1f}s")
self.needs_reopen.set()
def ping(self) -> None:
started = time.perf_counter()
try:
reply = command(self.link, {"command": "ping"})
except (serial.SerialException, ConnectionError) as exc:
print(f"[warn] link lost: {exc}")
return
if reply.get("status") == "ok":
self.watchdog.heartbeat()
print(f"ping ok ({(time.perf_counter() - started) * 1000:.0f} ms)")
def service(self) -> None:
if not self.needs_reopen.is_set():
return
print(f"[link] reopening {self.port}")
try:
self.link.close()
self.link = open_link(self.port)
except serial.SerialException as exc:
print(f"[link] reopen failed: {exc}")
return # flag stays set; try again next pass
self.needs_reopen.clear()
self.watchdog.heartbeat()
if __name__ == "__main__":
sup = SupervisedLink()
for _ in range(12):
sup.service()
sup.ping()
time.sleep(1.0)
sup.watchdog.stop()
$ uv run python -m labs.supervised_link
[link] /dev/ttyUSB0 open
ping ok (11 ms)
ping ok (10 ms)
ping ok (11 ms)
[warn] link lost: device reports readiness to read but returned no data
[warn] link lost: write failed: [Errno 5] Input/output error
[warn] link lost: write failed: [Errno 5] Input/output error
[warn] link lost: write failed: [Errno 5] Input/output error
[watchdog] link silent for 5.0s
[link] reopening /dev/ttyUSB0
[link] reopen failed: could not open port /dev/ttyUSB0: [Errno 2] No such file or directory
[warn] link lost: write failed: [Errno 5] Input/output error
[link] reopening /dev/ttyUSB0
ping ok (11 ms)
ping ok (11 ms)
Measured on the bench with an ESP32 devkit on /dev/ttyUSB0, the USB cable
pulled by hand after the third ping and pushed back in around the tenth second. Your
timings, your port name and your kernel's wording for a vanished device will all vary.
What should not vary is the sequence: three healthy pings, a run of errors while the
board is gone, exactly one watchdog line, a reopen that fails because the device node
no longer exists, and a later reopen that works.
The important design choice is that the callback does not touch the serial port; it
sets a threading.Event and returns. Recovery runs on the watch thread,
and that thread has no idea whether the main thread is halfway through writing a
frame, so closing the port underneath it would turn one clean failure into a corrupted
one. The watcher raises a flag, the thread that owns the port acts on it in
service(), and only the flag crosses between threads. Recovery also has
to survive failing: the early return leaves the event set, so a board that takes
twenty seconds to come back is retried once per pass with no extra machinery.
Why this works: supervision by absence
Every detector in this book so far asked a question and read an answer. The startup probes call each engine and check what comes back; the preflight check reads the config and reports what is missing. Both need a component healthy enough to reply, so neither can see a freeze. The watchdog inverts the direction of information: nothing is asked of the pipeline at all, the pipeline volunteers a timestamp when it is well, and the absence of that timestamp is the whole signal. A hung transcriber cannot suppress an alarm that never depended on the transcriber returning. The alarm depends on the clock advancing, and the clock always advances.
Two numbers control the behaviour, and they do different jobs. The timeout sets the
threshold and must sit above the slowest healthy run of the stage you are guarding: an
eight-second limit on a model that occasionally takes ten will restart a machine that
was working, and false alarms teach you to ignore real ones. Chapter 28's timing
decorator is where those figures come from; measure first, double the worst honest
reading, then set the timeout. The poll sets the resolution. A stall is noticed
somewhere between timeout and timeout + poll seconds after it
begins, which is why the recorded silences in stage 3 read 0.5 and 0.6 instead of a flat
0.5. Shrink the poll for faster detection at the cost of a few more wakeups per second;
a 0.1 second poll against a 30 second timeout is free.
Sharing _last_beat between two threads is safe here for a specific reason
worth understanding, not assuming. Both threads touch one attribute, one writes and one
reads, and a Python attribute assignment of a float is a single bytecode that the
interpreter will not split. The reader therefore sees either the old value or the new
one, never a half-written number. The moment your recovery logic reads a value, decides
something, and writes it back, that guarantee is gone and you need a
threading.Lock around the sequence. This chapter stays on the safe side of
that line by keeping the shared state to one timestamp and one event flag.
Delete one line from _watch. It is an easy line to lose, since firing the
callback feels like the end of the story:
def _watch(self) -> None:
while self._running:
silence = self.silence()
if silence > self.timeout:
self.on_timeout(self.name, silence)
# BUG: never re-arms _last_beat
time.sleep(self.poll)
$ uv run python -m labs.watchdog
turn 1 answered
turn 2 answered
turn 3 answered
healthy run: 0 stalls recorded
after the stall: 8 stalls recorded
llm silent 0.6s
llm silent 0.7s
llm silent 0.8s
llm silent 0.9s
llm silent 1.0s
llm silent 1.1s
llm silent 1.2s
llm silent 1.3s
Read the recorded silences and the diagnosis writes itself. They climb by one poll interval each, all the way to the end of the stall, so the alarm is firing on every pass of the watch loop from the half-second mark onward. A correctly re-armed watchdog produces values that hover near the timeout; a staircase means the reference timestamp is frozen. Eight is only the count for a 1.3 second stall at this poll rate: wire that callback to a real recovery and a board left unplugged for a minute collects six hundred reopen attempts, each one closing a port another thread may be using. The fix is the assignment from stage 2, placed immediately after the callback returns.
Checkpoint, and a machine that notices
- I can explain why a stored timestamp detects a freeze that a boolean health flag never will, and why the clock has to be monotonic.
- Given a timeout and a poll interval, I can predict how many alarms a stall of a given length produces, and check my prediction against the recorded silences.
- I can size a timeout from measured stage latency instead of guessing, and say what a too-tight limit costs.
- I can name the reason a recovery callback sets a flag instead of closing the serial port itself.
- Handed a log of repeated restarts, I can tell a genuinely repeating failure from a missing re-arm by reading whether the reported silence climbs.
Exercise 1 — one watchdog per stage. Give the core three
watchdogs named stt, llm and tts, each with a
timeout sized to that stage, and heartbeat each one as its stage returns. Freeze a
single stage and confirm the alarm names it.
Build them in __init__, start them all, and call
self.watchdogs["stt"].heartbeat() on the line after
process() returns, and so on down the turn. The payoff is the log
line: "llm silent for 62.0s" points at one subsystem, where a single whole-loop
watchdog can only report that the loop stopped. The timeouts differ by an order of
magnitude, since one shared limit would have to be as loose as the slowest stage.
Exercise 2 — escalate, then give up. Count consecutive
stalls. Route the first through alert("WARNING", ...), the third
through alert("CRITICAL", ...), and stop the watchdog after five so a
dead stage cannot be restarted all night.
Keep a self.strikes counter that the callback increments and
heartbeat() resets to zero, so recovery from any stall clears the
history. The alert layer from chapter 16 already routes by severity rank and only
touches the network when the config enables it, so the escalation is a severity
string, not new plumbing. The cap matters on hardware: a board with a fried
regulator will never answer, and a supervisor that keeps trying forever hides the
fault behind a wall of identical log lines. Print something final on the fifth
strike so the giving up is visible.
Exercise 3 — test it without waiting. Add a
clock: Callable[[], float] = time.monotonic parameter, use it
everywhere the class currently calls the module directly, and drive the whole stall
from a fake clock so the test finishes instantly.
A list holding one number and a closure returning it is a complete fake clock. Set
it to 0, take a heartbeat, set it to 40, run _watch's body once by
hand, and assert exactly one recorded stall. The test finishes in microseconds and
proves the re-arm logic that the failure box broke, where a real-time version
would sleep for forty seconds and still be at the mercy of a loaded machine.
Injecting the clock is the move from chapter 32 applied to time itself: a
dependency handed in from outside is a dependency you can replace under test.
She now survives a stage that dies quietly. What she cannot yet tell you is whether the hardware on the other end of that cable is any good, because "the servo moved" and "the servo moved to 90 degrees" are different claims and only one of them is measurable. Next chapter writes down what each component is expected to do before it is asked to do it, records what it actually did, and lets the gap between the two numbers speak.