Merge pull request #461 from l0ng-ai/fix/scrollback-restore-survives-restart

fix(daemon): give a restarted server back its panes' shells and screens
This commit is contained in:
l0ng-ai
2026-08-10 16:46:52 +08:00
committed by GitHub
23 changed files with 663 additions and 142 deletions
+15
View File
@@ -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 `<config>/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** —
+8
View File
@@ -346,6 +346,7 @@ fn new_workspace(path: Option<String>, 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<Outcom
cwd: args.cwd,
ssh_spec: None,
agent: None,
shell: None,
},
tab: None,
})?;
@@ -472,6 +474,7 @@ fn pane_split(args: SplitArgs, ctx: &Context, backend: &mut dyn Backend) -> 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,
},
+1
View File
@@ -47,6 +47,7 @@ pub fn two_workspace_machine() -> Machine {
title: String::new(),
ssh_spec: None,
agent: None,
shell: None,
live: true,
};
Machine {
-12
View File
@@ -277,17 +277,6 @@ pub struct Config {
pub agent_commands: HashMap<String, String>,
#[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,
}
}
+19 -1
View File
@@ -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<Box<NativeSshSpec>>,
#[serde(default)]
pub agent: Option<AgentFacts>,
/// 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<ShellSpec>,
#[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<Box<NativeSshSpec>>,
#[serde(default)]
pub agent: Option<AgentFacts>,
/// 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<ShellSpec>,
}
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,
}
}
+6
View File
@@ -17,6 +17,12 @@ pub enum SessionPane {
cwd: Option<PathBuf>,
#[serde(default)]
pane_id: Option<u64>,
/// 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<crate::daemon::protocol::ShellSpec>,
#[serde(default)]
ssh_spec: Option<Box<NativeSshSpec>>,
#[serde(default)]
+5
View File
@@ -83,6 +83,8 @@ struct PaneRecord {
integration_dir: Option<PathBuf>,
size: WinSize,
cwd: Option<PathBuf>,
#[serde(default)]
shell: Option<crate::daemon::protocol::ShellSpec>,
shell_active: bool,
at_prompt: bool,
last_exit: Option<i32>,
@@ -227,6 +229,7 @@ fn stage(panes: &[Carried], next_pane_id: u64) -> std::io::Result<std::fs::File>
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<Adopted> {
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),
+48 -11
View File
@@ -111,6 +111,10 @@ struct SpawnConfig {
initial_cwd: Option<PathBuf>,
integration_dir: Option<PathBuf>,
remote: Option<RemoteContext>,
/// 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<ShellSpec>,
}
/// 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<PathBuf>,
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<ShellSpec>,
remote: Option<RemoteContext>,
agent: Option<crate::core::cli_agent::CLIAgent>,
agent_argv: Option<Vec<String>>,
@@ -954,6 +970,11 @@ pub struct Carried {
pub size: WinSize,
pub ring: Vec<crate::daemon::scrollback::Segment>,
pub cwd: Option<PathBuf>,
/// 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<ShellSpec>,
pub shell_active: bool,
pub at_prompt: bool,
pub last_exit: Option<i32>,
@@ -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<crate::daemon::control::PaneAg
})
}
fn observed_facts(st: &PaneState) -> (Option<String>, Option<crate::core::machine::AgentFacts>) {
type ObservedFacts = (
Option<String>,
Option<crate::core::machine::AgentFacts>,
Option<ShellSpec>,
);
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<String>, Option<crate::core::machin
.or_else(|| st.agent_argv.clone()),
status: st.agent_session.as_ref().map(|s| s.status),
});
(cwd, agent)
(cwd, agent, st.shell_spec.clone())
}
fn facts_changed(
before: &(Option<String>, Option<crate::core::machine::AgentFacts>),
after: &(Option<String>, Option<crate::core::machine::AgentFacts>),
) -> 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,
+9 -9
View File
@@ -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
@@ -143,11 +148,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<PathBuf> {
crate::core::config::config_path("scrollback")
}
+47 -42
View File
@@ -159,44 +159,44 @@ fn restorable_pane_ids(registry: &Registry) -> std::collections::HashSet<u64> {
let mut ids: std::collections::HashSet<u64> =
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
}
/// 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. 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.
fn spawn_scrollback_writer(registry: Arc<Registry>) {
/// it.
///
/// 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<Registry>) {
let spawned = std::thread::Builder::new()
.name("tty7-scrollback".into())
.spawn(move || {
let mut marks: HashMap<u64, u64> = 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;
@@ -205,7 +205,9 @@ fn spawn_scrollback_writer(registry: Arc<Registry>) {
crate::daemon::scrollback::save(pane.id, &segments);
marks.insert(pane.id, mark);
}
crate::daemon::scrollback::sweep(&restorable_pane_ids(&registry));
let restorable = restorable_pane_ids(&registry);
crate::daemon::scrollback::sweep(&restorable);
crate::daemon::history::sweep(&restorable);
marks.retain(|id, _| registry.get(*id).is_some());
}
});
@@ -221,9 +223,6 @@ fn spawn_scrollback_writer(registry: Arc<Registry>) {
/// 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);
@@ -239,10 +238,12 @@ fn store_scrollback_now(registry: &Registry) {
fn restored_screen(
request: crate::daemon::protocol::RestoreFrom,
) -> Option<crate::daemon::pane::Restore> {
if !crate::daemon::scrollback::enabled() {
return None;
}
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;
@@ -559,22 +560,26 @@ fn run_with(registry: Arc<Registry>) -> 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(&registry);
if crate::daemon::scrollback::enabled() {
crate::daemon::scrollback::sweep(&restorable);
} else {
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
// gone — so what is left is not to hoard them.
crate::daemon::history::sweep(&restorable);
spawn_scrollback_writer(registry.clone());
// 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.
//
// 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.
//
// 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 {
+1
View File
@@ -1716,6 +1716,7 @@ mod aggregate_tests {
cwd: Some("/repo/tty7".into()),
ssh_spec: None,
agent: None,
shell: None,
},
None,
None,
+1
View File
@@ -137,6 +137,7 @@ fn seed(pane: u64) -> PaneSeed {
cwd: Some("/home/me/proj".into()),
ssh_spec: None,
agent: None,
shell: None,
}
}
+1 -3
View File
@@ -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)
}
}
@@ -0,0 +1,403 @@
//! 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 {
// 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 {
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<Vec<u8>> {
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<u8>) {
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<u8> {
let mut seen: Vec<u8> = 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 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"),
)
.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)
);
}
+19 -5
View File
@@ -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 {
+68 -22
View File
@@ -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>) {
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<Self>) {
@@ -6566,6 +6558,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 +6570,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 +6595,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,
@@ -6634,7 +6633,26 @@ pub(crate) fn alive_panes_on(
}
}
fn pane_attachable(
/// 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.
///
/// 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.
fn pane_free_for(
alive: Option<&std::collections::HashMap<u64, Option<String>>>,
id: u64,
owner: crate::core::session::WorkspaceId,
@@ -6647,7 +6665,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
@@ -6736,6 +6754,7 @@ fn session_to_pane(
SessionPane::Leaf {
cwd,
pane_id,
shell,
ssh_spec,
agent,
agent_session_id,
@@ -6745,7 +6764,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() {
@@ -6762,7 +6784,7 @@ fn session_to_pane(
font_size,
cwd.clone(),
restore,
None,
shell.clone(),
window,
cx,
) {
@@ -7362,7 +7384,7 @@ 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_free_for, parse_ssh_connect_input,
parse_ssh_option_words,
};
@@ -7475,33 +7497,57 @@ 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"
);
}
#[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<u64, Option<String>> =
[(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"
);
// 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));
+1
View File
@@ -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,
-8
View File
@@ -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."
}
-8
View File
@@ -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` を実行してください"
}
-2
View File
@@ -1226,8 +1226,6 @@ l10n_keys! {
PaneRestoredScreenBanner,
AppRestartServerBodyInPlace,
SettingsDaemonStaleDescInPlace,
SettingsPersistScrollback,
SettingsPersistScrollbackDescription,
SettingsPerPaneHistory,
SettingsPerPaneHistoryDescription,
}
-6
View File
@@ -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} 个文件改动",
-11
View File
@@ -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),
+11 -2
View File
@@ -179,6 +179,11 @@ fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option<DesiredNod
.map(|p| p.to_string_lossy().into_owned()),
ssh_spec,
agent,
// Only a pane this window spawned knows this; one it
// attached to never saw the command line. The daemon fills
// that gap from its own side, so leaving it empty here
// withholds nothing the tree does not already get.
shell: view.shell_spec(),
},
})
}
@@ -201,6 +206,7 @@ fn desired_node(pane: &Pane, remote_window: bool, cx: &App) -> Option<DesiredNod
.map(|p| p.to_string_lossy().into_owned()),
ssh_spec: None,
agent,
shell: spawn.shell.clone(),
},
})
}
@@ -1185,13 +1191,14 @@ fn session_pane_from_node(node: &PaneNode, panes: &[PaneRecord]) -> 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,
}
}