All posts

The Bug That Taught Me Not to Trust `async` Hooks

Claude CodeCodex CLIHooksConcurrencyHardware

A physical status light for Claude Code sessions, a hook config with async: true everywhere, and a race condition that only showed up under real load: the light stuck on yellow long after the session had already ended.


Background

I built a physical desktop status light for Claude Code sessions. Green means the agent is working, yellow means it's waiting on me, red means idle, flashing green means it's compacting context, a slow breathing red means the connection's gone stale. The point is to walk away from the terminal and still know what's going on at a glance instead of babysitting a pane.

It's an RP2040 microcontroller with three LEDs, talking to a small Python CLI over USB serial. Claude Code's hook system drives the CLI automatically: UserPromptSubmit and PreToolUse call andon-light set working, Notification calls set waiting, Stop calls set idle, and so on. Every hook was originally async: true. That seemed like the obvious choice, since a status light shouldn't add latency to a real tool call. It's also what broke the whole thing.

The symptom

A session would end normally. Stop fires, the light goes red. Correct. Then, sometime later, with nothing running, the light would flip to yellow and just sit there. No session, no pending permission, nothing to wait on. It looked like a firmware bug. I checked the watchdog, checked the wiring, reflashed. The device was fine. It was doing exactly what it was told; the instruction itself just arrived late.

What async: true actually guarantees

The mental model I had going in was "async means it runs in the background, so it doesn't block." True, but incomplete. What async: true actually guarantees is that each hook command gets launched in the order its event fired. It says nothing about the order they finish.

Each andon-light set ... call is a fresh Python process: interpreter startup, import pyserial, open the serial port, write, close. Measured at roughly 40-50ms, which is cheap but not zero, and not perfectly consistent either. Under a tool-heavy turn, several hooks can fire within a short window. If a Notification (→ yellow) fires, and a Stop (→ idle) fires shortly after but happens to finish first, because process startup jitter isn't constant, the late-finishing yellow command lands after the correct red one and silently stomps it.

Nothing about this is exotic. It's the same "launch order isn't completion order" trap as any fire-and-forget async call writing to shared state, just wearing a hook-config costume instead of a queue-worker one. I'd read that sentence a hundred times in other contexts and still didn't clock that it applied here until I watched it happen on real hardware.

The fix

Drop async: true everywhere:

   "Stop": [
     {
       "hooks": [
         {
           "type": "command",
           "command": "andon-light set idle || true",
-          "async": true
         }
       ]
     }
   ]

Without it, Claude Code waits for each hook to finish before moving on, which is a real ordering guarantee instead of a launch-order one. The latency question I'd been trying to optimize for turned out not to matter: 40-50ms is nothing against how long a real Bash call or file edit takes. The tradeoff that justified async in the first place wasn't actually buying anything. I'd added a bug to save time I was never going to notice saving.

A second bug from the same instinct

Related, found a day later: Stop hooks don't fire on a user-initiated interrupt. Kill a running claude process with Ctrl+C and the light just stays on whatever color it last had, green, forever, from the outside looking exactly like a hung session. Claude Code's docs say this outright if you go looking, but I hadn't gone looking, because I'd reasoned my way to assuming Stop covered "session over" in general.

Fixed by adding a SessionEnd hook, which fires on any termination reason, not just a clean one, and pointing it at idle/red. Kept Stop too, since a normal turn ending should still resolve as fast as possible without waiting on session teardown.

The pattern in both bugs is the same: a behavior I picked by reasoning about what should happen, not by checking what actually does. async: true should be safe because each call is independent. Stop should cover session-end because what else would. Neither survived contact with a real session.

Where it landed

Firmware, host CLI, and all eight Claude Code hook events (SessionStart, UserPromptSubmit, PreToolUse, Notification, PermissionRequest, PreCompact, Stop, SessionEnd) are now validated end to end against real hardware, synchronously, in true event order. The light hasn't lied to me since.

The project kept moving past that first breadboard: a custom PCB with 10 individually addressable LEDs, a 3D-printed enclosure, and a host CLI published to PyPI with a one-click Windows installer. When I added a second agent integration, Codex CLI, the same lesson applied without me having to relearn it. hooks/codex/hooks.json never sets async, for the exact reason documented above.