Skip to content

Harden HUD WebSocket fan-out: add backpressure to broadcast() and a daemon transport test seam #308

Description

@arndvs

Problem

bin/hud-daemon.js (1,099 lines, zero tests) implements the ADR-003 transport cascade: producers emit via named pipe -> HTTP -> JSONL, and the daemon fans events out to browser clients over a hand-rolled WebSocket server.

The fan-out path has no backpressure. broadcast() (bin/hud-daemon.js:798) does wsClients.forEach(s => wsFrame(s, msg)) and wsFrame() (:787) calls socket.write() with no bufferedAmount / drain / highWaterMark handling. A single slow or disconnected browser client lets the Node socket buffer grow unboundedly, so an AFK loop that keeps emitting events can slowly exhaust the daemon's memory — the exact AFK scenario ADR-003 was built to observe. A dead client is only cleaned up when the next write() throws and wsClients.delete() runs (:795).

Two related hardening items belong in the same slice because they share the transport's write/timer lifecycle:

  • The heartbeat setInterval (:1053) and pipe-retry setTimeout (:629) never .unref(), so the daemon keeps the process alive around shutdown.
  • eventBuffers/violationsMap prune with .shift() per write (hot path), a minor inefficiency that a test seam would force into a bounded structure.

Architecture impact

Scope

  1. Cap per-socket buffering in wsFrame/broadcast: check socket.bufferedAmount (or writableLength) against a watermark and drop/close slow clients rather than buffering without bound; backpressure or process-and-discard with a HUD_MAX_CLIENTS-style bound consistent with the existing HUD_MAX_PROJECTS knob.
  2. .unref() the heartbeat interval and pipe-retry timers so the daemon can exit cleanly on shutdown.
  3. Extract the transport write path into an injectable module with a unit test (using node:test/node:assert, matching the zero-dependency Node-only design in ADR-003) covering: frame construction (len <126 / <65536 / >=65536), write-throw -> client removed, bufferedAmount cap, and the JSONL fallback path.
  4. Add a transport-level regression test asserting the daemon never calls write() on an errored socket.

Acceptance criteria

  • node --test bin/hud-daemon.test.js (or equivalent) passes: >=1 test each for frame framing, slow-client cap, write-error cleanup, and JSONL fallback.
  • A slow-client simulation shows a bounded per-socket buffer (no unbounded writableLength growth).
  • Daemon exits promptly on SIGTERM (timers unref'd).
  • No observable behavior change for healthy clients; no producer changes.

Non-goals

Proposed by: automated source:architecture-review pass.

Metadata

Metadata

Assignees

No one assigned

    Labels

    source:architecture-reviewPRDs proposed by the automated architecture-review workflow

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions