From 1caec8febd39af7a2f3a70b91a55c6089428dd4a Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Wed, 30 Sep 2026 11:58:03 +0800 Subject: [PATCH] fix(remote): don't replace a pane that could not be reached with a fresh shell MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reattaching a restored pane treated every failure other than silence as "the pane is gone on its machine" and spawned a fresh shell in its place. That includes the link never opening at all — the server refusing another session, a transport error — when the pane is still running over there. The tab lost its session, and the old shell was left orphaned on the remote daemon; one workspace had accumulated 53 shells for 17 panes. Only the daemon's explicit "no such pane" now means gone. For a remote pane, any other failure leaves it pending, and a pane that stands in for a running one retries on its own a few times (2s, 4s, 8s, 16s, 16s) before leaving it to Try Again. Local panes keep their previous behavior. --- src/terminal/view.rs | 18 +++++++++++++- src/ui/app.rs | 23 +++++++++++++++++- src/ui/pending_pane.rs | 53 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 92 insertions(+), 2 deletions(-) diff --git a/src/terminal/view.rs b/src/terminal/view.rs index acab626b..d6d1e9c6 100644 --- a/src/terminal/view.rs +++ b/src/terminal/view.rs @@ -1493,10 +1493,26 @@ impl TerminalView { log::warn!("attach to pane {id} went unanswered ({e:#}); spawning fresh"); None } - Err(e) => { + Err(e) if crate::terminal::attach_refused(&e) => { log::info!("pane {id} is gone on its machine ({e:#}); spawning fresh"); None } + // The daemon never got to say either way: the link to its + // machine would not open, or broke before it answered. The pane + // is most likely still running over there, so a fresh shell in + // its place would orphan it — its scrollback, its agent session, + // whatever it was doing — and put a stranger in its tab. Fail, + // and let a retry reach the pane itself. + Err(e) if !route.is_local() => { + log::info!("pane {id} could not be reached to reattach ({e:#})"); + return Err(e.context(format!( + "could not reach pane {id}, which may still be running" + ))); + } + Err(e) => { + log::info!("pane {id} is gone ({e:#}); spawning fresh"); + None + } }, None => None, }; diff --git a/src/ui/app.rs b/src/ui/app.rs index f78a01bf..09ff1903 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -3886,7 +3886,28 @@ impl Tty7App { let parts = match parts { Ok(parts) => parts, Err(reason) => { - pending.update(cx, |p, cx| p.fail(reason, cx)); + let retry = pending.update(cx, |p, cx| { + p.fail(reason, cx); + p.next_auto_retry() + }); + if let Some(delay) = retry { + let pending = pending.clone(); + cx.spawn_in(window, async move |this, cx| { + cx.background_executor().timer(delay).await; + let _ = this.update_in(cx, |app, window, cx| { + let still_there = app.tabs.iter().any(|tab| { + tab.pane.leaves().iter().any(|l| l.entity_id() == slot_id) + }); + // Try Again got there first, or the tab is gone. + if !still_there || !pending.read(cx).is_failed() { + return; + } + pending.update(cx, |p, cx| p.retrying(cx)); + start_pane_spawn(pending.clone(), window, cx); + }); + }) + .detach(); + } return; } }; diff --git a/src/ui/pending_pane.rs b/src/ui/pending_pane.rs index 0428c1d5..42aa46b4 100644 --- a/src/ui/pending_pane.rs +++ b/src/ui/pending_pane.rs @@ -40,6 +40,19 @@ pub struct PendingPane { pub machine: SharedString, pub state: PendingState, pub spawn: PendingSpawn, + /// Retries made without anyone pressing Try Again. See + /// [`PendingPane::next_auto_retry`]. + auto_retries: u32, +} + +/// How many times a pane standing in for one that may still be running asks +/// again on its own before it leaves the question to Try Again. +const AUTO_RETRY_LIMIT: u32 = 5; + +/// 2s, 4s, 8s, 16s, 16s — about three quarters of a minute in all, which +/// covers a reconnect without keeping a machine that is really gone busy. +fn auto_retry_delay(restores: bool, made: u32) -> Option { + (restores && made < AUTO_RETRY_LIMIT).then(|| Duration::from_secs(1 << (made + 1).min(4))) } impl EventEmitter for PendingPane {} @@ -55,9 +68,26 @@ impl PendingPane { machine: machine.into(), state: PendingState::Connecting, spawn, + auto_retries: 0, } } + /// How long to wait before asking again on its own, or `None` once it has + /// asked enough. Only a pane that stands in for one already running on its + /// machine retries by itself: the failure that leaves it here is the link, + /// not the pane, and the link usually comes back within seconds — the + /// pane behind it has been running the whole time. A brand-new pane that + /// failed has nothing waiting for it; Try Again is the user's call. + pub fn next_auto_retry(&mut self) -> Option { + let delay = auto_retry_delay(self.spawn.restore_pane.is_some(), self.auto_retries)?; + self.auto_retries += 1; + Some(delay) + } + + pub fn is_failed(&self) -> bool { + matches!(self.state, PendingState::Failed(_)) + } + pub fn fail(&mut self, reason: impl Into, cx: &mut Context) { self.state = PendingState::Failed(reason.into()); cx.notify(); @@ -139,3 +169,26 @@ impl Render for PendingPane { .child(body) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_a_pane_standing_in_for_a_running_one_retries_by_itself() { + assert_eq!( + auto_retry_delay(false, 0), + None, + "a new pane has nothing waiting" + ); + let delays: Vec = (0..) + .map_while(|made| auto_retry_delay(true, made)) + .map(|d| d.as_secs()) + .collect(); + assert_eq!( + delays, + vec![2, 4, 8, 16, 16], + "then Try Again is the user's" + ); + } +}