From c138be687ae3f06bbdeb8f54d9a7bdbb9777df0b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:32:28 +0800 Subject: [PATCH 1/7] fix(daemon): keep a pane's shell and its screen across a restart MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two things a pane lost when the background service stopped and started, both of them things the tree was the only possible place to keep. **The shell.** `PaneRecord` and `PaneSeed` carried a pane's cwd, its ssh spec and its agent, but never what it was running. A window rebuilding a dead pane from the tree therefore had nothing to pass and spawned on whatever the default shell is now — so a restart turned a bash pane into a PowerShell one, quietly and in place. The daemon resolves the override against the config at spawn time and is the only party that knows the answer, so it keeps it and reports it; the seed carries it too, for the panes a window spawned itself. A handoff carries it in the blob, because nothing on the far side of an `execve` can work out the command line of a child it never spawned. **The screen.** The startup sweep ran before the endpoint was listening, which is the one moment nothing can answer the question it asks: the registry is empty and the windows that know which screens are still wanted cannot say so yet. A tree that failed to parse made it worse — `read_machine` quarantines it and returns an empty `Machine`, so one bad file took every pane's stored screen with it. The sweep now happens only on the periodic pass, a tick later, with the registry filled in and the tree caught up; nothing is serving a request in between. Turning the setting *off* still clears the directory at once, because there the promptness is the whole promise. Two smaller ones alongside it: `restorable_pane_ids` now counts the tree's pane list and not only the panes some tab currently stands on — the two disagree while a window is between layouts, and being wrong costs a file swept a tick late in one direction and somebody's terminal in the other. And `restored_screen` drops the snapshot file *after* deciding it was not empty, so a snapshot holding nothing is no longer consumed by the request it could not answer. The restore path had no end-to-end test, which is how this shipped: the unit tests cover the file, not whether a window that reattaches is shown anything. The new one runs a real daemon, puts a marker on a real pane, stops the daemon, starts another, and reads the wire. --- crates/tty7-cli/src/commands.rs | 8 + crates/tty7-cli/src/testbed.rs | 1 + crates/tty7-core/src/core/machine.rs | 20 +- crates/tty7-core/src/core/session.rs | 6 + crates/tty7-core/src/daemon/handoff.rs | 5 + crates/tty7-core/src/daemon/pane.rs | 59 ++- crates/tty7-core/src/daemon/server.rs | 40 +- crates/tty7-core/src/host/server.rs | 1 + crates/tty7-server/tests/client_lib.rs | 1 + .../tty7-server/tests/scrollback_restore.rs | 402 ++++++++++++++++++ src/ui/app.rs | 10 +- src/ui/home.rs | 1 + src/ui/tree_sync.rs | 13 +- 13 files changed, 542 insertions(+), 25 deletions(-) create mode 100644 crates/tty7-server/tests/scrollback_restore.rs diff --git a/crates/tty7-cli/src/commands.rs b/crates/tty7-cli/src/commands.rs index 797c5ec0..8daa3486 100644 --- a/crates/tty7-cli/src/commands.rs +++ b/crates/tty7-cli/src/commands.rs @@ -346,6 +346,7 @@ fn new_workspace(path: Option, open: bool, backend: &mut dyn Backend) -> cwd: path, ssh_spec: None, agent: None, + shell: None, }, tab: None, })?; @@ -418,6 +419,7 @@ fn run(args: RunArgs, ctx: &Context, backend: &mut dyn Backend) -> Result Resu cwd, ssh_spec: None, agent: None, + shell: None, }, first: false, })?; @@ -595,6 +598,7 @@ fn tab_new( cwd, ssh_spec: None, agent: None, + shell: None, }, tab: None, })? { @@ -1397,6 +1401,7 @@ mod tests { cwd: Some("C:\\newproj".into()), ssh_spec: None, agent: None, + shell: None, }, tab: None, }, @@ -1635,6 +1640,7 @@ mod tests { cwd: Some("C:\\elsewhere".into()), ssh_spec: None, agent: None, + shell: None, }, tab: None, } @@ -1668,6 +1674,7 @@ mod tests { cwd: Some("C:\\proj".into()), ssh_spec: None, agent: None, + shell: None, }, first: false, }, @@ -1898,6 +1905,7 @@ mod tests { cwd: Some("C:\\proj".into()), ssh_spec: None, agent: None, + shell: None, }, tab: None, }, diff --git a/crates/tty7-cli/src/testbed.rs b/crates/tty7-cli/src/testbed.rs index 71894e13..adb7d3c6 100644 --- a/crates/tty7-cli/src/testbed.rs +++ b/crates/tty7-cli/src/testbed.rs @@ -47,6 +47,7 @@ pub fn two_workspace_machine() -> Machine { title: String::new(), ssh_spec: None, agent: None, + shell: None, live: true, }; Machine { diff --git a/crates/tty7-core/src/core/machine.rs b/crates/tty7-core/src/core/machine.rs index db0ca399..67c2526e 100644 --- a/crates/tty7-core/src/core/machine.rs +++ b/crates/tty7-core/src/core/machine.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; use crate::core::cli_agent::CLIAgent; use crate::core::session::WorkspaceId; -use crate::daemon::protocol::NativeSshSpec; +use crate::daemon::protocol::{NativeSshSpec, ShellSpec}; pub const MACHINE_FILE: &str = "machine.json"; @@ -276,6 +276,15 @@ pub struct PaneRecord { pub ssh_spec: Option>, #[serde(default)] pub agent: Option, + /// What the pane is actually running, resolved: the spawn's override if it + /// had one, otherwise the shell the config named at the time. + /// + /// Without it a pane rebuilt from this tree comes back on whatever the + /// default shell is now, so a daemon restart silently turns a bash pane + /// into a PowerShell one. The rest of the record describes where a pane is + /// and what is running in it; this is the part that says what it *is*. + #[serde(default)] + pub shell: Option, #[serde(default)] pub live: bool, } @@ -288,6 +297,7 @@ impl PaneRecord { title: String::new(), ssh_spec: None, agent: None, + shell: None, live: false, } } @@ -313,6 +323,11 @@ pub struct PaneSeed { pub ssh_spec: Option>, #[serde(default)] pub agent: Option, + /// See [`PaneRecord::shell`]. Carried here too so a pane that reaches the + /// tree as a seed — a split, a `tty7` CLI call — names its shell from the + /// start rather than only once the daemon has observed it. + #[serde(default)] + pub shell: Option, } impl PaneSeed { @@ -322,6 +337,7 @@ impl PaneSeed { cwd: None, ssh_spec: None, agent: None, + shell: None, } } @@ -332,6 +348,7 @@ impl PaneSeed { title: String::new(), ssh_spec: self.ssh_spec.map(|s| Box::new(s.without_secrets())), agent: self.agent, + shell: self.shell, live, } } @@ -1364,6 +1381,7 @@ mod tests { cwd: Some(cwd.to_string()), ssh_spec: None, agent: None, + shell: None, } } diff --git a/crates/tty7-core/src/core/session.rs b/crates/tty7-core/src/core/session.rs index 0118858c..3c0e5046 100644 --- a/crates/tty7-core/src/core/session.rs +++ b/crates/tty7-core/src/core/session.rs @@ -17,6 +17,12 @@ pub enum SessionPane { cwd: Option, #[serde(default)] pane_id: Option, + /// The shell this pane was running. Without it a pane that has to be + /// respawned — its daemon restarted, or this is a cold start — comes + /// back on whatever the default shell is, which is how a bash pane + /// turns into a PowerShell one. + #[serde(default)] + shell: Option, #[serde(default)] ssh_spec: Option>, #[serde(default)] diff --git a/crates/tty7-core/src/daemon/handoff.rs b/crates/tty7-core/src/daemon/handoff.rs index b608dd9f..e82db450 100644 --- a/crates/tty7-core/src/daemon/handoff.rs +++ b/crates/tty7-core/src/daemon/handoff.rs @@ -83,6 +83,8 @@ struct PaneRecord { integration_dir: Option, size: WinSize, cwd: Option, + #[serde(default)] + shell: Option, shell_active: bool, at_prompt: bool, last_exit: Option, @@ -227,6 +229,7 @@ fn stage(panes: &[Carried], next_pane_id: u64) -> std::io::Result integration_dir: pane.integration_dir.clone(), size: pane.size, cwd: pane.cwd.clone(), + shell: pane.shell_spec.clone(), shell_active: pane.shell_active, at_prompt: pane.at_prompt, last_exit: pane.last_exit, @@ -358,6 +361,7 @@ pub fn adopt(fd: RawFd) -> Option { size: record.size, ring, cwd: record.cwd, + shell_spec: record.shell, shell_active: record.shell_active, at_prompt: record.at_prompt, last_exit: record.last_exit, @@ -400,6 +404,7 @@ mod tests { bytes: output.to_vec(), }], cwd: Some(PathBuf::from("/work")), + shell_spec: None, shell_active: true, at_prompt: true, last_exit: Some(0), diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 2e37489d..e0d4b252 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -111,6 +111,10 @@ struct SpawnConfig { initial_cwd: Option, integration_dir: Option, remote: Option, + /// The shell this pane actually got, after the override and the config have + /// been resolved against each other. Recorded so the machine tree can name + /// it — see [`crate::core::machine::PaneRecord::shell`]. + shell: Option, } /// Why a configured shell cannot be run, in one sentence, before anything @@ -156,12 +160,21 @@ fn build_spawn_config( anyhow::bail!(problem); } let remote = wsl_remote_context(configured.as_ref()); + // Taken before the chosen shell is consumed by the command builder: what + // goes in the tree is what was resolved here, not the possibly-empty + // override the caller sent. + let shell = configured.as_ref().map(|c| ShellSpec { + program: c.program.clone(), + args: c.args.clone(), + args_are_tty7_defaults: c.args_are_tty7_defaults, + }); let (cmd, integration_dir) = build_shell_command(configured, &initial_cwd, pane, workspace)?; Ok(SpawnConfig { cmd, initial_cwd, integration_dir, remote, + shell, }) } @@ -630,6 +643,9 @@ struct PaneState { observer_seq: u64, cwd: Option, shell: ShellState, + /// What this pane is running, for the machine tree to record. Distinct from + /// `shell` above, which is the shell-integration state. + shell_spec: Option, remote: Option, agent: Option, agent_argv: Option>, @@ -954,6 +970,11 @@ pub struct Carried { pub size: WinSize, pub ring: Vec, pub cwd: Option, + /// What the pane is running. Nothing on the other side of the exec can work + /// it out again — the command line belongs to a child this image never + /// spawned — so a handoff that dropped it would leave the tree naming no + /// shell for a pane that plainly has one. + pub shell_spec: Option, pub shell_active: bool, pub at_prompt: bool, pub last_exit: Option, @@ -1268,6 +1289,7 @@ impl DaemonPane { observer_seq: 0, cwd: spawn.initial_cwd, shell: ShellState::default(), + shell_spec: spawn.shell.clone(), remote: spawn.remote.clone(), agent: None, agent_session: None, @@ -1415,6 +1437,7 @@ impl DaemonPane { size: st.ring.tail_size(), ring: st.ring.snapshot(), cwd: st.cwd.clone(), + shell_spec: st.shell_spec.clone(), shell_active: st.shell.active, at_prompt: st.shell.at_prompt, last_exit: st.shell.last_exit_code, @@ -1477,6 +1500,7 @@ impl DaemonPane { observers: Vec::new(), observer_seq: 0, cwd: carried.cwd, + shell_spec: carried.shell_spec, shell: ShellState { active: carried.shell_active, at_prompt: carried.at_prompt, @@ -1526,6 +1550,9 @@ impl DaemonPane { subscriber_epoch: 0, observers: Vec::new(), observer_seq: 0, + // A native ssh pane is not running a shell of this machine's; what + // it is, `ssh_spec` already says. + shell_spec: None, cwd: None, shell: ShellState::default(), remote: Some(remote), @@ -1814,12 +1841,15 @@ impl DaemonPane { && let (Some(before), Some(after)) = (facts_before, facts_after) && facts_changed(&before, &after) { - let (cwd, agent) = after; + let (cwd, agent, shell) = after; crate::core::machine::observe_pane(pane, |p| { if cwd.is_some() { p.cwd = cwd; } p.agent = agent; + if shell.is_some() { + p.shell = shell; + } if alive { p.live = true; } @@ -2373,7 +2403,13 @@ fn agent_state_snapshot(st: &PaneState) -> Option (Option, Option) { +type ObservedFacts = ( + Option, + Option, + Option, +); + +fn observed_facts(st: &PaneState) -> ObservedFacts { let cwd = st.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()); let agent = st.agent.map(|agent| crate::core::machine::AgentFacts { agent, @@ -2385,14 +2421,13 @@ fn observed_facts(st: &PaneState) -> (Option, Option, Option), - after: &(Option, Option), -) -> bool { - before.0 != after.0 || agent_facts_changed(before.1.as_ref(), after.1.as_ref()) +fn facts_changed(before: &ObservedFacts, after: &ObservedFacts) -> bool { + before.0 != after.0 + || agent_facts_changed(before.1.as_ref(), after.1.as_ref()) + || before.2 != after.2 } fn agent_facts_changed( @@ -3923,6 +3958,7 @@ mod tests { subscriber_epoch: 0, observers: Vec::new(), observer_seq: 0, + shell_spec: None, cwd: None, shell: ShellState::default(), remote: None, @@ -3939,7 +3975,7 @@ mod tests { use crate::core::cli_agent::{AgentSessionState, AgentStatus, CLIAgent}; let mut st = test_state(true); - assert_eq!(observed_facts(&st), (None, None)); + assert_eq!(observed_facts(&st), (None, None, None)); st.cwd = Some(PathBuf::from("/work/api")); st.agent = Some(CLIAgent::Claude); @@ -3951,7 +3987,7 @@ mod tests { ..Default::default() }); - let (cwd, agent) = observed_facts(&st); + let (cwd, agent, _shell) = observed_facts(&st); assert_eq!(cwd.as_deref(), Some("/work/api")); let agent = agent.expect("an agent in the foreground is a fact"); assert_eq!(agent.agent, CLIAgent::Claude); @@ -3964,7 +4000,7 @@ mod tests { assert_eq!(agent.status, Some(AgentStatus::Working)); st.agent_session = None; - let (_, agent) = observed_facts(&st); + let (_, agent, _) = observed_facts(&st); assert_eq!( agent.unwrap().launch_argv.as_deref(), Some(&["claude".to_string()][..]) @@ -3998,6 +4034,7 @@ mod tests { launch_argv: Some(vec!["claude".to_string()]), status: None, }), + shell: None, }, None, None, diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 2ff1dee8..97b6fd4c 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -159,14 +159,21 @@ fn restorable_pane_ids(registry: &Registry) -> std::collections::HashSet { let mut ids: std::collections::HashSet = registry.list().into_iter().map(|p| p.pane_id).collect(); if let Some(store) = crate::core::machine::observed_store() { + let machine = store.machine(); ids.extend( - store - .machine() + machine .workspaces .iter() .flat_map(|w| w.tabs.iter()) .flat_map(|t| t.root.pane_ids()), ); + // And the pane list, not only the panes some tab currently stands on. + // The two disagree while a window is between layouts — a pane whose + // tab has been taken down and not yet put back is still a pane the + // tree knows about — and the cost of being wrong is asymmetric: an + // extra file is swept a tick later, a missing one is somebody's + // terminal. + ids.extend(machine.panes.iter().map(|p| p.id)); } ids } @@ -243,10 +250,13 @@ fn restored_screen( return None; } let segments = crate::daemon::scrollback::load(request.pane_id)?; - crate::daemon::scrollback::forget(request.pane_id); if segments.is_empty() { return None; } + // After the emptiness check, not before it: dropping the file is how a + // screen that *was* handed out stops being handed out twice, and a + // snapshot that turned out to hold nothing was never handed out at all. + crate::daemon::scrollback::forget(request.pane_id); log::info!( "pane {} is gone; its last screen is restored into a fresh pane", request.pane_id @@ -559,14 +569,24 @@ fn run_with(registry: Arc) -> anyhow::Result<()> { } spawn_orphan_sweep(registry.clone()); - // Before the writer starts: a daemon that has just come up owns no panes, - // so everything on disk belongs to panes the machine tree either still - // names — those are the ones a window is about to ask to restore — or has - // forgotten, and the latter are nobody's to restore any more. let restorable = restorable_pane_ids(®istry); - if crate::daemon::scrollback::enabled() { - crate::daemon::scrollback::sweep(&restorable); - } else { + // Deliberately not swept here while the setting is on. Startup is the one + // moment this process knows least: it owns no panes yet, and the windows + // that know which screens are still wanted cannot say so until the + // endpoint below is listening. Answering "is anyone going to ask for + // this?" here answers it when nobody can — and the answer deletes. A tree + // that failed to parse makes it worse, because `read_machine` quarantines + // it and hands back an empty `Machine`, so one bad file would take every + // pane's screen with it. + // + // The periodic sweep asks the same question a tick later, with the + // registry filled in and the tree caught up, and that is soon enough: + // nothing here is serving a request in the meantime. + // + // Off is not the same question. Then nothing on disk is worth keeping and + // deleting it promptly is the setting's whole promise, so that one still + // happens before anything else runs. + if !crate::daemon::scrollback::enabled() { crate::daemon::scrollback::sweep(&std::collections::HashSet::new()); } // A daemon that was killed outright never retired anything, so the files of diff --git a/crates/tty7-core/src/host/server.rs b/crates/tty7-core/src/host/server.rs index 9ad1846f..71864287 100644 --- a/crates/tty7-core/src/host/server.rs +++ b/crates/tty7-core/src/host/server.rs @@ -1716,6 +1716,7 @@ mod aggregate_tests { cwd: Some("/repo/tty7".into()), ssh_spec: None, agent: None, + shell: None, }, None, None, diff --git a/crates/tty7-server/tests/client_lib.rs b/crates/tty7-server/tests/client_lib.rs index 26896704..b17a18a0 100644 --- a/crates/tty7-server/tests/client_lib.rs +++ b/crates/tty7-server/tests/client_lib.rs @@ -137,6 +137,7 @@ fn seed(pane: u64) -> PaneSeed { cwd: Some("/home/me/proj".into()), ssh_spec: None, agent: None, + shell: None, } } diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs new file mode 100644 index 00000000..2664a477 --- /dev/null +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -0,0 +1,402 @@ +//! A pane's screen survives the daemon being stopped and started. +//! +//! The unit tests under `daemon::scrollback` cover the file: it round-trips, it +//! is trimmed from the front, the sweep keeps what it is told to keep. None of +//! that answers the question the feature exists for, which is whether a window +//! that comes back to a restarted daemon is shown what its panes had on them. +//! That answer involves a real daemon writing a real snapshot, dying, and a +//! second daemon handing the bytes to the client that asks — so this runs all +//! of it and reads the wire. +//! +//! The ordering is the whole difficulty. A client cannot ask for a restore +//! until the daemon is listening, so anything the daemon throws away *while +//! starting up* is thrown away before the only party who knows what is still +//! wanted has been able to say so. + +use std::io::Write as _; +use std::path::PathBuf; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use tty7_core::client::{ControlClient, PaneClient}; +use tty7_core::core::machine::PaneSeed; +use tty7_core::daemon::control::{ControlHello, ControlRequest, ReplyOk}; +use tty7_core::daemon::protocol::{ClientMsg, DaemonMsg, RestoreFrom, ShellSpec, WinSize}; +use tty7_core::daemon::transport; + +const READY_WITHIN: Duration = Duration::from_secs(30); +const STREAM_WITHIN: Duration = Duration::from_secs(30); +const STOP_WITHIN: Duration = Duration::from_secs(30); + +const MARKER: &[u8] = b"tty7_screen_kept"; + +/// One instance's directories, outliving the daemons that serve them — the +/// point of the test is what is on disk between two of them. +struct Instance { + dir: tempfile::TempDir, +} + +impl Instance { + fn new() -> Instance { + let dir = tempfile::TempDir::new().unwrap(); + std::fs::write( + dir.path().join("config.json"), + r#"{"persist_scrollback": true}"#, + ) + .unwrap(); + Instance { dir } + } + + fn path(&self) -> &std::path::Path { + self.dir.path() + } + + /// What the daemon writes down for clients to find it by: a socket on unix, + /// a port-and-token file on Windows. + fn endpoint(&self) -> PathBuf { + #[cfg(unix)] + let name = "daemon.sock"; + #[cfg(windows)] + let name = "daemon.port"; + self.dir.path().join(name) + } + + fn panes(&self) -> PaneClient { + PaneClient::at(self.endpoint()) + } + + fn control_endpoint(&self) -> PathBuf { + #[cfg(unix)] + let name = "control.sock"; + #[cfg(windows)] + let name = "control.port"; + self.dir.path().join(name) + } + + fn snapshot_of(&self, pane_id: u64) -> Option> { + std::fs::read( + self.dir + .path() + .join("scrollback") + .join(format!("{pane_id}.bin")), + ) + .ok() + } + + /// The tree a window would have left behind: one workspace, one tab, and + /// that tab standing on the pane whose screen we want back. + fn record_tab_on(&self, pane_id: u64) { + let tree = format!( + r#"{{ + "workspaces": [ + {{ + "id": "11111111-2222-3333-4444-555555555555", + "name": null, + "last_active": 1786343761, + "tabs": [ + {{ + "id": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "name": null, + "sidebar_group": null, + "root": {{ "Leaf": {{ "pane": {pane_id} }} }} + }} + ], + "active_tab": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + }} + ], + "panes": [ + {{ "id": {pane_id}, "cwd": null, "title": "", "ssh_spec": null, "agent": null, "live": false }} + ] +}}"# + ); + std::fs::write(self.dir.path().join("machine.json"), tree).unwrap(); + } + + fn start(&self) -> Running { + let child = Command::new(env!("CARGO_BIN_EXE_tty7-server")) + .arg("--daemon") + .arg("--config-dir") + .arg(self.path()) + .env("TTY7_DATA_DIR", self.path()) + .env("TTY7_CONTROL_SOCK", self.path().join("control.sock")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start tty7-server --daemon"); + let running = Running { + child, + stopped: false, + }; + let deadline = Instant::now() + READY_WITHIN; + loop { + if self.panes().version().is_ok() { + return running; + } + assert!( + Instant::now() < deadline, + "the daemon did not open its pane endpoint within {READY_WITHIN:?}" + ); + std::thread::sleep(Duration::from_millis(50)); + } + } + + /// Ask the daemon to go, the way the restart in Settings asks. This is the + /// path that takes each pane's last snapshot on the way out, so a test that + /// killed the process instead would be testing the periodic writer. + fn stop(&self, mut running: Running) { + let mut stream = + transport::connect_endpoint_at(&self.endpoint()).expect("connect to ask for shutdown"); + ClientMsg::Shutdown + .encode(&mut stream) + .expect("send Shutdown"); + stream.flush().ok(); + drop(stream); + + let deadline = Instant::now() + STOP_WITHIN; + loop { + match running.child.try_wait() { + Ok(Some(_)) => { + running.stopped = true; + return; + } + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + Ok(None) => panic!("the daemon did not exit within {STOP_WITHIN:?}"), + Err(e) => panic!("waiting for the daemon failed: {e}"), + } + } + } + + /// The `Spawn` a window sends for a pane whose `Attach` found nothing: a + /// new shell, asked to open showing what the dead one had. + fn spawn_restoring(&self, dead: u64) -> (u64, Vec) { + let mut stream = + transport::connect_endpoint_at(&self.endpoint()).expect("connect to spawn"); + ClientMsg::Spawn { + cwd: None, + size: size(), + shell: Some(interactive_shell()), + owner: None, + workspace: None, + restore: Some(RestoreFrom { + pane_id: dead, + banner: Some("the shell below is new".to_string()), + }), + } + .encode(&mut stream) + .expect("send Spawn"); + + let pane_id = match DaemonMsg::read(&mut stream) { + Ok(DaemonMsg::Spawned { pane_id }) => pane_id, + other => panic!("expected Spawned, got {other:?}"), + }; + + // Everything the daemon replays sits in the socket ahead of whatever + // the new shell writes, so a bounded drain is enough: the replay is + // already queued by the time `Spawned` is read. + stream + .set_read_timeout(Some(Duration::from_secs(2))) + .expect("bound the drain"); + let mut replayed = Vec::new(); + while let Ok(msg) = DaemonMsg::read(&mut stream) { + match msg { + DaemonMsg::Snapshot(bytes) | DaemonMsg::Output(bytes) => { + replayed.extend_from_slice(&bytes) + } + _ => {} + } + } + (pane_id, replayed) + } +} + +struct Running { + child: Child, + stopped: bool, +} + +impl Drop for Running { + fn drop(&mut self) { + if !self.stopped { + let _ = self.child.kill(); + let _ = self.child.wait(); + } + } +} + +fn size() -> WinSize { + WinSize { + cols: 100, + rows: 30, + cell_w: 8, + cell_h: 16, + } +} + +fn interactive_shell() -> ShellSpec { + #[cfg(unix)] + let program = "/bin/sh"; + #[cfg(windows)] + let program = "cmd.exe"; + ShellSpec { + program: program.into(), + args: Vec::new(), + args_are_tty7_defaults: false, + } +} + +fn windows_contain(haystack: &[u8], needle: &[u8]) -> bool { + haystack.windows(needle.len()).any(|w| w == needle) +} + +fn collect_until(session: &mut tty7_core::client::PaneSession, marker: &[u8]) -> Vec { + let mut seen: Vec = Vec::new(); + loop { + match session.recv() { + Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => { + seen.extend_from_slice(&bytes); + if windows_contain(&seen, marker) { + return seen; + } + } + Ok(DaemonMsg::Exited { code }) => panic!( + "the pane exited ({code:?}) before {:?} appeared; saw {:?}", + String::from_utf8_lossy(marker), + String::from_utf8_lossy(&seen) + ), + Ok(_) => {} + Err(e) => panic!( + "the pane stream ended early: {e}; saw {:?}", + String::from_utf8_lossy(&seen) + ), + } + } +} + +/// Put a marker on a pane's screen and give the daemon back the pane's id. +fn pane_showing_the_marker(instance: &Instance) -> u64 { + let mut session = instance + .panes() + .spawn(None, size(), Some(interactive_shell()), None, None) + .expect("spawn a pane"); + session + .set_recv_timeout(Some(STREAM_WITHIN)) + .expect("bound the stream reads"); + let pane_id = session.pane_id(); + session + .input(format!("echo {}\r", String::from_utf8_lossy(MARKER)).as_bytes()) + .expect("the shell takes input"); + collect_until(&mut session, MARKER); + // Detach rather than kill: the pane outlives this connection, which is the + // state a daemon restart finds its panes in. + session.detach().expect("detach"); + pane_id +} + +/// The case the feature is for: nothing has told the new daemon anything yet, +/// because the window that would tell it is still waiting for it to listen. +#[test] +fn a_restarted_daemon_still_has_the_screen_when_the_window_asks() { + let instance = Instance::new(); + let running = instance.start(); + let dead = pane_showing_the_marker(&instance); + instance.stop(running); + + let stored = instance + .snapshot_of(dead) + .expect("the shutdown wrote the pane's screen"); + assert!( + windows_contain(&stored, MARKER), + "the snapshot on disk does not hold the pane's screen" + ); + + let _restarted = instance.start(); + let (_new_pane, replayed) = instance.spawn_restoring(dead); + assert!( + windows_contain(&replayed, MARKER), + "the restarted daemon did not give the pane back its screen; \ + the client received {:?}", + String::from_utf8_lossy(&replayed) + ); +} + +/// What a pane is running has to reach the tree, because that is the only +/// place a window rebuilding the pane can read it from. Without it the rebuild +/// falls back to the default shell, which is how a restart turns a bash pane +/// into a PowerShell one. +#[test] +fn the_tree_records_the_shell_a_pane_is_running() { + let instance = Instance::new(); + let _running = instance.start(); + let pane = pane_showing_the_marker(&instance); + + // A pane reaches the tree by being put in a tab, which is what the window + // does right after it spawns one — carrying the same seed it spawned with. + // Until then the daemon has nothing to record its facts against, so the + // seed is the pane's first and only chance to say what it is running. + let mut control = ControlClient::connect_at( + &instance.control_endpoint(), + &ControlHello::host_rpc("probe", "probe"), + ) + .expect("control handshake"); + let ws = match control + .request(ControlRequest::WorkspaceCreate { + name: Some("probe".into()), + workspace: None, + }) + .expect("create a workspace") + { + ReplyOk::WorkspaceTree(ws) => *ws, + other => panic!("expected WorkspaceTree, got {other:?}"), + }; + control + .request(ControlRequest::TabCreate { + workspace: ws.id, + at: None, + pane: PaneSeed { + shell: Some(interactive_shell()), + ..PaneSeed::bare(pane) + }, + tab: None, + }) + .expect("put the pane in a tab"); + + let wanted = interactive_shell().program; + let deadline = Instant::now() + STREAM_WITHIN; + loop { + let tree = std::fs::read_to_string(instance.path().join("machine.json")).unwrap_or_default(); + // The record is keyed by pane id and the program is a plain string in + // it; matching on both together is enough to say this pane's shell — + // not some other pane's — reached the tree. + if tree.contains(&format!("\"id\": {pane}")) && tree.contains(&wanted) { + return; + } + assert!( + Instant::now() < deadline, + "the tree never recorded pane {pane}'s shell ({wanted}); it holds: {tree}" + ); + std::thread::sleep(Duration::from_millis(100)); + } +} + +/// The same thing for an instance whose tree still names the pane. This one +/// passes on its own merits today; it is here so a fix for the case above +/// cannot be one that quietly stops honouring the tree. +#[test] +fn a_screen_comes_back_when_the_tree_still_names_its_pane() { + let instance = Instance::new(); + let running = instance.start(); + let dead = pane_showing_the_marker(&instance); + instance.stop(running); + instance.record_tab_on(dead); + + let _restarted = instance.start(); + let (_new_pane, replayed) = instance.spawn_restoring(dead); + assert!( + windows_contain(&replayed, MARKER), + "a pane the tree still names came back blank; the client received {:?}", + String::from_utf8_lossy(&replayed) + ); +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 70d8aa89..eed97af6 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -6566,6 +6566,7 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { SessionPane::Leaf { cwd: spawn.working_directory.clone(), pane_id: spawn.restore_pane, + shell: spawn.shell.clone(), ssh_spec: None, agent: spawn.agent, agent_session_id: spawn.agent_session_id.clone(), @@ -6577,6 +6578,11 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { SessionPane::Leaf { cwd: view.spawnable_cwd(), pane_id: Some(view.pane_id), + // `None` for a pane this window attached to rather than + // spawned: it never knew what was on the other end. The tree + // does — the daemon records it — and that is what a restore + // reads, so the gap here costs nothing it can see. + shell: view.shell_spec(), ssh_spec: view.ssh_spec(), agent: view.agent(), agent_session_id: view.agent_session().and_then(|s| s.session_id), @@ -6597,6 +6603,7 @@ fn pane_to_session(pane: &Pane, cx: &App) -> SessionPane { Pane::Empty => SessionPane::Leaf { cwd: None, pane_id: None, + shell: None, ssh_spec: None, agent: None, agent_session_id: None, @@ -6736,6 +6743,7 @@ fn session_to_pane( SessionPane::Leaf { cwd, pane_id, + shell, ssh_spec, agent, agent_session_id, @@ -6762,7 +6770,7 @@ fn session_to_pane( font_size, cwd.clone(), restore, - None, + shell.clone(), window, cx, ) { diff --git a/src/ui/home.rs b/src/ui/home.rs index cf70bd7a..9e06cf7a 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -293,6 +293,7 @@ mod tests { fn leaf(cwd: Option<&str>) -> SessionPane { SessionPane::Leaf { + shell: None, cwd: cwd.map(PathBuf::from), pane_id: None, ssh_spec: None, diff --git a/src/ui/tree_sync.rs b/src/ui/tree_sync.rs index b0829d79..05c3a2e6 100644 --- a/src/ui/tree_sync.rs +++ b/src/ui/tree_sync.rs @@ -179,6 +179,11 @@ fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option Option SessionPane match node { PaneNode::Leaf { pane } => { let record = panes.iter().find(|p| p.id == *pane); - let (cwd, ssh_spec, agent) = match record { + let (cwd, ssh_spec, agent, shell) = match record { Some(r) => ( r.cwd.clone().map(std::path::PathBuf::from), r.ssh_spec.clone(), r.agent.clone(), + r.shell.clone(), ), - None => (None, None, None), + None => (None, None, None, None), }; SessionPane::Leaf { cwd, @@ -1210,6 +1217,7 @@ fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> SessionPane // trying to guess — except it is right. `live` stays a hint for // what to show, never the judge of what to destroy. pane_id: Some(*pane), + shell, ssh_spec, agent: agent.as_ref().map(|a| a.agent), agent_session_id: agent.as_ref().and_then(|a| a.session_id.clone()), @@ -2259,6 +2267,7 @@ mod tests { cwd: Some(format!("/work/{pane}")), ssh_spec: None, agent: None, + shell: None, } } From 3093babdddcda2f2de3cde55aa533ed4d73ed451 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:33:26 +0800 Subject: [PATCH 2/7] fix(pane): say when a restore is not asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dropping the request here produced a blank pane, which is also what a pane with nothing stored looks like and what a daemon that refused would produce. Three causes and one appearance, with nothing anywhere to tell them apart — the filter was silent, so reading the source was the only way to find out which had happened. --- src/terminal/remote.rs | 24 +++++++++++++++++++----- 1 file changed, 19 insertions(+), 5 deletions(-) diff --git a/src/terminal/remote.rs b/src/terminal/remote.rs index 51ba3362..6ce9ca85 100644 --- a/src/terminal/remote.rs +++ b/src/terminal/remote.rs @@ -311,11 +311,25 @@ impl RemoteTerminal { // predates the field would silently drop it. Sending it anyway would // cost nothing but would make the log claim a restore that never // happened, so the local case is the only one that asks. - let restore = restore.filter(|_| { - route.is_local() - && crate::daemon::spawn::local_daemon_supports( - crate::daemon::protocol::FEATURE_RESTORE_SCROLLBACK, - ) + let restore = restore.and_then(|want| { + let local = route.is_local(); + let supported = crate::daemon::spawn::local_daemon_supports( + crate::daemon::protocol::FEATURE_RESTORE_SCROLLBACK, + ); + if local && supported { + return Some(want); + } + // Said out loud, because the symptom of dropping it here is a pane + // that opens blank — which is also what a pane with nothing stored + // looks like, and what a daemon that refused would produce. Three + // causes, one appearance; without this line the only way to tell + // them apart is to read the source. + log::info!( + "not asking to restore pane {}'s screen: local={local} \ + daemon-supports-restore={supported}", + want.pane_id + ); + None }); ClientMsg::Spawn { From 412bfcfc906d1bfe1cb4ceca0edab7271aa34b6f Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:41:18 +0800 Subject: [PATCH 3/7] fix(session): let a dead pane keep its id so its screen can be asked for MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pane_attachable` answered one question and was used for two. Deciding whether to attach needs to know the pane is alive; deciding which dead pane a fresh one replaces needs only its id — and that is the case the stored screen exists for. Using the first answer for the second was self-defeating. After the daemon restarts, every pane the window held is missing from the new daemon's listing, so the id was ruled out, so `restore_pane` was `None`, so the window spawned a pane that had never heard of a predecessor. No attach was tried, no restore was requested, and the screen the daemon still had on disk was swept a tick later without anyone reading it. The setting was on, the snapshot was written, the daemon was ready to hand it over, and nothing ever asked. Ownership still rules an id out, because another workspace's pane is neither ours to attach to nor ours to show. Liveness no longer does: the attach is still tried first and still gives way to a fresh spawn when the pane really is gone, which is the arrangement the tree path already argues for at length — `live` is a hint about what to show, never the judge of what to destroy. --- src/ui/app.rs | 66 ++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 63 insertions(+), 3 deletions(-) diff --git a/src/ui/app.rs b/src/ui/app.rs index eed97af6..105ef9ee 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -6645,6 +6645,30 @@ fn pane_attachable( alive: Option<&std::collections::HashMap>>, id: u64, owner: crate::core::session::WorkspaceId, +) -> bool { + // Listed at all, and then whose it is. A pane missing from the listing is + // one there is nothing to attach to. + alive.is_none_or(|listed| listed.contains_key(&id)) && pane_free_for(alive, id, owner) +} + +/// Whether this window may stand on `id` — as the pane it attaches to, or as +/// the dead predecessor whose screen a fresh pane opens showing. +/// +/// Liveness deliberately does not come into it, and that is the whole point. +/// A pane missing from the listing is usually one whose daemon has just +/// restarted, which is exactly when its stored screen is worth asking for. +/// Ruling the id out there threw away the only thing that could ask: the +/// window spawned a pane that had never heard of a predecessor, so no attach +/// was tried, no restore was requested, and the screen the daemon still had on +/// disk was swept a tick later, unread. Attaching is still tried first and +/// still fails harmlessly when the pane really is gone. +/// +/// Ownership does come into it. Another workspace's pane is not this window's +/// to attach to, and its screen is not this window's to show. +fn pane_free_for( + alive: Option<&std::collections::HashMap>>, + id: u64, + owner: crate::core::session::WorkspaceId, ) -> bool { let Some(alive) = alive else { // No listing to consult. Attaching is the safe guess in both @@ -6654,7 +6678,7 @@ fn pane_attachable( return true; }; match alive.get(&id) { - None => false, + None => true, Some(None) => true, Some(Some(recorded)) => { // Only a workspace id is a claim. Anything else is a client @@ -6753,7 +6777,10 @@ fn session_to_pane( leaf_shares_the_window_daemon(workspace.is_some(), ssh_spec.is_some()); let restore = match workspace.is_some() { true => (*pane_id).filter(|_| same_daemon), - false => (*pane_id).filter(|id| same_daemon && pane_attachable(alive, *id, owner)), + // Not `pane_attachable`: a dead pane's id is what the restore + // is keyed on, so it has to survive being dead. The attach is + // still attempted first and still gives way to a fresh spawn. + false => (*pane_id).filter(|id| same_daemon && pane_free_for(alive, *id, owner)), }; if restore.is_none() { if let Some(spec) = ssh_spec.clone() { @@ -7370,7 +7397,8 @@ mod window_drag_tests { mod tests { use super::{ CloseReason, TabAgentSession, clear_window_override_values, close_prompt, - leaf_shares_the_window_daemon, mru_order, pane_attachable, parse_ssh_connect_input, + leaf_shares_the_window_daemon, mru_order, pane_attachable, pane_free_for, + parse_ssh_connect_input, parse_ssh_option_words, }; @@ -7510,6 +7538,38 @@ mod tests { ); } + #[test] + fn a_dead_pane_keeps_its_id_so_its_screen_can_be_asked_for() { + let ours = crate::core::session::WorkspaceId::new(); + let theirs = crate::core::session::WorkspaceId::new(); + let alive: std::collections::HashMap> = + [(1, Some(ours.to_string())), (2, Some(theirs.to_string()))] + .into_iter() + .collect(); + + // The restart case: every pane the window held is missing from the new + // daemon's listing. Their ids are the only handle on the screens it + // still has stored, so being dead must not erase them — this is what + // made a restarted server come back to a row of blank shells. + assert!( + pane_free_for(Some(&alive), 4, ours), + "a dead pane's id has to survive; the restore is keyed on it" + ); + assert!( + !pane_attachable(Some(&alive), 4, ours), + "attaching to it is still hopeless, and that stays true" + ); + + // What being free does not mean: helping yourself to a pane that is + // alive and belongs to another workspace, whose screen is not this + // window's to show either. + assert!( + !pane_free_for(Some(&alive), 2, ours), + "another workspace's pane is not ours to restore from" + ); + assert!(pane_free_for(Some(&alive), 1, ours), "our own pane is ours"); + } + #[test] fn a_native_ssh_leaf_in_a_remote_window_is_not_looked_up_in_the_remote_daemon() { assert!(!leaf_shares_the_window_daemon(true, true)); From 477d82524f39f50dfc271ad949921ca31a45f4ea Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 15:47:47 +0800 Subject: [PATCH 4/7] feat(daemon): keep every pane's screen, without asking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `persist_scrollback` is gone, and with it the switch, its three translations and the branches that read it. Keeping a capped tail of each pane's output is now what the daemon does, not something it can be asked to do. This reverses the call made when the feature landed. The argument for off-by-default was that the ring holds whatever the pane printed — echoed tokens, `env` output, an agent's transcript — and that writing that down should be the user's decision to make. What the argument missed is when the decision gets made: the moment anyone learns they wanted this is the moment a daemon has already died, and by then the setting could only be turned on for next time. A feature whose entire purpose is to survive an event nobody schedules cannot be opt-in. The cost is real and does not go away: pane output now lives at `/scrollback/*.bin` on every machine, 0600 on unix and behind the config directory's ACL on Windows, capped at 256 KiB per pane and dropped as soon as no window can still ask for it. Old configs naming the key still parse — nothing in `Config` refuses unknown fields — so the key simply stops meaning anything. --- crates/tty7-core/src/core/config.rs | 12 ----- crates/tty7-core/src/daemon/scrollback.rs | 5 -- crates/tty7-core/src/daemon/server.rs | 49 +++++-------------- .../tty7-server/tests/scrollback_restore.rs | 12 ++--- src/ui/app.rs | 8 --- src/ui/i18n/en.rs | 8 --- src/ui/i18n/ja.rs | 8 --- src/ui/i18n/mod.rs | 2 - src/ui/i18n/zh.rs | 6 --- src/ui/settings.rs | 11 ----- 10 files changed, 16 insertions(+), 105 deletions(-) diff --git a/crates/tty7-core/src/core/config.rs b/crates/tty7-core/src/core/config.rs index 50a40989..6f4f95ee 100644 --- a/crates/tty7-core/src/core/config.rs +++ b/crates/tty7-core/src/core/config.rs @@ -277,17 +277,6 @@ pub struct Config { pub agent_commands: HashMap, #[serde(default = "default_true")] pub restore_agent_sessions: bool, - /// Keep a capped tail of each pane's output on disk, so a daemon that dies - /// without getting to hand off — a crash, a `kill -9`, a reboot — comes - /// back to panes that still show what was in them. - /// - /// Off by default, and the default is the interesting part. What the ring - /// holds is whatever the pane printed, which routinely includes secrets: - /// an echoed token, the output of `env`, an agent's transcript. In memory - /// they die with the daemon. Writing them down is the entire feature and - /// also its entire cost, so it is the user who decides to pay it. - #[serde(default)] - pub persist_scrollback: bool, /// Give each pane its own shell history instead of one file every pane /// appends to and reads back. /// @@ -556,7 +545,6 @@ impl Default for Config { command_frecency: HashMap::new(), agent_commands: HashMap::new(), restore_agent_sessions: true, - persist_scrollback: false, per_pane_history: false, } } diff --git a/crates/tty7-core/src/daemon/scrollback.rs b/crates/tty7-core/src/daemon/scrollback.rs index 3dbe114a..dff605eb 100644 --- a/crates/tty7-core/src/daemon/scrollback.rs +++ b/crates/tty7-core/src/daemon/scrollback.rs @@ -143,11 +143,6 @@ fn take<'a>(cur: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { Some(head) } -/// Whether the user has asked for scrollback to outlive the daemon. -pub fn enabled() -> bool { - crate::core::config::Config::load().persist_scrollback -} - fn dir() -> Option { crate::core::config::config_path("scrollback") } diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 97b6fd4c..40a7f919 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -182,28 +182,14 @@ fn restorable_pane_ids(registry: &Registry) -> std::collections::HashSet { /// /// Only panes whose ring has moved are written, so an idle machine does no IO /// at all, and the busy pane that most needs a fresh copy is the one that gets -/// it. Turning the setting off mid-run is honoured here too: the next tick -/// clears the directory rather than leaving terminal output on disk that the -/// user has just said they do not want stored. +/// it. fn spawn_scrollback_writer(registry: Arc) { let spawned = std::thread::Builder::new() .name("tty7-scrollback".into()) .spawn(move || { let mut marks: HashMap = HashMap::new(); - let mut storing = crate::daemon::scrollback::enabled(); loop { std::thread::sleep(crate::daemon::scrollback::SNAPSHOT_INTERVAL); - let enabled = crate::daemon::scrollback::enabled(); - if !enabled { - if storing { - log::info!("scrollback persistence turned off; dropping what was stored"); - crate::daemon::scrollback::sweep(&std::collections::HashSet::new()); - marks.clear(); - storing = false; - } - continue; - } - storing = true; for pane in registry.all() { if marks.get(&pane.id) == Some(&pane.scrollback_mark()) { continue; @@ -228,9 +214,6 @@ fn spawn_scrollback_writer(registry: Arc) { /// covers the ones we do, and makes the copy exact rather than up to /// [`SNAPSHOT_INTERVAL`](crate::daemon::scrollback::SNAPSHOT_INTERVAL) stale. fn store_scrollback_now(registry: &Registry) { - if !crate::daemon::scrollback::enabled() { - return; - } for pane in registry.all() { let (segments, _) = pane.scrollback_snapshot(); crate::daemon::scrollback::save(pane.id, &segments); @@ -246,9 +229,6 @@ fn store_scrollback_now(registry: &Registry) { fn restored_screen( request: crate::daemon::protocol::RestoreFrom, ) -> Option { - if !crate::daemon::scrollback::enabled() { - return None; - } let segments = crate::daemon::scrollback::load(request.pane_id)?; if segments.is_empty() { return None; @@ -570,25 +550,18 @@ fn run_with(registry: Arc) -> anyhow::Result<()> { spawn_orphan_sweep(registry.clone()); let restorable = restorable_pane_ids(®istry); - // Deliberately not swept here while the setting is on. Startup is the one - // moment this process knows least: it owns no panes yet, and the windows - // that know which screens are still wanted cannot say so until the - // endpoint below is listening. Answering "is anyone going to ask for - // this?" here answers it when nobody can — and the answer deletes. A tree - // that failed to parse makes it worse, because `read_machine` quarantines - // it and hands back an empty `Machine`, so one bad file would take every - // pane's screen with it. + // No scrollback sweep here, deliberately. Startup is the one moment this + // process knows least: it owns no panes yet, and the windows that know + // which screens are still wanted cannot say so until the endpoint below is + // listening. Answering "is anyone going to ask for this?" here answers it + // when nobody can — and the answer deletes. A tree that failed to parse + // makes it worse, because `read_machine` quarantines it and hands back an + // empty `Machine`, so one bad file would take every pane's screen with it. // - // The periodic sweep asks the same question a tick later, with the - // registry filled in and the tree caught up, and that is soon enough: - // nothing here is serving a request in the meantime. + // The periodic sweep asks the same question a tick later, with the registry + // filled in and the tree caught up, and that is soon enough: nothing here + // is serving a request in the meantime. // - // Off is not the same question. Then nothing on disk is worth keeping and - // deleting it promptly is the setting's whole promise, so that one still - // happens before anything else runs. - if !crate::daemon::scrollback::enabled() { - crate::daemon::scrollback::sweep(&std::collections::HashSet::new()); - } // A daemon that was killed outright never retired anything, so the files of // panes that died with it are still here. Their commands cannot be // recovered — the mark saying which were new belongs to a shell that is diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs index 2664a477..971aa6e5 100644 --- a/crates/tty7-server/tests/scrollback_restore.rs +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -38,13 +38,11 @@ struct Instance { impl Instance { fn new() -> Instance { - let dir = tempfile::TempDir::new().unwrap(); - std::fs::write( - dir.path().join("config.json"), - r#"{"persist_scrollback": true}"#, - ) - .unwrap(); - Instance { dir } + // No config: keeping each pane's screen is what the daemon does, not + // something it is asked to do. + Instance { + dir: tempfile::TempDir::new().unwrap(), + } } fn path(&self) -> &std::path::Path { diff --git a/src/ui/app.rs b/src/ui/app.rs index 105ef9ee..4c8d9ab8 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -2539,14 +2539,6 @@ impl Tty7App { self.update_config(cx, |cfg| cfg.restore_session = on); } - /// The daemon reads this from the config file on its own — it is the one - /// holding the output — so there is nothing to tell it here. Turning it off - /// also removes what was already stored, which the daemon does on its next - /// pass rather than leaving the bytes behind. - pub(crate) fn set_persist_scrollback(&mut self, on: bool, cx: &mut Context) { - self.update_config(cx, |cfg| cfg.persist_scrollback = on); - } - /// Takes effect on the next pane: a shell is told where its history lives /// when it starts, and nothing can move it afterwards. pub(crate) fn set_per_pane_history(&mut self, on: bool, cx: &mut Context) { diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index b9751096..6914debd 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1483,14 +1483,6 @@ pub fn translate_en(key: L10nKey) -> &'static str { adds is written back when it closes, so nothing is lost. Applies to bash and zsh \ panes that tty7 can set up; a shell started with your own arguments is left alone." } - L10nKey::SettingsPersistScrollback => "Keep pane output on disk", - L10nKey::SettingsPersistScrollbackDescription => { - "If the background service dies without warning — a crash, or a reboot — panes come \ - back showing what was on them instead of blank. The processes are gone either way; \ - this restores the picture. It writes a capped tail of every pane's output to disk, \ - including anything printed there: tokens, the output of `env`, an agent's \ - transcript. Off means that output only ever lives in memory." - } L10nKey::PanelMoreChangedFiles => { "… and {count} more changed files — run git diff to see them." } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index 3ed1c505..a8b2cccb 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1530,14 +1530,6 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { 新しいペインは空ではなく既存の履歴から始まり、追加された分はペインを閉じるときに書き戻されるので失われません。\ tty7 が設定できる bash と zsh のペインが対象で、独自の引数で起動したシェルはそのままです" } - L10nKey::SettingsPersistScrollback => "ペインの出力をディスクに残す", - L10nKey::SettingsPersistScrollbackDescription => { - "バックグラウンドサービスが引き継ぎの間もなく落ちた場合(クラッシュや再起動)、\ - ペインは空ではなく、そこにあった内容を表示して戻ります。プロセスはいずれにせよ失われ、\ - ここで戻るのは画面だけです。各ペインの出力の末尾を上限つきでディスクに書き込みます。\ - そこに表示されたもの(トークン、`env` の出力、エージェントの記録)も含みます。\ - オフなら、その出力はメモリ上にしか存在しません。" - } L10nKey::PanelMoreChangedFiles => { "… さらに変更されたファイル {count} 個 — 表示するには `git diff` を実行してください" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 1a448775..489d29f7 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1226,8 +1226,6 @@ l10n_keys! { PaneRestoredScreenBanner, AppRestartServerBodyInPlace, SettingsDaemonStaleDescInPlace, - SettingsPersistScrollback, - SettingsPersistScrollbackDescription, SettingsPerPaneHistory, SettingsPerPaneHistoryDescription, } diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 1761731e..ec546971 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1406,12 +1406,6 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { 新面板会从你已有的历史开始,而不是一片空白;面板关闭时,它新增的部分会写回原来的历史文件,不会丢。\ 只对 tty7 能接管的 bash 和 zsh 面板生效;用你自己参数启动的 shell 不受影响。" } - L10nKey::SettingsPersistScrollback => "把面板输出留在磁盘上", - L10nKey::SettingsPersistScrollbackDescription => { - "后台服务如果没来得及交接就没了(崩溃、重启机器),面板回来时会显示原先的内容,而不是一片空白。\ - 进程无论如何都救不回来,这里恢复的只是画面。它会把每个面板输出的末尾(有上限)写到磁盘上,\ - 包括那里打印过的一切:token、`env` 的输出、agent 的对话记录。关掉则这些输出只存在于内存里。" - } L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 git diff 查看。", L10nKey::PanelMoreChangedFiles => "…还有 {count} 个变更文件——运行 `git diff` 查看。", L10nKey::ScmFilesChanged => "{count} 个文件改动", diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 1b9db4c1..704e9f3e 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -4743,7 +4743,6 @@ impl Tty7App { NewTabPosition::End => 1, }; let restore_session = cfg.restore_session; - let persist_scrollback = cfg.persist_scrollback; let remember_window_size = cfg.remember_window_size; let show_tray_icon = cfg.show_tray_icon; let tab_bar_idx = match cfg.tab_bar_position { @@ -4804,10 +4803,6 @@ impl Tty7App { .checked(restore_session) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_restore_session(*on, cx))) .into_any_element(); - let persist_scrollback_switch = crate::ui::theme::switch("wt-persist-scrollback", cx) - .checked(persist_scrollback) - .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_persist_scrollback(*on, cx))) - .into_any_element(); let remember_window_switch = crate::ui::theme::switch("wt-remember-window", cx) .checked(remember_window_size) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx))) @@ -4901,12 +4896,6 @@ impl Tty7App { restore_switch, cx, )) - .child(self.settings_row( - t(L10nKey::SettingsPersistScrollback), - t(L10nKey::SettingsPersistScrollbackDescription), - persist_scrollback_switch, - cx, - )) .child(self.settings_row( t(L10nKey::SettingsShowTrayIcon), t(L10nKey::SettingsShowTrayIconDesc), From 852d3178c8cd1c51e2455d7c6b9ad4f05d7859b3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:10:58 +0800 Subject: [PATCH 5/7] style: rustfmt --- crates/tty7-server/tests/scrollback_restore.rs | 3 ++- src/ui/app.rs | 3 +-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs index 971aa6e5..15b660cb 100644 --- a/crates/tty7-server/tests/scrollback_restore.rs +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -364,7 +364,8 @@ fn the_tree_records_the_shell_a_pane_is_running() { let wanted = interactive_shell().program; let deadline = Instant::now() + STREAM_WITHIN; loop { - let tree = std::fs::read_to_string(instance.path().join("machine.json")).unwrap_or_default(); + let tree = + std::fs::read_to_string(instance.path().join("machine.json")).unwrap_or_default(); // The record is keyed by pane id and the program is a plain string in // it; matching on both together is enough to say this pane's shell — // not some other pane's — reached the tree. diff --git a/src/ui/app.rs b/src/ui/app.rs index 4c8d9ab8..2dfc35ce 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -7390,8 +7390,7 @@ mod tests { use super::{ CloseReason, TabAgentSession, clear_window_override_values, close_prompt, leaf_shares_the_window_daemon, mru_order, pane_attachable, pane_free_for, - parse_ssh_connect_input, - parse_ssh_option_words, + parse_ssh_connect_input, parse_ssh_option_words, }; #[test] From b3a66e75d0b578f8bf77df22fd41c1abac93c523 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:30:45 +0800 Subject: [PATCH 6/7] fix(test): let the machine-tree seed keep up with a new pane field PaneSeed grew a `shell`, but this test is unix-only, so a Windows box never compiles it and never says so. Build the seed from `bare` and the next field lands on its own. --- crates/tty7-server/tests/machine_tree.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/crates/tty7-server/tests/machine_tree.rs b/crates/tty7-server/tests/machine_tree.rs index 9e1d5a72..4ec69818 100644 --- a/crates/tty7-server/tests/machine_tree.rs +++ b/crates/tty7-server/tests/machine_tree.rs @@ -111,10 +111,8 @@ fn machine_file(dir: &tempfile::TempDir) -> PathBuf { fn seed(pane: u64, cwd: &str) -> PaneSeed { PaneSeed { - pane, cwd: Some(cwd.to_string()), - ssh_spec: None, - agent: None, + ..PaneSeed::bare(pane) } } From a55340ed7f81a2034cc12e1befe21d2545fa7086 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 10 Aug 2026 16:35:49 +0800 Subject: [PATCH 7/7] fix(daemon): sweep a dead daemon's leavings on the writer's tick, not at startup Review follow-ups on this branch. `history::sweep` still ran at startup, three lines under a new comment explaining why sweeping there is wrong. The reasoning transfers exactly, and worse than by analogy: a restore carries the dead pane's commands to its successor via `history::carry`, so sweeping before the window can ask deletes the file the request is about. Same shape as the scrollback bug, one file over. Both sweeps now run on the writer's tick off one shared id set, and the writer is named for what it does. `pane_attachable` lost its only caller when the restore path moved to `pane_free_for`, leaving a function kept alive by the test asserting on it. The attach site does not need to predict the listing: it tries the attach, and a pane that is gone falls through to the fresh spawn on its own. Gone, with its tests folded into `pane_free_for`'s. `restored_screen` now drops the snapshot in both directions. Keeping the file when it decoded to nothing left it to be re-read and re-rejected by every later restore, and swept never, for a pane the tree still names. Also: the module doc still said scrollback was off unless asked for, which is what this branch reverses; and #449 landed the whole feature with no CHANGELOG entry, so nothing told anyone that pane output now lives on disk. --- CHANGELOG.md | 15 +++++ crates/tty7-core/src/daemon/scrollback.rs | 13 ++-- crates/tty7-core/src/daemon/server.rs | 60 +++++++++++-------- .../tty7-server/tests/scrollback_restore.rs | 8 ++- src/ui/app.rs | 41 +++++-------- 5 files changed, 79 insertions(+), 58 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cef5e28e..0deb0bcf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 shows through the whole workspace, and the settings panel stays opaque. macOS and Linux keep the existing blur toggle. +- **Panes come back showing what was on them after the background service dies + unexpectedly** — a crash, a `kill -9` or a reboot takes the shells with it + either way, but the screens no longer go with them. A capped tail of each + pane's output is kept at `/scrollback/*.bin` (0600 on unix, behind + the config directory's ACL on Windows; 256 KiB per pane, written at most + every 30s and only for panes whose output moved), and a pane that reopens on + a dead predecessor's id is handed it. A planned restart already carried the + live ptys across untouched; this covers the deaths nothing gets to prepare + for. There is no switch: the moment anyone learns they wanted this is the + moment a service has already died, so it is on for everyone. The bytes are + dropped as soon as nothing can ask for them — closing a pane deletes its + file at once, a restore consumes it, and a periodic pass collects the rest. + A pane's shell is recorded alongside, so a git bash pane no longer comes + back as PowerShell. + ### Fixed - **An SFTP upload no longer sits in the browser under its temporary name** — diff --git a/crates/tty7-core/src/daemon/scrollback.rs b/crates/tty7-core/src/daemon/scrollback.rs index dff605eb..5c545b6d 100644 --- a/crates/tty7-core/src/daemon/scrollback.rs +++ b/crates/tty7-core/src/daemon/scrollback.rs @@ -22,10 +22,15 @@ //! screen or two, not the ring's whole 8 MiB. The value of scrollback decays //! steeply with distance from the bottom, and every byte here is a byte of //! someone's terminal sitting on disk. -//! - **It is off unless asked for.** These bytes include whatever was echoed -//! into the pane: tokens, `env` output, an agent's transcript. In memory -//! they die with the daemon. On disk they outlive it, which is the whole -//! point and also the whole risk, so the choice is the user's. +//! - **It is not asked for.** These bytes include whatever was echoed into the +//! pane: tokens, `env` output, an agent's transcript. In memory they die +//! with the daemon; on disk they outlive it, which is the whole point and +//! also the whole risk. It was a setting once, defaulting to off. That was +//! wrong about *when* the choice gets made: the moment anyone learns they +//! wanted this is the moment a daemon has already died, and by then the +//! switch could only be flipped for next time. A feature that exists to +//! survive an unscheduled event cannot be opt-in. So the cost is paid for +//! everyone, and the two bullets below are what keep it small. //! - **It is dropped as soon as it is meaningless.** A pane that is closed, or //! that no workspace refers to any more, has its file removed. Retention is //! by relevance, not by calendar: a snapshot of a pane nobody will reopen is diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 40a7f919..e1450f02 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -178,12 +178,19 @@ fn restorable_pane_ids(registry: &Registry) -> std::collections::HashSet { ids } -/// Keep each pane's stored screen roughly current. +/// Keep each pane's stored screen roughly current, and collect what no pane +/// can be asked about any more. /// /// Only panes whose ring has moved are written, so an idle machine does no IO /// at all, and the busy pane that most needs a fresh copy is the one that gets /// it. -fn spawn_scrollback_writer(registry: Arc) { +/// +/// The two sweeps ride along here rather than at startup because this is where +/// the question they ask can be answered: by now the registry holds this +/// daemon's panes and the windows have had time to put their trees back. Both +/// take the same set, because a pane whose screen is still worth restoring is +/// exactly a pane whose commands are still worth carrying. +fn spawn_snapshot_keeper(registry: Arc) { let spawned = std::thread::Builder::new() .name("tty7-scrollback".into()) .spawn(move || { @@ -198,7 +205,9 @@ fn spawn_scrollback_writer(registry: Arc) { crate::daemon::scrollback::save(pane.id, &segments); marks.insert(pane.id, mark); } - crate::daemon::scrollback::sweep(&restorable_pane_ids(®istry)); + let restorable = restorable_pane_ids(®istry); + crate::daemon::scrollback::sweep(&restorable); + crate::daemon::history::sweep(&restorable); marks.retain(|id, _| registry.get(*id).is_some()); } }); @@ -230,13 +239,15 @@ fn restored_screen( request: crate::daemon::protocol::RestoreFrom, ) -> Option { let segments = crate::daemon::scrollback::load(request.pane_id)?; + // Dropped either way — this is the one request that will ever be made about + // this pane, so nothing is served by keeping the file past it. What the + // emptiness check decides is whether a *restore* happened, not whether the + // file stays: a snapshot holding nothing is not a screen to hand over, but + // it is still a file nobody will read again. + crate::daemon::scrollback::forget(request.pane_id); if segments.is_empty() { return None; } - // After the emptiness check, not before it: dropping the file is how a - // screen that *was* handed out stops being handed out twice, and a - // snapshot that turned out to hold nothing was never handed out at all. - crate::daemon::scrollback::forget(request.pane_id); log::info!( "pane {} is gone; its last screen is restored into a fresh pane", request.pane_id @@ -549,25 +560,26 @@ fn run_with(registry: Arc) -> anyhow::Result<()> { } spawn_orphan_sweep(registry.clone()); - let restorable = restorable_pane_ids(®istry); - // No scrollback sweep here, deliberately. Startup is the one moment this - // process knows least: it owns no panes yet, and the windows that know - // which screens are still wanted cannot say so until the endpoint below is - // listening. Answering "is anyone going to ask for this?" here answers it - // when nobody can — and the answer deletes. A tree that failed to parse - // makes it worse, because `read_machine` quarantines it and hands back an - // empty `Machine`, so one bad file would take every pane's screen with it. + // No sweeping here, deliberately — of either kind. Startup is the one + // moment this process knows least: it owns no panes yet, and the windows + // that know which of a dead daemon's files are still wanted cannot say so + // until the endpoint below is listening. Answering "is anyone going to ask + // for this?" here answers it when nobody can — and the answer deletes. A + // tree that failed to parse makes it worse, because `read_machine` + // quarantines it and hands back an empty `Machine`, so one bad file would + // take every pane's screen and every pane's history with it. // - // The periodic sweep asks the same question a tick later, with the registry - // filled in and the tree caught up, and that is soon enough: nothing here - // is serving a request in the meantime. + // History was swept here until it was noticed that a restore carries the + // dead pane's commands to its successor (`history::carry`, at the top of + // the `Spawn` handler): sweeping before the window can ask deletes the very + // file the request is about. Same shape as the scrollback bug, one file + // over. // - // A daemon that was killed outright never retired anything, so the files of - // panes that died with it are still here. Their commands cannot be - // recovered — the mark saying which were new belongs to a shell that is - // gone — so what is left is not to hoard them. - crate::daemon::history::sweep(&restorable); - spawn_scrollback_writer(registry.clone()); + // Both sweeps run on the writer's tick instead, with the registry filled in + // and the tree caught up. That is soon enough: nothing here is serving a + // request in the meantime, and a daemon killed outright left its files for + // exactly this pass to collect. + spawn_snapshot_keeper(registry.clone()); for stream in listener.incoming() { match stream { diff --git a/crates/tty7-server/tests/scrollback_restore.rs b/crates/tty7-server/tests/scrollback_restore.rs index 15b660cb..04436e72 100644 --- a/crates/tty7-server/tests/scrollback_restore.rs +++ b/crates/tty7-server/tests/scrollback_restore.rs @@ -332,9 +332,11 @@ fn the_tree_records_the_shell_a_pane_is_running() { // A pane reaches the tree by being put in a tab, which is what the window // does right after it spawns one — carrying the same seed it spawned with. - // Until then the daemon has nothing to record its facts against, so the - // seed is the pane's first and only chance to say what it is running. - let mut control = ControlClient::connect_at( + // Until then the daemon has nothing to record its facts against, so this is + // the pane's first chance to say what it is running, and for a pane that + // then sits at a prompt it is the only one: the daemon's own observation + // rides on a fact *changing*, and a pane's shell never does. + let control = ControlClient::connect_at( &instance.control_endpoint(), &ControlHello::host_rpc("probe", "probe"), ) diff --git a/src/ui/app.rs b/src/ui/app.rs index 2dfc35ce..f3336c97 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -6633,16 +6633,6 @@ pub(crate) fn alive_panes_on( } } -fn pane_attachable( - alive: Option<&std::collections::HashMap>>, - id: u64, - owner: crate::core::session::WorkspaceId, -) -> bool { - // Listed at all, and then whose it is. A pane missing from the listing is - // one there is nothing to attach to. - alive.is_none_or(|listed| listed.contains_key(&id)) && pane_free_for(alive, id, owner) -} - /// Whether this window may stand on `id` — as the pane it attaches to, or as /// the dead predecessor whose screen a fresh pane opens showing. /// @@ -6652,8 +6642,13 @@ fn pane_attachable( /// Ruling the id out there threw away the only thing that could ask: the /// window spawned a pane that had never heard of a predecessor, so no attach /// was tried, no restore was requested, and the screen the daemon still had on -/// disk was swept a tick later, unread. Attaching is still tried first and -/// still fails harmlessly when the pane really is gone. +/// disk was swept a tick later, unread. +/// +/// There used to be a second predicate here that also required the id to be +/// listed, and the attach site consulted it. Nothing does now: the attach is +/// simply tried, and a pane that really is gone fails it and falls through to +/// the fresh spawn — the same outcome the listing was consulted to predict, +/// reached by asking the daemon instead of guessing ahead of it. /// /// Ownership does come into it. Another workspace's pane is not this window's /// to attach to, and its screen is not this window's to show. @@ -7389,8 +7384,8 @@ mod window_drag_tests { mod tests { use super::{ CloseReason, TabAgentSession, clear_window_override_values, close_prompt, - leaf_shares_the_window_daemon, mru_order, pane_attachable, pane_free_for, - parse_ssh_connect_input, parse_ssh_option_words, + leaf_shares_the_window_daemon, mru_order, pane_free_for, parse_ssh_connect_input, + parse_ssh_option_words, }; #[test] @@ -7502,28 +7497,24 @@ mod tests { .collect(); assert!( - pane_attachable(Some(&alive), 1, ours), + pane_free_for(Some(&alive), 1, ours), "our own pane attaches" ); assert!( - !pane_attachable(Some(&alive), 2, ours), + !pane_free_for(Some(&alive), 2, ours), "another workspace's pane must spawn fresh instead" ); assert!( - pane_attachable(Some(&alive), 3, ours), + pane_free_for(Some(&alive), 3, ours), "an unowned pane is legacy" ); assert!( - pane_attachable(Some(&alive), 5, ours), + pane_free_for(Some(&alive), 5, ours), "an owner that names no workspace is not a rival's claim: older CLIs \ wrote their own name there, and respawning strands the live pane" ); assert!( - !pane_attachable(Some(&alive), 4, ours), - "a dead id never attaches" - ); - assert!( - pane_attachable(None, 4, ours), + pane_free_for(None, 4, ours), "a failed List says nothing about pane 4; the attach itself must decide, \ because respawning on a transient RPC error destroys a live session" ); @@ -7546,10 +7537,6 @@ mod tests { pane_free_for(Some(&alive), 4, ours), "a dead pane's id has to survive; the restore is keyed on it" ); - assert!( - !pane_attachable(Some(&alive), 4, ours), - "attaching to it is still hopeless, and that stays true" - ); // What being free does not mean: helping yourself to a pane that is // alive and belongs to another workspace, whose screen is not this