mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
|
||||
@@ -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(®istry);
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user