GLaDOS Vol 3 · Her Craft
ch 30 / 99
Chapter 30

A Protocol of Her Own

One mind, one process, and the wall it just hit

Everything she does today happens inside a single Python process. The event bus from chapter 15 feels like modules talking to each other, but every message on it is a function call: publisher and subscriber share one interpreter, one memory space, one crash. That was the right first architecture, and it has quietly become the ceiling. The language model wants the machine with the GPU. The body she is getting runs on a microcontroller bolted to an arm. Two programs on two machines cannot share a Python dict. They can only share a wire.

The wire is nearly free: a TCP socket, the reliable pipe every higher protocol is built on. TCP promises that the bytes you send arrive, in order, without gaps or duplicates. What it does not promise (and this trips nearly everyone exactly once) is anything about messages. TCP is a byte stream. Send {"command": "status"} and the receiver's recv(4096) may hand back the whole thing, half of it, or all of it plus the front of the next message; the operating system batches and splits traffic however it likes. Call json.loads() on half an object and the error reads like corrupt data, but nothing is corrupt. You assumed a boundary the protocol never drew.

Drawing that boundary yourself is called framing, and the volume closes by doing it in the simplest form that works: one message is one line — serialize the dict to JSON, append a newline, and on the other side refuse to parse until that newline has arrived. Held on both ends, that one-sentence contract turns a stream of bytes into a sequence of dicts her modules can trust.

An echo first, then a protocol

▣ Build · stage 1 — the smallest possible round trip
# labs/wire.py
import json
import socket
import threading
import time

HOST = "localhost"
PORT = 9999

def serve_raw() -> None:
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((HOST, PORT))
    server.listen(1)
    conn, _addr = server.accept()
    with conn:
        raw = conn.recv(4096)   # one read
        conn.sendall(raw)       # echo the exact bytes back
    server.close()

if __name__ == "__main__":
    threading.Thread(target=serve_raw, daemon=True).start()
    time.sleep(0.1)
    with socket.create_connection((HOST, PORT)) as sock:
        sock.sendall(json.dumps({"command": "status"}).encode() + b"\n")
        reply = sock.recv(4096)
    print(f"Raw reply: {reply!r}")
$ uv run python labs/wire.py
Raw reply: b'{"command": "status"}\n'

The server blocks on accept(), so it runs on a daemon thread while the main thread plays client; the time.sleep(0.1) gives it a moment to bind and listen, and skipping that pause earns a ConnectionRefusedError. Two conversions bracket the trip: json.dumps produces a str, sockets only carry bytes, so .encode() goes out and .decode() will come back. And look at what returned: raw bytes, trailing \n still attached. We have a working pipe. We do not yet have messages.

▣ Build · stage 2 — read until the newline, then parse
def recv_message(sock: socket.socket) -> dict:
    data = b""
    while True:
        chunk = sock.recv(4096)
        if not chunk:               # empty bytes: the peer closed
            break
        data += chunk
        if data.endswith(b"\n"):    # the frame is complete
            break
    return json.loads(data.decode().strip())

def serve_once() -> None:
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.bind((HOST, PORT))
    server.listen(1)
    conn, _addr = server.accept()
    with conn:
        msg = recv_message(conn)
        response = {"status": "ok", "echo": msg}
        conn.sendall((json.dumps(response) + "\n").encode())
    server.close()
$ uv run python labs/wire.py
Parsed reply: {'status': 'ok', 'echo': {'command': 'status', 'source': 'glados'}}

recv_message is the framing rule made executable: keep appending chunks until the buffer ends with the delimiter, or until an empty chunk says the peer hung up. The loop cannot collapse to a single recv, because a single recv makes a promise TCP never made. And the same function serves both sides of the wire, client reading the reply, server reading the request. One helper, one contract, no drift.

▣ Build · stage 3 — production posture: the full labs/wire.py
# labs/wire.py — full file
import json
import socket
import threading
import time

HOST = "localhost"
PORT = 9999

def recv_message(sock: socket.socket) -> dict:
    data = b""
    while True:
        chunk = sock.recv(4096)
        if not chunk:
            break
        data += chunk
        if data.endswith(b"\n"):
            break
    return json.loads(data.decode().strip())

def send_message(host: str, port: int, message: dict) -> dict:
    with socket.create_connection((host, port), timeout=5) as sock:
        sock.sendall((json.dumps(message) + "\n").encode())
        return recv_message(sock)

def handle_client(conn: socket.socket) -> None:
    with conn:
        msg = recv_message(conn)
        response = {"status": "ok", "echo": msg}
        conn.sendall((json.dumps(response) + "\n").encode())

def serve_once(host: str, port: int) -> None:
    server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server.bind((host, port))
    server.listen(1)
    conn, _addr = server.accept()
    handle_client(conn)
    server.close()

def main() -> None:
    threading.Thread(target=serve_once, args=(HOST, PORT), daemon=True).start()
    time.sleep(0.1)
    response = send_message(HOST, PORT, {"command": "status", "source": "glados"})
    print(f"Response: {response}")

if __name__ == "__main__":
    main()
$ uv run python labs/wire.py
Response: {'status': 'ok', 'echo': {'command': 'status', 'source': 'glados'}}

Two additions make the demo livable. SO_REUSEADDR: without it, killing the server parks the port in TIME_WAIT for up to two minutes, and every restart during development greets you with Address already in use. And timeout=5 on the client connection, so send_message fails fast against a machine that is down instead of blocking forever. main owns the demo, but send_message and recv_message are the actual deliverable, the pair volume 4 will import every time two of her processes need a word.

◆ Note — a protocol you can poke with your bare hands

Line-delimited text buys you something binary protocols charge extra for: inspectability. With the server running, printf '{"command": "status"}\n' | nc localhost 9999 prints the framed reply straight to your terminal; no client code, just a wire you can question by hand. SMTP and Redis made the same trade for the same reason. Debuggability is a feature you design in, and this chapter just designed some in.

Why this works: a contract about endings

A TCP connection gives you ordering and delivery but no record boundaries; imposing structure on the stream is always the receiver's job. The delimiter makes that job finite, and given it, recv_message is a three-case machine. Buffer ends with \n: the frame is complete, parse it. Chunk is non-empty but no delimiter yet: a partial frame, loop and append. Chunk is empty bytes: EOF, the peer closed, stop and parse what arrived. Only one combination hangs it: a peer that sends no newline and holds the connection open.

One worry to retire early: what if a message contains a newline, a transcript with line breaks, say? It cannot break the frame, because json.dumps never emits a raw newline inside a string value; it escapes it to a backslash followed by an n, two ordinary characters. The first actual 0x0A byte on the wire is provably the one we appended: the delimiter is safe because the serializer promises never to use it.

Newlines are one answer to the question every protocol must answer: where does a message end? The other classic answer is a length prefix, where a few bytes announce the payload size and the receiver reads exactly that many. You have met it without the name: the WAV files of chapter 3 open every chunk with a declared size, length-prefix framing on disk. Prefixes win for binary payloads; newlines win for JSON, being trivial to implement and readable in a terminal. What never works is the third option people ship by accident, hoping one recv equals one message — the failure below runs it so you never have to.

⚠ Worked failure — the parser takes the blame for the framing

You test the framed server, it works, and you simplify: one recv, no loop, parse immediately. Every small message survives. Then a real one arrives, larger than a single TCP segment:

def serve_once() -> None:
    ...
    conn, _addr = server.accept()
    with conn:
        raw = conn.recv(4096)            # one read, assumed complete
        msg = json.loads(raw.decode())   # BUG: raw may be half a frame
        conn.sendall((json.dumps({"echo": msg}) + "\n").encode())

# and the client sends a payload bigger than one segment:
big = {"command": "log", "text": "x" * 8000}
$ uv run python labs/wire.py
Exception in thread Thread-1 (serve_once):
Traceback (most recent call last):
  ...
    msg = json.loads(raw.decode())   # BUG: raw may be half a frame
  File ".../json/decoder.py", line 361, in raw_decode
    obj, end = self.scan_once(s, idx)
json.decoder.JSONDecodeError: Unterminated string starting at: line 1 column 28 (char 27)

Read the message the traceback is actually sending. "Unterminated string" points a finger at the JSON, and the sender will swear the JSON was whole when it left, because it was. The column number is the tell: 28 is exactly where the long "text" value begins, so the decoder ran off the end of the buffer mid-string. A single recv returned the first chunk of an 8,000-byte frame and the parser was handed a truncated object. The bug lives two lines above the line that raised, in an assumption, not an expression: one read is not one message. Framing bugs surrender to this move — stop asking what is wrong with the data and ask who decided the data was complete.

Checkpoint at the edge of the volume

✓ Checkpoint — what you can now do
  • I can list the three things a single recv(4096) may legally return for one sent message, and say why the OS is allowed all three.
  • I can state the framing contract in one sentence and point to the two lines that enforce it, one on each side of the wire.
  • I can trace recv_message's exits (delimiter seen, empty chunk) and name the one peer behavior that would hang it.
  • I know why a newline inside a message value cannot break the frame: json.dumps ships it as two characters, backslash then n.
  • Handed a JSONDecodeError from a socket, I check who decided the buffer was complete before I suspect the data.
⚡ Exercises — try first, then reveal
Exercise 1 — a server that answers differently. Make handle_client dispatch on msg.get("command"): reply pong to "ping", an uptime dict to "status", and a structured error to anything else. Send all three and watch the replies.

Branch on the command and build a different response dict per case, the unknown branch returning something like {"status": "error", "reason": "unknown command: dance"} instead of raising. This is chapter 20's routing, one wire lower: an unknown command is the fallback agent's problem again, and the answer is again a reply that names the failure, never a crash that ends the conversation.

Exercise 2 — surviving a dead server. Wrap send_message so a missing server logs a warning and returns None instead of crashing the caller. Prove it by sending to a port nothing listens on.

Catch ConnectionRefusedError around the connection, print something like [warn] no server listening on localhost:65000, and return None; against the dead port the demo now warns and keeps running. Add socket.timeout to the same except clause and the machine-is-down case degrades just as politely. In volume 4 her modules start and stop independently, so "the other side is not there right now" is a normal Tuesday, not an exception worth dying for.

Exercise 3 — a server that stays up. serve_once serves one client and exits. Turn it into a persistent server: loop on accept() and hand each connection to handle_client on its own daemon thread. Test with three clients in a row.

Replace the single accept with while True, raise the backlog to listen(5), and spawn threading.Thread(target=handle_client, args=(conn,), daemon=True).start() per connection; three sequential clients each get their echo without a restart. Daemon threads keep a stuck client from pinning the whole process. This persistent server is the seed of every module's front door in the next volume.

Stand back and look at what this volume added. She curates her own few-shot examples from the exchanges you rated well. Her preferences overlay your choices onto safe defaults. Her speech is normalized before the voice reads it, and she reads your mood back, letting it fade honestly with time. The lab notebook records every experiment with the hypothesis written before the result. Her plans are data you can query. Her body already exists as a simulated interface that clamps what you ask of it. The pipeline reports where its milliseconds go, her sensor logs bound their own growth, and as of today her processes own a wire protocol with framing they can trust. Volume 2 gave her a mind; this volume gave her a craft: habits of measurement, honesty, and repair that a growing system needs more than any single ability.

Volume 4 is called One System, and it earns the name: the pile of working scripts becomes an architecture drawn on purpose, modules composed behind explicit seams, with a startup sequence that validates every dependency before she says a word. Then the protocol you built today gets its first real job: a wire out of the computer entirely, to an ESP32 microcontroller listening on the far end. Her body's nervous system starts there, and it speaks in framed messages because you decided, this chapter, where a message ends.