Skip to content

Recover terminal output flow from a dropped ack#172

Merged
luckeyfaraday merged 2 commits into
mainfrom
fix/terminal-flush-wedge
Jun 21, 2026
Merged

Recover terminal output flow from a dropped ack#172
luckeyfaraday merged 2 commits into
mainfrom
fix/terminal-flush-wedge

Conversation

@luckeyfaraday

Copy link
Copy Markdown
Owner

Summary

Fixes a flow-control wedge introduced with the terminal output backpressure in #169.

That change made the Electron main process send at most one unacknowledged output batch per terminal, gating further sends until the renderer acks by sequence (embeddedTerminal:dataAck). The in-flight gate (inFlightOutput) was only ever cleared by:

  • a matching ack, or
  • terminal exit (clearTerminalOutputState).

Embedded terminals live in the main process and survive renderer reloads. So if a renderer reloads or crashes after a batch is emitted but before it acks, the gate stays set forever — every later flushOutput hits if (... || inFlightOutput.has(id)) continue and skips that terminal. Its live output silently freezes until the process exits. The rolling buffer keeps updating (appendBuffer runs independently), so a remount shows a correct one-shot snapshot via getEmbeddedTerminalBuffer, but subsequent incremental output never arrives.

Why not reset the gate on getEmbeddedTerminalBuffer

That was the obvious hook (a remount re-fetches the buffer), but getEmbeddedTerminalBuffer has many incidental callers — control-server.ts control snapshots and SSE backfill, and App.tsx polling the buffer length as a marker. Resetting the gate there would relax the backpressure on every passive read, defeating its purpose.

Change

  • Extract the gate into a pure, unit-tested OutputAckGate (client/electron/terminal-output-ack.ts), replacing the inline inFlightOutput map + nextOutputSequence counter.
  • Treat a batch left unacknowledged for longer than DEFAULT_OUTPUT_ACK_TIMEOUT_MS (2s) — comfortably above a normal sub-millisecond IPC round-trip — as lost. The next flushOutput re-sends with a fresh sequence, so the terminal recovers on its own without any remount signal. The dropped batch's bytes are already in the rolling buffer, so a remounting renderer re-renders them from the snapshot; no data is lost.

A late ack for a since-resent batch is ignored (sequence mismatch), so there's no spurious clear or double-flush. Normal-path behavior (ack within milliseconds) is unchanged.

Verification

  • npm run build:electron — clean
  • npm run test:electron145/145 pass, including 7 new terminal-output-ack tests (gate holds until ack, ignores stale acks, releases after timeout, late-ack-after-resend is ignored, clear() drops the batch).

Context

Follow-up to the flow-control work in #169 (merged). Flagged during PR review as the one outstanding correctness gap in that change.

🤖 Generated with Claude Code

luckeyfaraday and others added 2 commits June 20, 2026 18:29
The per-terminal output flow control added in #169 sends at most one
unacknowledged batch to the renderer and gates further sends until the
renderer acks by sequence. The in-flight gate was only ever cleared by a
matching ack or by terminal exit, so a renderer that reloaded or crashed
after a batch was emitted but before it acked left the gate stuck: every
later flush skipped that terminal and its live output silently froze until
the process exited. The rolling buffer kept updating, so a remount showed a
correct snapshot, but subsequent live output never arrived.

Resetting the gate in getEmbeddedTerminalBuffer is not viable -- it has many
incidental callers (control-server snapshots/SSE, App buffer-length polling)
that would defeat the backpressure on every passive read.

Instead extract the gate into a pure, unit-tested OutputAckGate
(terminal-output-ack.ts) and treat a batch left unacknowledged for longer
than DEFAULT_OUTPUT_ACK_TIMEOUT_MS (2s, well above a normal IPC round-trip)
as lost: the next flush re-sends with a fresh sequence and the terminal
recovers on its own. The dropped batch's bytes are already in the rolling
buffer, so a remounting renderer re-renders them from the snapshot.

- npm run build:electron -- clean
- npm run test:electron -- 145/145 pass (incl. 7 new terminal-output-ack tests)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@luckeyfaraday luckeyfaraday merged commit 494a0dc into main Jun 21, 2026
@luckeyfaraday luckeyfaraday deleted the fix/terminal-flush-wedge branch June 21, 2026 00:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant