fix(remote): don't replace a pane that could not be reached with a fresh shell

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.
This commit is contained in:
l0ng-ai
2026-09-30 11:58:03 +08:00
parent b0b7589e11
commit 1caec8febd
3 changed files with 92 additions and 2 deletions
+17 -1
View File
@@ -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,
};
+22 -1
View File
@@ -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;
}
};
+53
View File
@@ -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<Duration> {
(restores && made < AUTO_RETRY_LIMIT).then(|| Duration::from_secs(1 << (made + 1).min(4)))
}
impl EventEmitter<RetryRequested> 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<Duration> {
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<SharedString>, cx: &mut Context<Self>) {
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<u64> = (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"
);
}
}