GLaDOS Vol 2 · Her Mind
ch 15 / 99
Chapter 15

An Event Bus

The web of imports is the bug

Count her organs: wake word, capture, transcription, memory, context, personality, the model, the voice. Eight modules, and they need to react to each other. The wake word fires and something must start listening; the model replies and something must speak. The obvious wiring is the direct call: the wake module imports the listener and calls it, the mind imports the mouth and calls that. It works at three modules. Then you add logging, then a lights trigger, then telemetry, and every one needs threading into call sites across the codebase. Changing one function's signature now breaks four files, and testing the wake loop drags the entire stack in behind it.

The cut is one object: an event bus. Components publish named events without knowing who listens; components subscribe to names without knowing who publishes. The publisher's whole obligation is to shout the name and walk away. Zero listeners or ten is the bus's business, never the shouter's. This pattern is called publish/subscribe, it is older than most languages you have used, and it is about forty lines of Python.

Forty lines of nervous system

▣ Build · stage 1 — the smallest bus that works
# labs/event_bus.py
from collections import defaultdict
from typing import Callable

class EventBus:
    def __init__(self):
        self._handlers: dict[str, list[Callable]] = defaultdict(list)

    def subscribe(self, event: str, handler: Callable) -> None:
        self._handlers[event].append(handler)

    def publish(self, event: str, data=None) -> None:
        for handler in self._handlers[event]:
            handler(data)

bus = EventBus()
bus.subscribe("speech_detected", lambda d: print(f"[EVENT] speech_detected: {d}"))
bus.publish("speech_detected", {"text": "Hello GLaDOS"})
$ uv run python labs/event_bus.py
[EVENT] speech_detected: {'text': 'Hello GLaDOS'}

The entire mechanism is a dictionary from names to lists of functions. defaultdict(list) earns its import: the first subscriber for an event arrives before that event's list exists, and the defaultdict conjures an empty list on first touch, so neither method ever guards for a missing key. The other load-bearing fact is one you proved in chapter 2 without noticing: functions are objects. They sit in lists, and calling handler(data) later works because a function in a list is still a function.

▣ Build · stage 2 — leaving the room
    def unsubscribe(self, event: str, handler: Callable) -> None:
        self._handlers[event] = [
            h for h in self._handlers[event] if h is not handler
        ]
$ uv run python labs/event_bus.py
[EVENT] speech_detected: {'text': 'first'}
done

The demo subscribes a named handler, publishes once, unsubscribes, publishes again: one line of output, then silence, then "done." Two details are doing quiet work. Removal compares by identity (is not, never !=), because you want the exact function object the caller handed you gone, not anything that happens to compare equal. And the list is rebuilt with a comprehension instead of mutated in place, which stays safe even if a handler unsubscribes itself mid-publish. Note what the second publish did about having no audience: nothing. Hold that thought for the failure box.

▣ Build · stage 3 — one bus, shared by import
bus = EventBus()   # module-level: every importer gets this one

def on_speech_detected(data):
    print(f"[EVENT] speech_detected: {data}")

def on_response_ready(data):
    print(f"[EVENT] response_ready: {data}")

if __name__ == "__main__":
    bus.subscribe("speech_detected", on_speech_detected)
    bus.subscribe("response_ready", on_response_ready)
    bus.publish("speech_detected", {"text": "Hello GLaDOS"})
    bus.publish("response_ready", {"reply": "Fascinating. You can speak."})
$ uv run python labs/event_bus.py
[EVENT] speech_detected: {'text': 'Hello GLaDOS'}
[EVENT] response_ready: {'reply': 'Fascinating. You can speak.'}

The module-level instance is the meeting point: every file that writes from event_bus import bus gets the same object, which is how the wake module and the mouth module coordinate without either importing the other. Their entire shared vocabulary is a set of strings. That is the decoupling, and it is also the danger, because strings have no compiler checking them. The __main__ guard keeps the demo wiring from firing on import, a habit you now apply without being told.

◆ Note — name the events once, in one place

Since strings are the whole contract, spelling is architecture. The finished system keeps a single module of constants (SPEECH_DETECTED = "speech_detected" and kin), and every publisher and subscriber imports the constant instead of typing the string. A typo in a constant name is a crash at import time, loud and immediate; a typo in a string literal is the silent failure below. Same mistake, wildly different price, depending only on where you keep the names.

Why this works: the string is the entire contract

Publisher and subscriber share no types, no imports, no signatures beyond "takes the data." They share a string. That thinness is what makes the pattern scale: adding a telemetry module tomorrow means one subscribe call in the telemetry file, and not one other file learns telemetry exists. It is also what volume 4's architecture builds on, what the automation engine in chapter 17 rides (rules subscribe to events), and what the hardware controller in volume 5 uses to keep servo commands out of the language model's code path. This chapter is forty lines you will still be using in chapter 99.

⚠ Worked failure — published into the void

You wire the reply pipeline: the mind publishes, the mouth subscribes. She thinks, and never speaks. No error, no log, nothing:

bus.subscribe("response_ready", speak_handler)
# ... elsewhere, in the mind module:
bus.publish("reponse_ready", {"reply": text})   # typo: reponse
$ uv run python labs/event_bus.py
You: tell me a joke
(thinking...)
(—nothing. no reply, no speech, no error—)

defaultdict gives the misspelled event a fresh empty list, the publish loop runs zero times, and the system continues, perfectly healthy, around a message that fell into a gap between two strings. This is the pub/sub tax: decoupling removed the import errors that would have caught the typo. Two defenses, cheap and cheaper: constants instead of literals (the note above), and a debug subscriber registered for every known event at startup that logs traffic, so "the mouth never got it" becomes visible in one glance at the log. Volume 5's telemetry chapter builds exactly that watcher.

Checkpoint, decoupled

✓ Checkpoint — what you can now do
  • I can explain what the import web costs at eight modules, with the concrete symptom (one signature change, four broken files).
  • I can write the bus from memory: the defaultdict, the append, the loop.
  • I know why unsubscribe compares with is not and rebuilds rather than mutates.
  • I can state the pub/sub tax in one sentence and both defenses against it.
  • I can say why the shared bus is a module-level instance and what the import guard protects.
⚡ Exercises — try first, then reveal
Exercise 1 — rewire one seam. In the voice loop, replace the direct call from transcription to thinking with bus.publish("speech_detected", ...), and subscribe the mind to it. What broke, and what got easier?

Nothing broke if the strings match; the loop behaves identically. What got easier shows up when you add a second subscriber: a one-line logger that prints every utterance now installs without touching the loop, the mind, or anything else. The first time you add a feature by only adding a file, the pattern pays for itself.

Exercise 2 — the crashing listener. Subscribe two handlers to one event and make the first raise an exception. What happens to the second, and what would you change in publish?

The exception propagates out of publish and the second handler never runs: one bad listener silenced another. The fix wraps each call in try/except, logs the failure, and continues the loop, which changes the bus's promise from "handlers run" to "handlers run independently." That promise is the right one for her: the lights turning on must not depend on the logger being bug-free.

Exercise 3 — the traffic log. Build the debug watcher from the failure box: a function that subscribes to a list of known events and timestamps each one into a file. Publish a typoed event. How does the log tell you what happened?

By absence: the log shows speech_detected arriving and no response_ready after it, which points you at the mind's publish line, where the typo lives. Logs that show what did not happen are how silent failures get found; you will meet this idea again, grown up, in volume 5's telemetry and volume 6's health checks.

She has a nervous system, and modules that can join or leave without surgery on their neighbors. Time to give the nerves something to carry: next chapter she learns to raise her voice when something goes wrong, because an assistant that fails silently is a roommate who watched the stove catch fire and said nothing.