Repository navigation
fix(terminal): stop stale multiplex stream handles from swallowing input - #16325
Conversation
Investigating STA-5098. Parking a mirrored remote tab closes its stream while a sibling tab keeps the multiplexer alive, so the reveal re-subscribes on an instance that already retired a stream. This came back green, which exonerates the multiplexer as the cause of STA-5098 — the wedge is above it. Kept as a contract guard; the header says explicitly that it is not coverage for that ticket.
A stream handle whose record was dropped (park close, or a reconnect that clears the stream table) kept reporting success: sendFrame gates only on socket readiness, never on stream membership. The host drops those frames for an unknown stream id, so a revealed cold-parked remote pane looked connected while the PTY never saw a byte and never painted (STA-5098). Reporting success also defeated the transport's own recovery — it re-sends input over terminal.send when the stream refuses it, which never ran. Guard the three public senders on stream membership, the check close() and setOutputPaused already use, and drain input queued behind a viewport claim on every stream install rather than only a still-pending claim. Withdraws the parked-reveal re-subscribe test: its fake host answered Subscribe with an immediate snapshot, so it could not fail.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Repository UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (3)
📝 WalkthroughWalkthroughThe transport now uses 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)Full details: Description checkExplanation The description is detailed and covers the change, rationale, testing, visual proof, compatibility considerations, and checklist. It does not provide the required linked issue reference; it states that the issue is internal instead. ✨ Finishing Touches📝 Generate docstrings
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…put (stablyai#16325) * test(terminal): pin park/reveal re-subscribe on a shared multiplexer Investigating STA-5098. Parking a mirrored remote tab closes its stream while a sibling tab keeps the multiplexer alive, so the reveal re-subscribes on an instance that already retired a stream. This came back green, which exonerates the multiplexer as the cause of STA-5098 — the wedge is above it. Kept as a contract guard; the header says explicitly that it is not coverage for that ticket. * fix(terminal): stop stale multiplex stream handles from swallowing input A stream handle whose record was dropped (park close, or a reconnect that clears the stream table) kept reporting success: sendFrame gates only on socket readiness, never on stream membership. The host drops those frames for an unknown stream id, so a revealed cold-parked remote pane looked connected while the PTY never saw a byte and never painted (STA-5098). Reporting success also defeated the transport's own recovery — it re-sends input over terminal.send when the stream refuses it, which never ran. Guard the three public senders on stream membership, the check close() and setOutputPaused already use, and drain input queued behind a viewport claim on every stream install rather than only a still-pending claim. Withdraws the parked-reveal re-subscribe test: its fake host answered Subscribe with an immediate snapshot, so it could not fail. * chore(terminal): tighten the stale-stream comments (cherry picked from commit cc48013)
…put (stablyai#16325) * test(terminal): pin park/reveal re-subscribe on a shared multiplexer Investigating STA-5098. Parking a mirrored remote tab closes its stream while a sibling tab keeps the multiplexer alive, so the reveal re-subscribes on an instance that already retired a stream. This came back green, which exonerates the multiplexer as the cause of STA-5098 — the wedge is above it. Kept as a contract guard; the header says explicitly that it is not coverage for that ticket. * fix(terminal): stop stale multiplex stream handles from swallowing input A stream handle whose record was dropped (park close, or a reconnect that clears the stream table) kept reporting success: sendFrame gates only on socket readiness, never on stream membership. The host drops those frames for an unknown stream id, so a revealed cold-parked remote pane looked connected while the PTY never saw a byte and never painted (STA-5098). Reporting success also defeated the transport's own recovery — it re-sends input over terminal.send when the stream refuses it, which never ran. Guard the three public senders on stream membership, the check close() and setOutputPaused already use, and drain input queued behind a viewport claim on every stream install rather than only a still-pending claim. Withdraws the parked-reveal re-subscribe test: its fake host answered Subscribe with an immediate snapshot, so it could not fail. * chore(terminal): tighten the stale-stream comments
…put (stablyai#16325) * test(terminal): pin park/reveal re-subscribe on a shared multiplexer Investigating STA-5098. Parking a mirrored remote tab closes its stream while a sibling tab keeps the multiplexer alive, so the reveal re-subscribes on an instance that already retired a stream. This came back green, which exonerates the multiplexer as the cause of STA-5098 — the wedge is above it. Kept as a contract guard; the header says explicitly that it is not coverage for that ticket. * fix(terminal): stop stale multiplex stream handles from swallowing input A stream handle whose record was dropped (park close, or a reconnect that clears the stream table) kept reporting success: sendFrame gates only on socket readiness, never on stream membership. The host drops those frames for an unknown stream id, so a revealed cold-parked remote pane looked connected while the PTY never saw a byte and never painted (STA-5098). Reporting success also defeated the transport's own recovery — it re-sends input over terminal.send when the stream refuses it, which never ran. Guard the three public senders on stream membership, the check close() and setOutputPaused already use, and drain input queued behind a viewport claim on every stream install rather than only a still-pending claim. Withdraws the parked-reveal re-subscribe test: its fake host answered Subscribe with an immediate snapshot, so it could not fail. * chore(terminal): tighten the stale-stream comments

ELI5
You park a remote terminal tab, come back to it, and it looks fine — the old output is there, it says it's connected — but typing does nothing and it never paints again. The client was writing keystrokes to a stream the host had already thrown away, and nobody noticed because the write kept reporting success.
What Changed
src/renderer/src/runtime/remote-runtime-terminal-multiplexer.tssendFramegates only on socket readiness (matchesCurrentEnvironmentRevision() && ready && subscription) and never checks that the stream id is still registered. A handle whose record had been dropped —close()on park, orhandleClose()clearing the stream table on reconnect — kept returningtruefromsendInput,claimViewport, andresize, while the host discarded those frames for an unknown stream id.Guard those three senders on stream membership via a new
isRegisteredStream. This is the same checkclose()andsetOutputPausedalready perform; these three were simply missing it.src/renderer/src/components/terminal-pane/remote-runtime-pty-transport.tsDrain input queued behind a viewport claim on every stream install, not only a still-pending claim. Previously a claim that was pending with no known viewport left its queued bytes orphaned. Extracted as
flushPendingClaimInput, called from both the live-claim path and the install path.Also drops a previously merged test whose fake host answered
Subscribewith an immediate snapshot — it was green by construction and could not fail for the reason it was written.Why
A revealed cold-parked remote terminal lost PTY input and live painting. The diagnostics from the failing CI run line up field for field with the stale-handle theory:
restoredBuffer: true— the snapshot arrived from the earlier, still-valid subscriptionhostReceivedInput: false,paintedLive: false— frames discarded host-side for an unknown stream idrecoveryState: "connected"— the client had no signal anything was wrongIt is a race: whether the stale handle is still the transport's current stream when the socket regains readiness. That is why it reproduces intermittently on CI and never on macOS.
The failure was silent rather than degraded because the transport already has a recovery path — when a stream refuses input it re-sends over the
terminal.sendRPC. The stale stream's false success is precisely what kept that fallback from running. Making the senders honest restores it.Membership-gating is the minimal fix and the one consistent with the surrounding code, rather than adding new reconnect bookkeeping.
Linked Issue
Tracked in the internal issue tracker; no public GitHub issue.
Visual Proof
N/A— no UI change. The behavioral change is covered by the E2E specs below, which assert on pane content and host-side input rather than appearance.Testing
Platform tested: macOS. CI covers Linux.
src/renderer/src/runtime/remote-runtime-terminal-stale-stream-frames.test.ts(new) drives the real multiplexer — not a fake host — and asserts a closed stream refuses input/claim/resize while a sibling stream keeps the socket live. Verified failing without the fix (expected true to be false), passing with it.tsc --noEmit -p config/tsconfig.tc.web.jsonclean;oxlintclean on changed files.tests/e2e/paired-remote-terminal-parked-reveal-interactivity.spec.tsandtests/e2e/host-parked-pane-remote-viewer.spec.ts. The cold-parked scenario now reportshostReceivedInput: true, paintedLive: true.Reviewer-relevant limits, stated plainly:
The local E2E pass is a non-regression check, not proof. That spec has never failed on macOS across repeated runs; only CI can confirm the fix.
The transport-side drain has no regression test of its own. I could not construct a deterministic repro for it — it is hardening on a reachable leak, not the proven root cause.
I wrote and then deleted a second unit case for the reconnect-orphan path: it passed without the fix, because
handleClosereleases the multiplexer instance so that handle's socket is already dead. It could not fail for the right reason, so it is not included.I manually tested these changes locally
Automated tests added/updated, or explained why not below
AI Disclosure
Review
Agent skill upstream boundary
docs/reference/agent-skill-sharing-upstream-boundary.mdand copies or mechanically translates no upstream skill-installer source, tests, fixtures, registry entries, path tables, comments, or documentation.Notes
Ensure no issues in: Security, Cross-platoform support (Linux, Windows, Mac), Remote SSH, Mobile, general backwards compatibility, performance
truereturn intofalse, and only for a stream that was already unusable. Callers already handlefalse— that is the existing fallback path.Map.getidentity comparison per frame send.Checklist
N/Awith reasonpnpm lint,pnpm typecheck,pnpm test, andpnpm buildpass (or CI will cover; local preferred)Ran locally: full
vitestsuite,typecheck:web, andoxlinton changed files — all clean. Did not run the fullpnpm lint/pnpm typecheck/pnpm buildmatrix; leaving that to CI.