From ba6760c6f92b3dd9da0f7fa2bfcd4ac7b03713d3 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Tue, 25 Aug 2026 16:54:56 +0800 Subject: [PATCH] fix(restore): carry the pane's title in the snapshot so a restored tab keeps its name (#725) A tab's title only exists as live terminal state: the OSC that set it was emitted screens ago, and the on-disk snapshot is capped at 256 KiB, so the bytes that would restore it are almost always trimmed away. Since #681 made a silent attach fall through to a fresh spawn, a slow daemon on reopen turns every such tab into the default "tty7". Store the pane's last OSC title beside the snapshot's segments (as a trailing field old readers skip and old files simply lack), and replay it as a fresh BEL-terminated OSC 0 -- control bytes stripped so a stored title cannot terminate the sequence early -- before the restore preamble. The new pane's daemon record inherits the title too, so the switcher and CLI agree. --- crates/tty7-core/src/daemon/handoff.rs | 5 +- crates/tty7-core/src/daemon/pane.rs | 60 +++++++++++++++-- crates/tty7-core/src/daemon/scrollback.rs | 80 ++++++++++++++++++----- crates/tty7-core/src/daemon/server.rs | 11 ++-- 4 files changed, 126 insertions(+), 30 deletions(-) diff --git a/crates/tty7-core/src/daemon/handoff.rs b/crates/tty7-core/src/daemon/handoff.rs index 56e849b5..909df4e9 100644 --- a/crates/tty7-core/src/daemon/handoff.rs +++ b/crates/tty7-core/src/daemon/handoff.rs @@ -222,7 +222,9 @@ fn stage(panes: &[Carried], next_pane_id: u64) -> std::io::Result let mut records = Vec::with_capacity(panes.len()); let mut data = Vec::new(); for pane in panes { - let encoded = crate::daemon::scrollback::encode(&pane.ring); + // The title travels in the record's own `osc_title` field, not in the + // ring blob. + let encoded = crate::daemon::scrollback::encode(&pane.ring, None); records.push(PaneRecord { id: pane.id, owner: pane.owner.clone(), @@ -353,6 +355,7 @@ pub fn adopt(fd: RawFd) -> Option { let ring = raw .get(cursor..end) .and_then(crate::daemon::scrollback::decode) + .map(|(segments, _)| segments) .unwrap_or_default(); cursor = end; panes.push(Carried { diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index 13f1de19..d2d9c9d7 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -1243,6 +1243,29 @@ impl portable_pty::ChildKiller for AdoptedKiller { pub struct Restore { pub segments: Vec, pub banner: Option, + /// The pane's last OSC title, carried beside the bytes rather than in them: + /// the OSC that set it was usually emitted screens ago and trimmed out of + /// the capped snapshot, so replaying the segments alone brings the screen + /// back under the default name. + pub title: Option, +} + +/// The OSC that puts a restored pane's title back. +/// +/// BEL-terminated rather than ST so the sequence contains no ESC of its own, +/// and the title is stripped of control bytes — a BEL or ESC inside it would +/// end the sequence early and leak the rest into the terminal as input. +pub fn retitle(title: &str) -> Vec { + let mut out = b"\x1b]0;".to_vec(); + out.extend( + title + .chars() + .filter(|c| !c.is_control()) + .collect::() + .as_bytes(), + ); + out.push(0x07); + out } /// The bytes that separate restored output from the new shell's own. @@ -1335,13 +1358,16 @@ impl DaemonPane { let reader_handle = pair.master.try_clone_reader()?; let writer = Arc::new(Mutex::new(pair.master.take_writer()?)); - let ring = match restore { + let (ring, restored_title) = match restore { Some(restore) => { let mut ring = ReplayRing::seeded(restore.segments, size); + if let Some(title) = restore.title.as_deref() { + ring.append(&retitle(title)); + } ring.append(&restore_preamble(restore.banner.as_deref())); - ring + (ring, restore.title) } - None => ReplayRing::new(size), + None => (ReplayRing::new(size), None), }; Ok(Self::over_pty( @@ -1361,7 +1387,7 @@ impl DaemonPane { observers: Vec::new(), observer_seq: 0, cwd: spawn.initial_cwd, - osc_title: None, + osc_title: restored_title, shell: ShellState::default(), shell_spec: spawn.shell.clone(), remote: spawn.remote.clone(), @@ -2067,11 +2093,13 @@ impl DaemonPane { self.state.lock().unwrap().ring.appended } - pub fn scrollback_snapshot(&self) -> (Vec, u64) { + pub fn scrollback_snapshot( + &self, + ) -> (Vec, Option, u64) { let st = self.state.lock().unwrap(); let mut segments = st.ring.snapshot(); crate::daemon::scrollback::trim_to(&mut segments, crate::daemon::scrollback::SNAPSHOT_CAP); - (segments, st.ring.appended) + (segments, st.osc_title.clone(), st.ring.appended) } pub fn kill(&self) { @@ -3655,6 +3683,26 @@ mod tests { } } + #[test] + fn a_restored_pane_reannounces_its_title() { + let bytes = retitle("✳ fixing the switcher"); + assert_eq!( + bytes, b"\x1b]0;\xe2\x9c\xb3 fixing the switcher\x07", + "the OSC that set the title was trimmed out of the snapshot long ago; \ + the restore has to say it again or the tab comes back as the default name" + ); + } + + #[test] + fn a_stored_title_cannot_smuggle_control_bytes_into_the_stream() { + let bytes = retitle("evil\x07\x1b]0;title\x1b\\rest"); + assert_eq!( + bytes, b"\x1b]0;evil]0;title\\rest\x07", + "a BEL or ESC inside the title would terminate the OSC early and leak \ + the rest as input to the terminal" + ); + } + #[test] fn a_banner_cannot_smuggle_extra_lines_into_the_pane() { let text = String::from_utf8(restore_preamble(Some("first\r\nsecond"))).unwrap(); diff --git a/crates/tty7-core/src/daemon/scrollback.rs b/crates/tty7-core/src/daemon/scrollback.rs index 5c545b6d..c1dbf68a 100644 --- a/crates/tty7-core/src/daemon/scrollback.rs +++ b/crates/tty7-core/src/daemon/scrollback.rs @@ -91,7 +91,7 @@ pub fn trim_to(segments: &mut Vec, cap: usize) { } } -pub fn encode(segments: &[Segment]) -> Vec { +pub fn encode(segments: &[Segment], title: Option<&str>) -> Vec { let mut out = Vec::with_capacity( MAGIC.len() + 4 + segments.iter().map(|s| s.bytes.len() + 12).sum::(), ); @@ -105,6 +105,13 @@ pub fn encode(segments: &[Segment]) -> Vec { out.extend_from_slice(&(seg.bytes.len() as u32).to_le_bytes()); out.extend_from_slice(&seg.bytes); } + // Trailing, so a file with no title is byte-identical to the old format: + // the old reader stopped after the segments and never checked for a tail, + // which is also what lets it skip a tail it does not know about. + if let Some(title) = title { + out.extend_from_slice(&(title.len() as u32).to_le_bytes()); + out.extend_from_slice(title.as_bytes()); + } out } @@ -115,7 +122,7 @@ pub fn encode(segments: &[Segment]) -> Vec { /// half-written file from an older scheme, both land as a short read. There is /// nothing to salvage and nothing at stake in refusing: the pane comes back /// blank, which is what it did before this module existed. -pub fn decode(raw: &[u8]) -> Option> { +pub fn decode(raw: &[u8]) -> Option<(Vec, Option)> { let mut cur = raw.strip_prefix(MAGIC.as_slice())?; let count = u32::from_le_bytes(take(&mut cur, 4)?.try_into().ok()?) as usize; let mut segments = Vec::with_capacity(count.min(1024)); @@ -136,7 +143,13 @@ pub fn decode(raw: &[u8]) -> Option> { bytes, }); } - Some(segments) + // A file from before titles were stored ends here; a damaged tail costs + // only the title, never the screen. + let title = (|| { + let len = u32::from_le_bytes(take(&mut cur, 4)?.try_into().ok()?) as usize; + String::from_utf8(take(&mut cur, len)?.to_vec()).ok() + })(); + Some((segments, title)) } fn take<'a>(cur: &mut &'a [u8], n: usize) -> Option<&'a [u8]> { @@ -161,7 +174,7 @@ fn path_for(pane_id: u64) -> Option { /// Renamed into place so a reader never sees a half-written file, and mode 0600 /// from creation rather than after the fact — a window in which someone else's /// terminal output is world-readable is not one worth leaving open. -pub fn save(pane_id: u64, segments: &[Segment]) { +pub fn save(pane_id: u64, segments: &[Segment], title: Option<&str>) { let Some(path) = path_for(pane_id) else { return; }; @@ -173,7 +186,7 @@ pub fn save(pane_id: u64, segments: &[Segment]) { return; } let temp = parent.join(format!("{pane_id}.{}.tmp", std::process::id())); - if let Err(e) = write_private(&temp, &encode(segments)) { + if let Err(e) = write_private(&temp, &encode(segments, title)) { log::debug!("could not stage pane {pane_id}'s scrollback: {e}"); let _ = std::fs::remove_file(&temp); return; @@ -204,10 +217,10 @@ fn write_private(path: &std::path::Path, bytes: &[u8]) -> std::io::Result<()> { std::fs::write(path, bytes) } -pub fn load(pane_id: u64) -> Option> { +pub fn load(pane_id: u64) -> Option<(Vec, Option)> { let raw = std::fs::read(path_for(pane_id)?).ok()?; match decode(&raw) { - Some(segments) => Some(segments), + Some(snapshot) => Some(snapshot), None => { log::debug!("pane {pane_id}'s stored scrollback is not readable by this build"); None @@ -272,7 +285,9 @@ mod tests { #[test] fn segments_survive_the_round_trip_with_their_geometry() { let segments = vec![seg(80, b"before the resize"), seg(120, b"after it")]; - let decoded = decode(&encode(&segments)).expect("what we wrote is readable"); + let decoded = decode(&encode(&segments, None)) + .map(|(s, _)| s) + .expect("what we wrote is readable"); assert_eq!( decoded, segments, "a replayed snapshot has to say which size each run of bytes was written at, \ @@ -282,7 +297,7 @@ mod tests { #[test] fn a_truncated_or_foreign_file_decodes_to_nothing() { - let raw = encode(&[seg(80, b"hello")]); + let raw = encode(&[seg(80, b"hello")], None); assert!( decode(&raw[..raw.len() - 2]).is_none(), "a short read must not be mistaken for a shorter snapshot" @@ -336,9 +351,11 @@ mod tests { fn a_stored_screen_comes_back_and_can_be_dropped() { pin_config_dir(); let pane = 90_001; - save(pane, &[seg(80, b"what the pane had on it")]); + save(pane, &[seg(80, b"what the pane had on it")], None); assert_eq!( - load(pane).expect("a saved screen is readable"), + load(pane) + .map(|(s, _)| s) + .expect("a saved screen is readable"), vec![seg(80, b"what the pane had on it")], ); forget(pane); @@ -352,10 +369,10 @@ mod tests { fn saving_twice_replaces_rather_than_appends() { pin_config_dir(); let pane = 90_002; - save(pane, &[seg(80, b"first")]); - save(pane, &[seg(80, b"second")]); + save(pane, &[seg(80, b"first")], None); + save(pane, &[seg(80, b"second")], None); assert_eq!( - load(pane).expect("still readable"), + load(pane).map(|(s, _)| s).expect("still readable"), vec![seg(80, b"second")], "each write is the pane's current screen, not another slice of history" ); @@ -366,8 +383,8 @@ mod tests { fn the_sweep_keeps_only_panes_something_can_still_ask_for() { pin_config_dir(); let (kept, dropped) = (90_003, 90_004); - save(kept, &[seg(80, b"in a workspace")]); - save(dropped, &[seg(80, b"in no workspace")]); + save(kept, &[seg(80, b"in a workspace")], None); + save(dropped, &[seg(80, b"in no workspace")], None); sweep(&HashSet::from([kept])); assert!( load(kept).is_some(), @@ -386,7 +403,7 @@ mod tests { use std::os::unix::fs::PermissionsExt as _; pin_config_dir(); let pane = 90_005; - save(pane, &[seg(80, b"a token someone echoed")]); + save(pane, &[seg(80, b"a token someone echoed")], None); let mode = std::fs::metadata(path_for(pane).expect("a path under the config dir")) .expect("the file exists") .permissions() @@ -399,10 +416,37 @@ mod tests { forget(pane); } + #[test] + fn the_title_rides_the_snapshot() { + let segments = vec![seg(80, b"a screenful")]; + let (decoded, title) = decode(&encode(&segments, Some("✳ fixing the switcher"))) + .expect("what we wrote is readable"); + assert_eq!(decoded, segments); + assert_eq!( + title.as_deref(), + Some("✳ fixing the switcher"), + "the OSC bytes that set the title were trimmed out of the ring long ago; \ + the snapshot has to carry the title itself or a restored pane comes back \ + under the default name" + ); + } + + #[test] + fn a_snapshot_without_a_title_still_decodes() { + // A file written before titles were stored ends right after its + // segments; it must read back whole, just untitled. + let segments = vec![seg(80, b"an old file")]; + let (decoded, title) = decode(&encode(&segments, None)).expect("still a valid snapshot"); + assert_eq!(decoded, segments); + assert_eq!(title, None); + } + #[test] fn an_empty_snapshot_round_trips() { assert_eq!( - decode(&encode(&[])).expect("still a valid snapshot"), + decode(&encode(&[], None)) + .map(|(s, _)| s) + .expect("still a valid snapshot"), Vec::new(), "a pane that has printed nothing is not a corrupt file" ); diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index 0af97dcb..c48b7142 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -201,8 +201,8 @@ fn spawn_snapshot_keeper(registry: Arc) { if marks.get(&pane.id) == Some(&pane.scrollback_mark()) { continue; } - let (segments, mark) = pane.scrollback_snapshot(); - crate::daemon::scrollback::save(pane.id, &segments); + let (segments, title, mark) = pane.scrollback_snapshot(); + crate::daemon::scrollback::save(pane.id, &segments, title.as_deref()); marks.insert(pane.id, mark); } let restorable = restorable_pane_ids(®istry); @@ -224,8 +224,8 @@ fn spawn_snapshot_keeper(registry: Arc) { /// [`SNAPSHOT_INTERVAL`](crate::daemon::scrollback::SNAPSHOT_INTERVAL) stale. fn store_scrollback_now(registry: &Registry) { for pane in registry.all() { - let (segments, _) = pane.scrollback_snapshot(); - crate::daemon::scrollback::save(pane.id, &segments); + let (segments, title, _) = pane.scrollback_snapshot(); + crate::daemon::scrollback::save(pane.id, &segments, title.as_deref()); } } @@ -238,7 +238,7 @@ fn store_scrollback_now(registry: &Registry) { fn restored_screen( request: crate::daemon::protocol::RestoreFrom, ) -> Option { - let segments = crate::daemon::scrollback::load(request.pane_id)?; + let (segments, title) = 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 @@ -255,6 +255,7 @@ fn restored_screen( Some(crate::daemon::pane::Restore { segments, banner: request.banner, + title, }) }