Problem
The broker keeps the events WebSocket warm and lets the PTY input WebSocket go silent. That asymmetry is the root cause of the drive-attach input flood reported in #1419, and it is the only part of that bug that is genuinely preventable rather than merely recoverable.
The two sockets a attach --mode drive session holds are not treated alike:
-
Events / output WS — pinged by the broker every 30 seconds.
crates/broker/src/listen_api.rs:2932 — let mut ping_interval = tokio::time::interval(Duration::from_secs(30));
sent at crates/broker/src/listen_api.rs:2982-2990 inside the tokio::select! loop.
-
PTY input WS — pinged by nobody, in either direction.
The broker's handler handle_pty_input_ws (crates/broker/src/listen_api.rs:1783-1866) is a pure receive/ack loop. It never initiates a Ping; it only answers one, mapping an inbound Message::Ping to a Pong (crates/broker/src/listen_api.rs:2036, :1846-1854).
The client half never pings either: PtyInputStream in packages/harness-driver/src/transport.ts:77-368 sets up open / message / close / error handlers and no keepalive timer.
Consequence
An interactive session that is reading output but not typing produces zero bytes on the input socket while the output socket is actively kept alive. Any idle timeout between client and broker — a reverse proxy, a cloud edge, a NAT table entry — therefore reaps the input socket alone, and leaves the output socket up.
The user-visible result is the worst possible shape: the screen keeps updating, so the session looks healthy, but input is permanently dead. PtyInputStream has no reconnect, so _closed latches at transport.ts:143 and every later send() rejects at transport.ts:194-203.
Why this is separate from #1419
#1419 is recovery — notice the stream died, reopen it, and exit non-zero if that fails. This issue is prevention — do not let an idle input socket be reaped in the first place. Recovery is still required (a PTY worker restart closes the socket for reasons no keepalive can prevent), but a session that reconnects every few minutes because its own transport went quiet is a bug even when the reconnect works.
Filing separately so the asymmetry is findable on its own rather than buried in a CLI fix PR.
Suggested fix
Give handle_pty_input_ws the same 30s ping_interval the events handler already has, via a tokio::select! over socket.recv() and the interval tick. The ws client library already answers Pings automatically, so no client change is strictly required — though a client-side idle ping in PtyInputStream would also defend against a broker that stops pinging.
Worth confirming as part of this: whether the two handlers should share one keepalive helper rather than growing a second hand-rolled copy.
Acceptance criteria
- The PTY input WS sends periodic Pings on the same cadence as the events WS.
- An input WS that is idle for well past the previous reaping window stays open and accepts input afterwards.
- A test asserts the input socket survives an idle period longer than the ping interval — not merely that a Ping is emitted.
- The events and input keepalive cadences do not drift apart silently; ideally one shared constant.
Notes
Found while root-causing #1419 ([drive] input stream send failed: PTY input stream is closed flooding a drive session). Not labelled for dispatch — this is a report, not a queued task.
Problem
The broker keeps the events WebSocket warm and lets the PTY input WebSocket go silent. That asymmetry is the root cause of the drive-attach input flood reported in #1419, and it is the only part of that bug that is genuinely preventable rather than merely recoverable.
The two sockets a
attach --mode drivesession holds are not treated alike:Events / output WS — pinged by the broker every 30 seconds.
crates/broker/src/listen_api.rs:2932—let mut ping_interval = tokio::time::interval(Duration::from_secs(30));sent at
crates/broker/src/listen_api.rs:2982-2990inside thetokio::select!loop.PTY input WS — pinged by nobody, in either direction.
The broker's handler
handle_pty_input_ws(crates/broker/src/listen_api.rs:1783-1866) is a pure receive/ack loop. It never initiates a Ping; it only answers one, mapping an inboundMessage::Pingto aPong(crates/broker/src/listen_api.rs:2036,:1846-1854).The client half never pings either:
PtyInputStreaminpackages/harness-driver/src/transport.ts:77-368sets upopen/message/close/errorhandlers and no keepalive timer.Consequence
An interactive session that is reading output but not typing produces zero bytes on the input socket while the output socket is actively kept alive. Any idle timeout between client and broker — a reverse proxy, a cloud edge, a NAT table entry — therefore reaps the input socket alone, and leaves the output socket up.
The user-visible result is the worst possible shape: the screen keeps updating, so the session looks healthy, but input is permanently dead.
PtyInputStreamhas no reconnect, so_closedlatches attransport.ts:143and every latersend()rejects attransport.ts:194-203.Why this is separate from #1419
#1419 is recovery — notice the stream died, reopen it, and exit non-zero if that fails. This issue is prevention — do not let an idle input socket be reaped in the first place. Recovery is still required (a PTY worker restart closes the socket for reasons no keepalive can prevent), but a session that reconnects every few minutes because its own transport went quiet is a bug even when the reconnect works.
Filing separately so the asymmetry is findable on its own rather than buried in a CLI fix PR.
Suggested fix
Give
handle_pty_input_wsthe same 30sping_intervalthe events handler already has, via atokio::select!oversocket.recv()and the interval tick. Thewsclient library already answers Pings automatically, so no client change is strictly required — though a client-side idle ping inPtyInputStreamwould also defend against a broker that stops pinging.Worth confirming as part of this: whether the two handlers should share one keepalive helper rather than growing a second hand-rolled copy.
Acceptance criteria
Notes
Found while root-causing #1419 (
[drive] input stream send failed: PTY input stream is closedflooding a drive session). Not labelled for dispatch — this is a report, not a queued task.