# SPEC — `wedged_viewer_does_not_stall_controller` can consume 240s and report nothing

**For:** hertz (test-infra lane, dispatched by doyle 2026-07-27)
**From:** todlando (RCA), doyle (lane + own-thin-PR rulings)
**Origin:** #104 attempt-1 red on kitsubito. Linux `attach::wedged_viewer_does_not_stall_controller`
TIMEOUT at the 240s nextest cap — first sighting ever.
**Ruled:** own thin PR. Must NOT ride #104 (unrelated seam; #104 does not grow scope).
**Attribution settled:** NOT leg C. spool/inject cannot reach the attach plane (reachability argument
accepted by doyle). Whatever a rerun shows on this test is main-or-box, never `2aae246`.

## What the trace shows (doyle's attempt-1 forensics, attempt-scoped fetch)

- Setup COMPLETED: controller conn=2 + viewer conn=3 attached at mono_ms 0–1.
- The DESIGNED eviction fired exactly on its bound: `CONN_WRITE_POISONED conn=3` viewer at
  mono_ms=15016 (15s write bound), writer-exit write-failed, transport-close `poisoned=true
  timed_out=true`.
- Then NOTHING for 225 seconds until nextest TERMINATING at 240s. Last captured line before
  `(test timed out)` is the viewer transport-close.

So the hang is INSIDE the assertion window, past setup, with the wedge manufactured and evicted on
schedule. (An earlier "rig-phase hang" hypothesis of mine is FALSIFIED by this trace — don't spend
time there.)

## Structure — why a 20s-bounded loop consumed 240s (source-verified)

1. `Brain::read_event()` is literally `read_event_until(None)`.
2. `read_frame_until(None)` is an UNBOUNDED blocking read on BOTH conn shapes — `read_frame(stream)`
   for `BrainConn::Whole`, a bare `frames.recv()` for `BrainConn::Split`. Only the `Some(deadline)`
   arm carries the recv_timeout budget.
3. `attach::read_controller_event_resuming` (crates/spt-daemon/src/attach.rs:165) calls
   `brain.read_event()` — always the None path — inside its OWN `loop`.
4. The test (crates/spt-daemon/tests/attach.rs:1253) checks `while Instant::now() < deadline` only
   BETWEEN iterations. Zero events ⇒ the FIRST call blocks forever ⇒ the 20s bound is never
   evaluated ⇒ nextest terminates at 240s.
5. The correct shape already exists ~30 lines away in the same file:
   `viewer_reads_marker_no_displace` uses `read_event_until(Some(now + 200ms))` with
   `Err(_) => continue`. The VIEWER helper got a per-read bound; the CONTROLLER helper never did.

## Mechanism — why "nothing follows" is unrepresentable (the actual RCA)

Gap detection is **EDGE-TRIGGERED**: `attach::is_output_gap` matches an error the reader raises only
when a frame with a forward seq jump ARRIVES. b4's controller path DROPS frames via `try_send` when
its bounded channel (`CONTROLLER_CHANNEL_DEPTH = 4096`, broker.rs:145) is full.

Under enough starvation the 200KB burst AND the trailing `\rISOLATED\r` marker are dropped in the
SAME overflow window; the echo child then has nothing left to say; no further frame ever arrives.
Therefore NEITHER accepted outcome can occur:

- the ISOLATED marker needs a frame to arrive;
- `ControllerIrrecoverablyBehind` needs TWO resumes, each driven by an ARRIVING gap.

**The "nothing follows a drop" case has NO representation in the test**, so it degrades to a block.
On a fast box the marker lands before the overflow and the test passes — which is why this is a
first sighting: it takes exactly this starvation to swallow the marker too.

⇒ This is a LATENT TEST-LIVENESS DEFECT independent of tonight's root cause: the test cannot report
the one outcome it actually hit. A watcher needs its own liveness proof.

## Narrowed but NOT closed — is the controller genuinely stalled?

Two inferences from the same trace:

- (a) the drain thread was ALIVE at 15016ms — the bounded write + poison of conn=3 IS the drain path
  doing its job on schedule, so "drain stalled by the wedged viewer" is not supported;
- (b) the controller conn was OPEN and silent, not closed — a closed transport surfaces `Err` from
  `read_event`, and the test's `Err(e) => panic!` arm would have failed FAST rather than hanging.

Alive drain + open silent controller + zero frames is consistent with drop-then-quiet
(observation-side starvation) and NOT with the isolation property itself breaking. That is inference
from ABSENCE, so it needs its own probe — see the oracle below. Box context worth holding:
`NTP_TOTP_UNCORRECTED` all-NTP-unreachable on kitsubito in that window.

## Fix shape (proposed; doyle-approved as the shape, yours to build/refine)

1. **Enabling plumbing, production byte-identical:** add
   `read_controller_event_resuming_until(brain, session_id, gen, by, deadline)` in
   `crates/spt-daemon/src/attach.rs`; today's `read_controller_event_resuming` delegates to it with
   `None`. Production `serve_attach` (attach.rs:401) unchanged.
2. **Test takes a per-read deadline** and treats `TimedOut` as keep-waiting-until-the-wall-clock
   deadline (the viewer helper's pattern).
3. **Name the third outcome.** At the wall-clock deadline, FAIL with "controller produced ZERO events
   after the viewer eviction" — a ~20s named red instead of a 240s harness kill.
4. **Discriminating oracle (this is the part that makes the red diagnostic):** at the deadline,
   attach a FRESH reader and read the ring.
   - fresh reader SEES the burst ⇒ broker + drain were fine and the controller's own bounded channel
     dropped it ⇒ observation-side (main/box);
   - fresh reader sees NOTHING ⇒ the child never echoed ⇒ box starvation upstream of the plane.
   Either way the test reports WHICH, instead of hanging.

## Bank as a CLASS, not one test (doyle's ruling — note it in the fix PR)

> **edge-triggered gap detection + drop-don't-block ⇒ the "nothing-follows-a-drop" outcome has no
> representation.**

Every timed observer in the b4 plane should be audited against it: any reader whose success
condition requires a NEXT frame, over a transport that may legitimately drop and then go quiet,
needs its own per-read bound plus an explicit zero-event outcome. Sibling shapes to audit while
you are in there: the other controller-side readers in `attach.rs`, and anything else calling
`read_event()` (the None path) inside a wall-clock-bounded loop.

## Sequencing

You now hold TWO start-gated items — silent-peer characterization and this. Source-reading can start
now; loops at queue drain. **Which lands first is YOUR call.** Paired base-vs-`2aae246` run is now
OPTIONAL (the mechanism already names the cause) — ask only if your fix work wants the extra data
point, and I will cut the base worktree.
