GLaDOS Vol 5 · Alive on the Bench
ch 41 / 99
Chapter 41

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.

◆ Note — what a heartbeat does not certify

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

▣ Build · stage 1 — liveness as arithmetic
# 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.

▣ Build · stage 2 — a thread that watches while you block
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.

▣ Build · stage 3 — feed it from a turn, then stop feeding it
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

▣ Build · stage 4 — the finished module
# 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.

▣ Build · stage 5 — supervising the wire to the board
# 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.

⚠ Worked failure — one stall, eight restarts

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

✓ Checkpoint — what you can now do
  • 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.
⚡ Exercises — try first, then reveal
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.