Repository navigation
Remote pane renders blank forever when the client starts while its paired runtime is unreachable #18495
Description
Symptom
A terminal pane for a paired runtime environment renders completely blank — dark background, a single cursor block, no text — with no error banner and no retry. It stays blank indefinitely, including after the runtime becomes reachable again.
Observed live: a MacBook client paired to a Mac Mini runtime over Tailscale. The tunnel was down for ~10h after a macOS update left Tailscale stopped, and the client happened to start during the outage.
What made it confusing
The host side was healthy the entire time:
$ orca terminal show --environment "Mac Mini" --terminal term_ad84b983-...
connected: true
writable: true
preview: <real Claude Code TUI content>
orca terminal read --screen returned real content too. So the RPC pull path worked while the streamed pane showed nothing.
Also true, and each one a red herring in its own way:
- Focusing the tab did nothing.
- Resizing the pane (SIGWINCH) did nothing.
- The pty stayed at ~53 columns throughout — a mounted xterm drives its pty's dimensions, so this is the tell that no client ever attached.
- Creating a new pane worked fine, and recreating the session fixed it. The spawn path was never broken; only re-binding a pre-existing pane was.
Root cause
A client that starts while its runtime is unreachable attaches to already-open panes rather than creating them, so the failure lands in attach() rather than connect().
attach() never sets connected, so in handleRemoteTerminalError the recoverable branch calls scheduleResubscribeAfterTransportClose(), which returns immediately at its own guard:
if (destroyed || !connected || !handle) { return }The return that follows skips connecting = false, emitRecoveryState() and surfaceErrorMessage(). getRecoveryState() therefore reports 'connecting', which the banner filter drops — so there is no error surface, no backoff, no parked retry, and no Reconnect button.
connect() handles the identical error class by arming scheduleConnectRetryAfterRecoverableFailure(). That asymmetry is the bug.
Two follow-on issues in the same path:
attach()cancels recovery on entry, and its catch cancelled unconditionally, so a replay restarts the 250ms..30s ladder and the auto-recovery deadline never latches.connect()cancels only on the gone and fatal paths.useRemoteRuntimeRecoveryTriggersfires only ononlineand system-resume. Neither fires when a tailnet or VPN returns:navigator.onLinenever went false (the LAN never dropped) and there is no power transition. So even a correctly parked pane waits for a manual Reconnect.
Repro
- Quit the client.
- Make the paired runtime unreachable (disconnect Tailscale/VPN).
- Start the client and let it restore a worktree that has remote panes.
- Restore reachability.
Expected: panes reattach, or at minimum show a Reconnect affordance.
Actual: tabs are present, panes are permanently blank, no error, no retry.
Impact
The ordering is ordinary — laptop boots, the client restores its session, the VPN connects a few seconds later. A short outage usually hides it; a long one makes it permanent. The failure is silent, and the recovery a user naturally reaches for (closing the pane) can take a live agent session with it.
Fix
PR incoming. Unit-testable without a live remote; verified red/green (expected 'connecting' to be 'backoff' before the fix).
Activity
tian2-ai commentedon Sep 18, 2026
Adding a concrete, repeatable trigger for this, measured on a fleet that keeps hitting it.
The race is with Orca's own auto-update restart
macOS client paired to a macOS host over a tailnet. From the client's
main.trace.ndjson:The client relaunched itself from an auto-update and began attaching to already-open remote panes while the host runtime did not yet exist. That is exactly the window this issue describes — except nobody disconnected anything. The updater created the window.
This matters because it is systematic rather than incidental: the two machines are on independent auto-update cadences (client was on 1.4.204, host on 1.4.205), so every update round is another chance to hit it, and the affected panes then stay blank permanently.
Corroborating evidence, using this issue's own pty-width tell
The sibling pane had been created fresh after the client was already connected, so it went through
connect()rather thanattach(). Same workspace, same host, same client, opposite outcome — which lines up with theconnect()/attach()asymmetry in the root cause above.Other notes from this instance
<agent> --resume <session-id>did. That second state — attached, correct geometry, still no frames — is what I filed separately as [Bug]: Remote client pane stays blank forever while still driving the host pty geometry — refit, tab switch, workspace switch and client restart all fail; only close+recreate recovers #21208; it may or may not be the same defect reached later.If #18496 lands, its retry would presumably cover this case, since the runtime does become reachable ~49s later. A smaller independent mitigation: hold off attaching remote panes until the environment connection has settled after an updater-initiated relaunch, since that relaunch is a moment the app already knows about.
luisgonzaleznf commentedon Sep 21, 2026
Thank you — the updater trace is the part I didn't have, and it changes how this should be prioritised.
My repro needed someone to disconnect a VPN by hand, which made the whole thing look incidental. Your timeline makes it structural:
The app relaunches itself into exactly the window this issue describes, and the two machines are on independent update cadences, so there is no version pairing a user can choose that avoids it. Every update round is another roll.
The geometry table settles the mechanism, too. 48x195 (host fit, unchanged for a day) on the blank pane against 40x101 (client fit) on a sibling in the same workspace is the
connect()/attach()split reproduced on hardware I've never touched: the sibling was created after the client was already connected, so it went throughconnect()and got the retry; the blank one went throughattach()and got the silent latch.Whether #18496 covers this
I believe it does, and by a wider margin than I would have guessed before checking the numbers.
The auto-recovery window is derived from the ladder rather than hand-tuned (
REMOTE_RUNTIME_AUTO_RECOVERY_TIMEOUT_MS):Your 49s gap sits well inside that. With #18496 armed, the attach failure schedules a retry instead of latching silently, and the pane should reattach on its own within about a minute of the host coming up at 13:28:56 — worst case on the 4th or 5th rung, sooner if the attempts fail fast rather than burning the full 15s budget. The environment-reachable trigger is then a fast path, not the thing rescuing it. So this case should never reach a user-visible
disconnected, let alone a permanent blank.Caveat, and it's the same one in the PR body: the unit tests prove the transport state machine, not the end-to-end render. I have not run the deliberate repro against a build carrying the patch, for either the VPN case or yours. If you can reproduce this across update rounds on a fleet, that is a far better test bed than my manual repro — happy to hand you a build to confirm before it merges.
On deferring the attach
Worth having, but I'd treat it as defence in depth rather than the fix. The retry is general: it covers every cause of the window, including the ones the app cannot see coming — a sleeping host, a slow tailnet, the VPN case I filed. Holding the attach until the connection settles only helps the one cause the app knows about in advance. If the retry lands and behaves as above, the updater case should already be invisible; if it isn't, that measurement is the argument for the deferral.
One label note
os:macoswas applied here yesterday, but your report has Windows clients hitting this as well, and Codex TUIs alongside Claude Code. The defect is in the shared transport path — nothing inattach()is platform-specific. Probably worth dropping that label so it doesn't filter out of Windows triage.Agreed that #21208 is a different state and should stay separate — you've already made that case better than I could.