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.
This commit is contained in:
l0ng-ai
2026-08-25 16:54:56 +08:00
committed by GitHub
parent ec95edee63
commit ba6760c6f9
4 changed files with 126 additions and 30 deletions
+4 -1
View File
@@ -222,7 +222,9 @@ fn stage(panes: &[Carried], next_pane_id: u64) -> std::io::Result<std::fs::File>
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<Adopted> {
let ring = raw
.get(cursor..end)
.and_then(crate::daemon::scrollback::decode)
.map(|(segments, _)| segments)
.unwrap_or_default();
cursor = end;
panes.push(Carried {
+54 -6
View File
@@ -1243,6 +1243,29 @@ impl portable_pty::ChildKiller for AdoptedKiller {
pub struct Restore {
pub segments: Vec<crate::daemon::scrollback::Segment>,
pub banner: Option<String>,
/// 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<String>,
}
/// 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<u8> {
let mut out = b"\x1b]0;".to_vec();
out.extend(
title
.chars()
.filter(|c| !c.is_control())
.collect::<String>()
.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<crate::daemon::scrollback::Segment>, u64) {
pub fn scrollback_snapshot(
&self,
) -> (Vec<crate::daemon::scrollback::Segment>, Option<String>, 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();
+62 -18
View File
@@ -91,7 +91,7 @@ pub fn trim_to(segments: &mut Vec<Segment>, cap: usize) {
}
}
pub fn encode(segments: &[Segment]) -> Vec<u8> {
pub fn encode(segments: &[Segment], title: Option<&str>) -> Vec<u8> {
let mut out = Vec::with_capacity(
MAGIC.len() + 4 + segments.iter().map(|s| s.bytes.len() + 12).sum::<usize>(),
);
@@ -105,6 +105,13 @@ pub fn encode(segments: &[Segment]) -> Vec<u8> {
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<u8> {
/// 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<Vec<Segment>> {
pub fn decode(raw: &[u8]) -> Option<(Vec<Segment>, Option<String>)> {
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<Vec<Segment>> {
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<PathBuf> {
/// 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<Vec<Segment>> {
pub fn load(pane_id: u64) -> Option<(Vec<Segment>, Option<String>)> {
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"
);
+6 -5
View File
@@ -201,8 +201,8 @@ fn spawn_snapshot_keeper(registry: Arc<Registry>) {
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(&registry);
@@ -224,8 +224,8 @@ fn spawn_snapshot_keeper(registry: Arc<Registry>) {
/// [`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<crate::daemon::pane::Restore> {
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,
})
}