diff --git a/crates/tty7-core/src/client/pane.rs b/crates/tty7-core/src/client/pane.rs index e8cc79e4..7ff64f8b 100644 --- a/crates/tty7-core/src/client/pane.rs +++ b/crates/tty7-core/src/client/pane.rs @@ -86,6 +86,34 @@ impl PaneClient { ClientMsg::Kill { pane_id }.encode(&mut stream) } + /// Ask the daemon to become `exe` without stopping, keeping every pane. + /// + /// Success is the connection ending: the process that answers this socket + /// is replaced mid-call, and its replacement has never heard of the socket. + /// Anything actually written back is the reason it did not happen, and in + /// that case the daemon is still running, still serving, still on its old + /// binary. + /// + /// Returning does not mean the new image is listening yet — rebinding the + /// endpoint takes it a moment. Callers that need to talk to it again should + /// wait for its version to answer. + pub fn hand_off(&self, exe: &std::path::Path) -> io::Result<()> { + use std::io::Write as _; + + let mut stream = self.open()?; + ClientMsg::Handoff { + exe: exe.to_path_buf(), + } + .encode(&mut stream)?; + stream.flush()?; + let _ = stream.set_read_timeout(Some(OPEN_REPLY_WAIT)); + match DaemonMsg::read(&mut stream) { + Err(_) => Ok(()), + Ok(DaemonMsg::Error(message)) => Err(io::Error::other(message)), + Ok(other) => Err(unexpected_reply("Handoff", &other)), + } + } + pub fn send_input(&self, pane_id: u64, bytes: &[u8]) -> io::Result<()> { let mut stream = self.open()?; ClientMsg::SendInput { diff --git a/crates/tty7-core/src/daemon/handoff.rs b/crates/tty7-core/src/daemon/handoff.rs new file mode 100644 index 00000000..94f960a1 --- /dev/null +++ b/crates/tty7-core/src/daemon/handoff.rs @@ -0,0 +1,474 @@ +//! Replacing the daemon's binary without replacing the daemon. +//! +//! Every other way of upgrading the daemon ends with the shells dead, and not +//! by oversight. A pty's master is a file descriptor held by this process; when +//! this process goes, the descriptor closes, the slave side raises `SIGHUP`, +//! and everything in the pane goes with it. Storing state does not help — see +//! `daemon::scrollback`, which stores a great deal of it and still cannot bring +//! back a single process. +//! +//! `execve` is the exception. It replaces the *image* while keeping the +//! *process*: same pid, same children, same open descriptors — anything without +//! `FD_CLOEXEC` — same file locks, same signal mask, same session and process +//! group. So the ptys stay open, the shells never see a hangup, and the thing +//! that changes is only which code is on the other end of the descriptor. +//! +//! What that costs is that nothing in memory survives. Threads, the pane +//! registry, the rings, the client connections: all of it is gone the instant +//! `execve` succeeds. Whatever the new image needs, this one has to write down +//! first and pass along by descriptor number. +//! +//! The blob is written to a file that is unlinked before a byte goes into it, +//! so it has no name for anything to read and the kernel frees it when the last +//! descriptor closes. That matters here: it holds every pane's ring, which is +//! the same terminal output `scrollback` makes people opt in to storing. A +//! handoff should not be a way to write it to disk behind their back. +//! +//! Not everything can cross: +//! +//! - **Native SSH panes.** Their session is a cipher state and a set of tasks +//! in this process's memory. The socket descriptor would survive, but nothing +//! that knows how to speak on it would. They are hung up before the exec, so +//! the far end sees a clean disconnect rather than a stalled connection. +//! - **Client connections.** A window is holding a socket to us; that socket +//! dies with the image. Clients reconnect and reattach by pane id, which is +//! the path they already use after any restart — except that this time the +//! attach finds the pane alive, with its ring and its running program. +//! - **Windows.** There is no `execve`, and a ConPTY's pseudoconsole handle +//! cannot be handed to another process. There the daemon still stops and +//! starts, and `scrollback` is what softens it. + +use std::io::{Read as _, Seek as _, Write as _}; +use std::os::fd::RawFd; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Serialize}; + +use crate::daemon::pane::Carried; +use crate::daemon::protocol::WinSize; + +/// The flag that tells a starting daemon it is the far side of a handoff. Its +/// value is the descriptor number the blob can be read from. +pub const HANDOFF_FLAG: &str = "--handoff"; + +/// The descriptor holding this daemon's claim to be *the* daemon. +/// +/// Passed rather than re-taken: the lock is still held, by this very process, +/// which is about to become the new one. Asking for it again from a second +/// descriptor would be refused by the kernel, and the new image would stand +/// down in favour of itself. +/// +/// It travels on the command line rather than inside the blob because it is the +/// one thing that still matters when the blob cannot be read. A daemon that has +/// lost its panes is a bad afternoon; a daemon that exits because it cannot +/// tell that the lock in its way is its own leaves the machine with no daemon +/// at all. +pub const SEAT_FLAG: &str = "--handoff-seat"; + +#[derive(Serialize, Deserialize)] +struct Manifest { + /// Where the new image resumes naming panes. Ids have to keep climbing: + /// a client that reconnects is holding the old ones, and handing the same + /// number to a different pane would attach a window to a stranger. + next_pane_id: u64, + panes: Vec, +} + +#[derive(Serialize, Deserialize)] +struct PaneRecord { + id: u64, + owner: Option, + master_fd: RawFd, + child_pid: u32, + integration_dir: Option, + size: WinSize, + cwd: Option, + shell_active: bool, + at_prompt: bool, + last_exit: Option, + remote: Option, + agent: Option, + agent_argv: Option>, + agent_session: Option, + /// Length of this pane's ring in the data section, which follows the + /// manifest in pane order. + ring_len: u32, +} + +/// What this process was told on the command line, if it was started as the far +/// side of a handoff. +#[derive(Debug, PartialEq, Eq)] +pub struct Inheritance { + pub blob_fd: RawFd, + pub seat_fd: Option, +} + +pub fn requested() -> Option { + let args: Vec = std::env::args_os().collect(); + Some(Inheritance { + blob_fd: fd_arg(&args, HANDOFF_FLAG)?, + seat_fd: fd_arg(&args, SEAT_FLAG), + }) +} + +fn fd_arg(args: &[std::ffi::OsString], flag: &str) -> Option { + let mut args = args.iter(); + while let Some(arg) = args.next() { + if arg.as_os_str() == std::ffi::OsStr::new(flag) { + return args.next()?.to_str()?.parse().ok(); + } + if let Some(value) = arg + .to_str() + .and_then(|a| a.strip_prefix(flag).and_then(|r| r.strip_prefix('='))) + { + return value.parse().ok(); + } + } + None +} + +/// Everything the new image is handed. +pub struct Adopted { + pub panes: Vec, + pub next_pane_id: u64, +} + +/// Become the new daemon: write down what the panes are, then `execve`. +/// +/// Returns only when the exec did not happen, and in that case nothing has been +/// broken — the descriptors are still open, the panes are still served, and the +/// caller can keep running as if it had never been asked. That ordering is the +/// whole safety argument: the state is staged first and the irreversible step +/// is last, so a failure anywhere before it costs a log line. +pub fn take_over( + exe: &Path, + panes: Vec, + next_pane_id: u64, + seat_fd: Option, +) -> anyhow::Error { + let blob = match stage(&panes, next_pane_id) { + Ok(blob) => blob, + Err(e) => return anyhow::anyhow!("could not stage the handoff: {e}"), + }; + + // Past this point every descriptor the new image needs has to survive the + // exec, and Rust opens everything close-on-exec. + let blob_fd = std::os::fd::AsRawFd::as_raw_fd(&blob); + for fd in std::iter::once(blob_fd) + .chain(seat_fd) + .chain(panes.iter().map(|p| p.master_fd)) + { + if let Err(e) = keep_across_exec(fd) { + return anyhow::anyhow!("descriptor {fd} would not survive the exec: {e}"); + } + } + + let mut cmd = std::process::Command::new(exe); + cmd.arg("--daemon"); + if let Some(dir) = crate::core::config::config_dir_path() { + cmd.arg("--config-dir").arg(dir); + } + cmd.arg(HANDOFF_FLAG).arg(blob_fd.to_string()); + if let Some(seat) = seat_fd { + cmd.arg(SEAT_FLAG).arg(seat.to_string()); + } + + log::info!( + "handing {} pane(s) to {} in place; pids and ptys are kept", + panes.len(), + exe.display() + ); + // Only ever returns an error: on success this process is already the new + // program and this code no longer exists. + let failure = std::os::unix::process::CommandExt::exec(&mut cmd); + anyhow::anyhow!("could not exec {}: {failure}", exe.display()) +} + +/// Write the blob into a file with no name. +/// +/// Created and unlinked before anything is written, so the pane output it +/// carries is reachable only through this descriptor and is freed the moment +/// the last copy of it closes. A crash between here and the exec leaves nothing +/// behind on disk. +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); + records.push(PaneRecord { + id: pane.id, + owner: pane.owner.clone(), + master_fd: pane.master_fd, + child_pid: pane.child_pid, + integration_dir: pane.integration_dir.clone(), + size: pane.size, + cwd: pane.cwd.clone(), + shell_active: pane.shell_active, + at_prompt: pane.at_prompt, + last_exit: pane.last_exit, + remote: pane.remote.clone(), + agent: pane.agent, + agent_argv: pane.agent_argv.clone(), + agent_session: pane.agent_session.clone(), + ring_len: encoded.len() as u32, + }); + data.extend_from_slice(&encoded); + } + + let manifest = serde_json::to_vec(&Manifest { + next_pane_id, + panes: records, + }) + .map_err(std::io::Error::other)?; + + let mut file = anonymous_file()?; + file.write_all(&(manifest.len() as u32).to_le_bytes())?; + file.write_all(&manifest)?; + file.write_all(&data)?; + file.flush()?; + file.seek(std::io::SeekFrom::Start(0))?; + Ok(file) +} + +fn anonymous_file() -> std::io::Result { + use std::os::unix::fs::OpenOptionsExt as _; + + let dir = std::env::temp_dir(); + for attempt in 0..8 { + let path = dir.join(format!("tty7-handoff-{}-{attempt}", std::process::id())); + match std::fs::OpenOptions::new() + .create_new(true) + .read(true) + .write(true) + .mode(0o600) + .open(&path) + { + Ok(file) => { + // Unlinked immediately: from here the bytes have no name, and + // the only way to them is the descriptor we are about to pass. + let _ = std::fs::remove_file(&path); + return Ok(file); + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => continue, + Err(e) => return Err(e), + } + } + Err(std::io::Error::other( + "no free name for the handoff file in the temp directory", + )) +} + +fn keep_across_exec(fd: RawFd) -> std::io::Result<()> { + let flags = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if flags < 0 { + return Err(std::io::Error::last_os_error()); + } + if unsafe { libc::fcntl(fd, libc::F_SETFD, flags & !libc::FD_CLOEXEC) } < 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(()) +} + +/// Read back what the previous image left on `fd`. +/// +/// A blob that cannot be read is not recoverable and not worth pretending +/// about: the descriptors are still open and the shells are still running, but +/// without the manifest there is no way to know which pane any of them is. The +/// daemon starts empty, the ptys are closed by the kernel when it exits, and +/// the panes read as gone — the same outcome as an ordinary restart. +pub fn adopt(fd: RawFd) -> Option { + let mut file = unsafe { ::from_raw_fd(fd) }; + let mut raw = Vec::new(); + if let Err(e) = file.read_to_end(&mut raw) { + log::error!("could not read the handoff blob on fd {fd}: {e}"); + return None; + } + drop(file); + + if raw.len() < 4 { + log::error!("the handoff blob on fd {fd} is empty"); + return None; + } + let manifest_len = u32::from_le_bytes(raw[..4].try_into().ok()?) as usize; + let manifest: Manifest = match raw.get(4..4 + manifest_len).map(serde_json::from_slice) { + Some(Ok(manifest)) => manifest, + Some(Err(e)) => { + log::error!("the handoff manifest does not parse: {e}"); + return None; + } + None => { + log::error!("the handoff blob is shorter than its own manifest"); + return None; + } + }; + + let mut cursor = 4 + manifest_len; + let mut panes = Vec::with_capacity(manifest.panes.len()); + for record in manifest.panes { + let end = cursor + record.ring_len as usize; + let ring = raw + .get(cursor..end) + .and_then(crate::daemon::scrollback::decode) + .unwrap_or_default(); + cursor = end; + panes.push(Carried { + id: record.id, + owner: record.owner, + master_fd: record.master_fd, + child_pid: record.child_pid, + integration_dir: record.integration_dir, + size: record.size, + ring, + cwd: record.cwd, + shell_active: record.shell_active, + at_prompt: record.at_prompt, + last_exit: record.last_exit, + remote: record.remote, + agent: record.agent, + agent_argv: record.agent_argv, + agent_session: record.agent_session, + }); + } + + Some(Adopted { + panes, + next_pane_id: manifest.next_pane_id, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn size() -> WinSize { + WinSize { + cols: 80, + rows: 24, + cell_w: 8, + cell_h: 17, + } + } + + fn carried(id: u64, fd: RawFd, output: &[u8]) -> Carried { + Carried { + id, + owner: Some("workspace".into()), + master_fd: fd, + child_pid: 4242, + integration_dir: Some(PathBuf::from("/tmp/tty7-int")), + size: size(), + ring: vec![crate::daemon::scrollback::Segment { + size: size(), + bytes: output.to_vec(), + }], + cwd: Some(PathBuf::from("/work")), + shell_active: true, + at_prompt: true, + last_exit: Some(0), + remote: None, + agent: None, + agent_argv: None, + agent_session: None, + } + } + + #[test] + fn panes_cross_the_blob_with_their_descriptors_and_their_screens() { + let staged = stage( + &[ + carried(7, 31, b"first pane"), + carried(9, 32, b"second pane"), + ], + 10, + ) + .expect("stage the blob"); + + let adopted = adopt(std::os::fd::IntoRawFd::into_raw_fd(staged)).expect("read it back"); + assert_eq!(adopted.next_pane_id, 10, "ids must not be handed out twice"); + assert_eq!(adopted.panes.len(), 2); + + assert_eq!(adopted.panes[0].id, 7); + assert_eq!( + adopted.panes[0].master_fd, 31, + "the descriptor number is the pty: without it the new image has a pane id and no pane" + ); + assert_eq!(adopted.panes[0].child_pid, 4242); + assert_eq!(adopted.panes[0].ring[0].bytes, b"first pane"); + assert_eq!(adopted.panes[0].cwd, Some(PathBuf::from("/work"))); + assert!(adopted.panes[0].at_prompt); + assert_eq!( + adopted.panes[1].ring[0].bytes, b"second pane", + "each pane's ring has to be read back at its own offset, not the first one's" + ); + } + + #[test] + fn a_pane_that_printed_nothing_still_crosses() { + let mut empty = carried(3, 20, b""); + empty.ring.clear(); + let staged = stage(&[empty], 4).expect("stage"); + let adopted = adopt(std::os::fd::IntoRawFd::into_raw_fd(staged)).expect("read back"); + assert_eq!( + adopted.panes.len(), + 1, + "a silent pane is still a live shell" + ); + assert!(adopted.panes[0].ring.is_empty()); + } + + #[test] + fn a_blob_that_is_not_one_is_refused_rather_than_guessed_at() { + let mut file = anonymous_file().expect("temp file"); + file.write_all(b"nothing like a manifest").expect("write"); + file.seek(std::io::SeekFrom::Start(0)).expect("rewind"); + assert!( + adopt(std::os::fd::IntoRawFd::into_raw_fd(file)).is_none(), + "without the manifest there is no way to say which shell is which pane" + ); + } + + #[test] + fn the_staged_file_has_no_name_to_read_it_by() { + let file = anonymous_file().expect("temp file"); + let dir = std::env::temp_dir(); + let leaked: Vec<_> = std::fs::read_dir(&dir) + .expect("read the temp directory") + .flatten() + .filter(|e| e.file_name().to_string_lossy().starts_with("tty7-handoff-")) + .collect(); + assert!( + leaked.is_empty(), + "every pane's output is in this file; it must not be sitting in {} under a name \ + anyone can open", + dir.display() + ); + drop(file); + } + + #[test] + fn the_flags_are_read_in_both_spellings_and_only_when_present() { + let args = |v: &[&str]| v.iter().map(std::ffi::OsString::from).collect::>(); + assert_eq!( + fd_arg(&args(&["--daemon", "--handoff", "17"]), HANDOFF_FLAG), + Some(17) + ); + assert_eq!( + fd_arg(&args(&["--daemon", "--handoff=17"]), HANDOFF_FLAG), + Some(17) + ); + assert_eq!(fd_arg(&args(&["--daemon"]), HANDOFF_FLAG), None); + assert_eq!( + fd_arg(&args(&["--handoff", "not-a-number"]), HANDOFF_FLAG), + None, + "a daemon that cannot tell which descriptor to read starts clean instead of guessing" + ); + assert_eq!( + fd_arg( + &args(&["--handoff", "17", "--handoff-seat", "18"]), + SEAT_FLAG + ), + Some(18), + "the seat is read separately, so it is still readable when the blob is not" + ); + } +} diff --git a/crates/tty7-core/src/daemon/mod.rs b/crates/tty7-core/src/daemon/mod.rs index d6b30d82..dbcc0391 100644 --- a/crates/tty7-core/src/daemon/mod.rs +++ b/crates/tty7-core/src/daemon/mod.rs @@ -1,5 +1,12 @@ pub mod control; pub mod duplex; +/// Upgrading the daemon in place, keeping the ptys and the shells on them. +/// +/// Unix only, and not for want of trying: `execve` is what makes it possible, +/// and Windows has neither that nor a way to hand a ConPTY to another process. +/// There the daemon still stops and starts, and `scrollback` softens it. +#[cfg(unix)] +pub mod handoff; pub mod install; pub mod pane; pub mod pidfile; diff --git a/crates/tty7-core/src/daemon/pane.rs b/crates/tty7-core/src/daemon/pane.rs index b18c1e92..e56bdacc 100644 --- a/crates/tty7-core/src/daemon/pane.rs +++ b/crates/tty7-core/src/daemon/pane.rs @@ -760,6 +760,17 @@ struct PtyBackend { integration_dir: Option, } +/// The pty half of a pane, before it is wired up: opened and spawned into by +/// [`DaemonPane::spawn`], inherited across an `exec` by [`DaemonPane::adopt`]. +struct PtyParts { + master: Box, + child: Arc>>, + shell_pid: Option, + integration_dir: Option, + reader_handle: Box, + writer: Arc>>, +} + struct NativeSshBackend { handle: Arc, connection: crate::daemon::ssh::SharedConnection, @@ -921,6 +932,235 @@ fn push_image_frame(frames: &mut Vec, frame: Vec) { } } +/// A live pane, reduced to what survives an `exec` plus what has to be written +/// down because it does not. +/// +/// The descriptor number and the child pid are the half the kernel keeps for +/// us. Everything else here was in memory, and memory is exactly what `exec` +/// replaces — so a pane's screen, its cwd, its prompt state and its agent are +/// carried across by hand or not at all. See `daemon::handoff`. +#[cfg(unix)] +pub struct Carried { + pub id: u64, + pub owner: Option, + pub master_fd: std::os::fd::RawFd, + pub child_pid: u32, + pub integration_dir: Option, + pub size: WinSize, + pub ring: Vec, + pub cwd: Option, + pub shell_active: bool, + pub at_prompt: bool, + pub last_exit: Option, + pub remote: Option, + pub agent: Option, + pub agent_argv: Option>, + pub agent_session: Option, +} + +/// A pty master this process inherited from its own previous image. +/// +/// `portable-pty` can only hand out a master it opened, and after an `exec` +/// there is nothing left of the one it opened except the descriptor number. The +/// operations a pane actually performs on a master are few enough — resize, ask +/// the size, clone a reader, take a writer, ask which process group is in the +/// foreground — and all of them are ioctls on that descriptor. +#[cfg(unix)] +struct AdoptedMaster { + fd: std::os::fd::OwnedFd, +} + +#[cfg(unix)] +impl AdoptedMaster { + fn from_fd(fd: std::os::fd::RawFd) -> anyhow::Result { + use std::os::fd::FromRawFd as _; + + // A descriptor that no longer refers to a terminal is one the previous + // image did not really hold — a number reused after a close, or a blob + // that named the wrong one. Adopting it would produce a pane whose + // every ioctl fails in a different way; refusing produces a pane that + // is simply gone, which is a thing the client already handles. + if unsafe { libc::isatty(fd) } != 1 { + anyhow::bail!("descriptor {fd} is not a terminal"); + } + Ok(Self { + fd: unsafe { std::os::fd::OwnedFd::from_raw_fd(fd) }, + }) + } + + fn dup(&self) -> std::io::Result { + Ok(std::fs::File::from(self.fd.try_clone()?)) + } +} + +#[cfg(unix)] +impl MasterPty for AdoptedMaster { + fn resize(&self, size: PtySize) -> anyhow::Result<()> { + use std::os::fd::AsRawFd as _; + + let ws = libc::winsize { + ws_row: size.rows, + ws_col: size.cols, + ws_xpixel: size.pixel_width, + ws_ypixel: size.pixel_height, + }; + if unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCSWINSZ, &ws as *const _) } != 0 { + anyhow::bail!("TIOCSWINSZ failed: {}", std::io::Error::last_os_error()); + } + Ok(()) + } + + fn get_size(&self) -> anyhow::Result { + use std::os::fd::AsRawFd as _; + + let mut ws: libc::winsize = unsafe { std::mem::zeroed() }; + if unsafe { libc::ioctl(self.fd.as_raw_fd(), libc::TIOCGWINSZ, &mut ws as *mut _) } != 0 { + anyhow::bail!("TIOCGWINSZ failed: {}", std::io::Error::last_os_error()); + } + Ok(PtySize { + rows: ws.ws_row, + cols: ws.ws_col, + pixel_width: ws.ws_xpixel, + pixel_height: ws.ws_ypixel, + }) + } + + fn try_clone_reader(&self) -> anyhow::Result> { + Ok(Box::new(self.dup()?)) + } + + /// Deliberately a plain `File`, unlike the writer `portable-pty` hands out: + /// that one sends EOF to the shell when it is dropped. Correct for a pty + /// whose pane is being closed, wrong for one whose pane is only changing + /// which program serves it. + fn take_writer(&self) -> anyhow::Result> { + Ok(Box::new(self.dup()?)) + } + + fn as_raw_fd(&self) -> Option { + use std::os::fd::AsRawFd as _; + Some(self.fd.as_raw_fd()) + } + + fn process_group_leader(&self) -> Option { + use std::os::fd::AsRawFd as _; + match unsafe { libc::tcgetpgrp(self.fd.as_raw_fd()) } { + pid if pid > 0 => Some(pid), + _ => None, + } + } +} + +/// A child this process inherited from its own previous image. +/// +/// Still genuinely our child — `exec` keeps the process, so the kernel's +/// parent-child bookkeeping is untouched and `waitpid` works. What was lost is +/// the `Child` value that knew how to ask. +#[cfg(unix)] +#[derive(Debug)] +struct AdoptedChild { + pid: libc::pid_t, + exited: Option, +} + +#[cfg(unix)] +impl AdoptedChild { + fn new(pid: u32) -> Self { + Self { + pid: pid as libc::pid_t, + exited: None, + } + } + + fn reap(&mut self, flags: libc::c_int) -> std::io::Result> { + if let Some(status) = &self.exited { + return Ok(Some(status.clone())); + } + let mut raw: libc::c_int = 0; + let seen = unsafe { libc::waitpid(self.pid, &mut raw, flags) }; + if seen == 0 { + return Ok(None); + } + if seen < 0 { + let e = std::io::Error::last_os_error(); + // ECHILD means someone already reaped it, or it was never ours. + // Either way there is no status left to collect and no point in + // asking again — reporting an exit of zero is what an unknown but + // finished child amounts to. + if e.raw_os_error() == Some(libc::ECHILD) { + let status = portable_pty::ExitStatus::with_exit_code(0); + self.exited = Some(status.clone()); + return Ok(Some(status)); + } + return Err(e); + } + let code = if libc::WIFEXITED(raw) { + libc::WEXITSTATUS(raw) as u32 + } else if libc::WIFSIGNALED(raw) { + 128 + libc::WTERMSIG(raw) as u32 + } else { + 0 + }; + let status = portable_pty::ExitStatus::with_exit_code(code); + self.exited = Some(status.clone()); + Ok(Some(status)) + } +} + +#[cfg(unix)] +impl portable_pty::Child for AdoptedChild { + fn try_wait(&mut self) -> std::io::Result> { + self.reap(libc::WNOHANG) + } + + fn wait(&mut self) -> std::io::Result { + loop { + if let Some(status) = self.reap(0)? { + return Ok(status); + } + } + } + + fn process_id(&self) -> Option { + Some(self.pid as u32) + } +} + +#[cfg(unix)] +#[derive(Debug)] +struct AdoptedKiller { + pid: libc::pid_t, +} + +#[cfg(unix)] +impl portable_pty::ChildKiller for AdoptedChild { + fn kill(&mut self) -> std::io::Result<()> { + AdoptedKiller { pid: self.pid }.kill() + } + + fn clone_killer(&self) -> Box { + Box::new(AdoptedKiller { pid: self.pid }) + } +} + +#[cfg(unix)] +impl portable_pty::ChildKiller for AdoptedKiller { + fn kill(&mut self) -> std::io::Result<()> { + if unsafe { libc::kill(self.pid, libc::SIGKILL) } != 0 { + let e = std::io::Error::last_os_error(); + // Already gone is the outcome kill was asked for. + if e.raw_os_error() != Some(libc::ESRCH) { + return Err(e); + } + } + Ok(()) + } + + fn clone_killer(&self) -> Box { + Box::new(AdoptedKiller { pid: self.pid }) + } +} + /// The screen a restored pane opens with. /// /// `segments` is what some earlier pane — the one this one replaces after a @@ -990,26 +1230,63 @@ impl DaemonPane { None => ReplayRing::new(size), }; - let state = Arc::new(Mutex::new(PaneState { - id, - ring, - subscriber: None, - subscriber_epoch: 0, - observers: Vec::new(), - observer_seq: 0, - cwd: spawn.initial_cwd, - shell: ShellState::default(), - remote: spawn.remote.clone(), - agent: None, - agent_session: None, - agent_argv: None, - alive: true, - exit_code: None, - })); + Ok(Self::over_pty( + PtyParts { + master: pair.master, + child, + shell_pid, + integration_dir: spawn.integration_dir, + reader_handle, + writer, + }, + PaneState { + id, + ring, + subscriber: None, + subscriber_epoch: 0, + observers: Vec::new(), + observer_seq: 0, + cwd: spawn.initial_cwd, + shell: ShellState::default(), + remote: spawn.remote.clone(), + agent: None, + agent_session: None, + agent_argv: None, + alive: true, + exit_code: None, + }, + owner, + on_dead, + )) + } + + /// Wire a pty, a child and a starting state into a running pane. + /// + /// Shared by the two ways a pty-backed pane comes to exist — one that opens + /// a pty and starts a shell in it, and one that inherits both from the + /// image it replaced. Everything below this line is identical for them, and + /// it is the part where getting it subtly wrong shows up as a pane that + /// never reports its death or never sees its own output. + fn over_pty( + parts: PtyParts, + state: PaneState, + owner: Option, + on_dead: impl FnOnce() + Send + 'static, + ) -> Arc { + let PtyParts { + master, + child, + shell_pid, + integration_dir, + reader_handle, + writer, + } = parts; + + let id = state.id; + let state = Arc::new(Mutex::new(state)); let shutting_down = Arc::new(AtomicBool::new(false)); let gate = Arc::new(OutputGate::new()); - - let master = Arc::new(Mutex::new(Some(pair.master))); + let master = Arc::new(Mutex::new(Some(master))); let pane = Arc::new(Self { id, @@ -1018,7 +1295,7 @@ impl DaemonPane { master: master.clone(), child: child.clone(), shell_pid, - integration_dir: spawn.integration_dir, + integration_dir, }), writer: writer.clone(), shutting_down: shutting_down.clone(), @@ -1084,7 +1361,118 @@ impl DaemonPane { ); *pane.reader.lock().unwrap() = Some(reader); - Ok(pane) + pane + } + + /// Reduce a live pane to what can be handed to another program image. + /// + /// Nothing is taken: the descriptor number is copied out, not the + /// descriptor, and the pane keeps serving exactly as before. That is what + /// lets the caller stage a whole handoff and then abandon it — an `exec` + /// that fails costs a log line, not a machine's worth of shells. + /// + /// `None` for a native-SSH pane. Its session lives in this process's + /// memory, not in a descriptor, and no amount of copying descriptor numbers + /// would let the new image speak on the wire the old one had encrypted. + #[cfg(unix)] + pub fn carry(&self) -> Option { + let PaneBackend::Pty(pty) = &self.backend else { + return None; + }; + let master_fd = pty + .master + .lock() + .ok()? + .as_ref() + .and_then(|master| master.as_raw_fd())?; + let st = self.state.lock().unwrap(); + Some(Carried { + id: self.id, + owner: self.owner.clone(), + master_fd, + child_pid: pty.shell_pid?, + integration_dir: pty.integration_dir.clone(), + size: st.ring.tail_size(), + ring: st.ring.snapshot(), + cwd: st.cwd.clone(), + shell_active: st.shell.active, + at_prompt: st.shell.at_prompt, + last_exit: st.shell.last_exit_code, + remote: st.remote.clone(), + agent: st.agent, + agent_argv: st.agent_argv.clone(), + agent_session: st.agent_session.clone(), + }) + } + + /// Rebuild a pane around a pty this process already holds. + /// + /// The descriptor and the child pid came through an `exec`, so both are + /// still ours in the only sense that matters: the kernel still has us down + /// as the pty's owner and the shell's parent. What is gone is everything + /// that was in memory, which is why the ring and the pane's status come + /// back from the handoff blob rather than from the shell. + #[cfg(unix)] + pub fn adopt( + carried: crate::daemon::pane::Carried, + on_dead: impl FnOnce() + Send + 'static, + ) -> anyhow::Result> { + let master = AdoptedMaster::from_fd(carried.master_fd)?; + let reader_handle = master.try_clone_reader()?; + let writer = Arc::new(Mutex::new(master.take_writer()?)); + let shell_pid = carried.child_pid; + // The size the pty is actually at is the kernel's to answer, and it is + // the truth: nobody resized it while we were not running. The carried + // size only says where the ring's tail was cut. + let size = master + .get_size() + .map(|s| WinSize { + cols: s.cols, + rows: s.rows, + cell_w: carried.size.cell_w, + cell_h: carried.size.cell_h, + }) + .unwrap_or(carried.size); + + let mut ring = ReplayRing::seeded(carried.ring, size); + // Not a restore banner: nothing was lost, so there is nothing to + // announce and nothing to reset. The shell below these bytes is the + // same shell that wrote them. + ring.resize(size); + + Ok(Self::over_pty( + PtyParts { + master: Box::new(master), + child: Arc::new(Mutex::new(Box::new(AdoptedChild::new(shell_pid)))), + shell_pid: Some(shell_pid), + integration_dir: carried.integration_dir, + reader_handle, + writer, + }, + PaneState { + id: carried.id, + ring, + subscriber: None, + subscriber_epoch: 0, + observers: Vec::new(), + observer_seq: 0, + cwd: carried.cwd, + shell: ShellState { + active: carried.shell_active, + at_prompt: carried.at_prompt, + last_exit_code: carried.last_exit, + command: None, + }, + remote: carried.remote, + agent: carried.agent, + agent_session: carried.agent_session, + agent_argv: carried.agent_argv, + alive: true, + exit_code: None, + }, + carried.owner, + on_dead, + )) } pub fn spawn_native_ssh( @@ -1798,6 +2186,14 @@ impl ReplayRing { ring } + /// The geometry new output would be recorded at. + fn tail_size(&self) -> WinSize { + self.segments + .back() + .map(|seg| seg.size) + .expect("ring always has a tail") + } + fn snapshot(&self) -> Vec { self.segments .iter() @@ -2985,6 +3381,25 @@ mod tests { assert_eq!(ring.flatten(), b"output"); } + #[cfg(unix)] + #[test] + fn a_descriptor_that_is_not_a_terminal_is_refused_rather_than_adopted() { + use std::os::fd::AsRawFd as _; + + let file = std::fs::File::open("/dev/null").expect("open /dev/null"); + let err = AdoptedMaster::from_fd(file.as_raw_fd()) + .err() + .expect("a plain file is not a pty"); + assert!( + err.to_string().contains("not a terminal"), + "the refusal was {err}" + ); + // Refused means not taken: the caller still owns what it passed, and + // dropping `file` here is what closes it. Adopting first and failing + // later would have closed a descriptor belonging to someone else. + drop(file); + } + #[test] fn the_restore_preamble_hands_the_new_shell_a_terminal_it_can_use() { let bytes = restore_preamble(Some("this shell is new")); diff --git a/crates/tty7-core/src/daemon/protocol.rs b/crates/tty7-core/src/daemon/protocol.rs index 572ed23f..20dfe4ca 100644 --- a/crates/tty7-core/src/daemon/protocol.rs +++ b/crates/tty7-core/src/daemon/protocol.rs @@ -23,6 +23,13 @@ pub const FEATURE_RESIZE_ECHO: &str = "resize-echo"; /// claim a restore that never happened. pub const FEATURE_RESTORE_SCROLLBACK: &str = "restore-scrollback"; +/// The daemon can replace its own binary without stopping, keeping every pty +/// and everything running on one — `ClientMsg::Handoff`. Advertised only where +/// it can actually be done, which is where `execve` exists, so a client can use +/// it to choose between offering an upgrade that costs the user nothing and one +/// that costs them every running command. +pub const FEATURE_HANDOFF: &str = "handoff"; + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct DaemonVersion { pub protocol: u32, @@ -36,14 +43,18 @@ pub struct DaemonVersion { impl DaemonVersion { pub fn current() -> DaemonVersion { + let mut features = vec![ + FEATURE_PANE_OWNER.to_string(), + FEATURE_RESIZE_ECHO.to_string(), + FEATURE_RESTORE_SCROLLBACK.to_string(), + ]; + if cfg!(unix) { + features.push(FEATURE_HANDOFF.to_string()); + } DaemonVersion { protocol: PROTOCOL_VERSION, build: env!("CARGO_PKG_VERSION").to_string(), - features: vec![ - FEATURE_PANE_OWNER.to_string(), - FEATURE_RESIZE_ECHO.to_string(), - FEATURE_RESTORE_SCROLLBACK.to_string(), - ], + features, instance: process_instance().to_string(), } } @@ -614,6 +625,16 @@ pub enum ClientMsg { }, List, Shutdown, + /// Become `exe` without stopping: the daemon rewrites itself in place and + /// keeps every pty, shell and pane id it is holding. The connection dies in + /// the process — the new image has never heard of it — so this is the last + /// thing a client can say on it, and the reply is the socket closing. + /// + /// Unix only. Elsewhere the daemon answers with an error and the caller + /// falls back to stopping and starting it. + Handoff { + exe: PathBuf, + }, EnsureLoopbackForward(LoopbackForwardRequest), ListLoopbackForwards, CloseLoopbackForward(LoopbackForwardId), @@ -750,6 +771,7 @@ mod kind { pub const SPAWN_OWNED: u8 = 53; pub const OBSERVE: u8 = 54; pub const SEND_INPUT: u8 = 55; + pub const HANDOFF: u8 = 56; pub const SPAWNED: u8 = 1; pub const SNAPSHOT: u8 = 2; @@ -942,6 +964,7 @@ impl ClientMsg { ClientMsg::Kill { pane_id } => write_frame(w, kind::KILL, &to_json(pane_id)?), ClientMsg::List => write_frame(w, kind::LIST, &[]), ClientMsg::Shutdown => write_frame(w, kind::SHUTDOWN, &[]), + ClientMsg::Handoff { exe } => write_frame(w, kind::HANDOFF, &to_json(exe)?), ClientMsg::EnsureLoopbackForward(req) => { write_frame(w, kind::ENSURE_LOOPBACK_FORWARD, &to_json(req)?) } @@ -1055,6 +1078,9 @@ impl ClientMsg { }, kind::LIST => ClientMsg::List, kind::SHUTDOWN => ClientMsg::Shutdown, + kind::HANDOFF => ClientMsg::Handoff { + exe: from_json(&payload)?, + }, kind::ENSURE_LOOPBACK_FORWARD => ClientMsg::EnsureLoopbackForward(from_json(&payload)?), kind::LIST_LOOPBACK_FORWARDS => ClientMsg::ListLoopbackForwards, kind::CLOSE_LOOPBACK_FORWARD => ClientMsg::CloseLoopbackForward(from_json(&payload)?), diff --git a/crates/tty7-core/src/daemon/server.rs b/crates/tty7-core/src/daemon/server.rs index facc12a0..b78809e3 100644 --- a/crates/tty7-core/src/daemon/server.rs +++ b/crates/tty7-core/src/daemon/server.rs @@ -26,6 +26,15 @@ impl Registry { self.next_id.fetch_add(1, Ordering::Relaxed) } + /// Resume naming panes where the previous image left off. + /// + /// Reusing a number would be worse than skipping one: a client that + /// reconnects after a handoff is still holding the old ids, and an `Attach` + /// for one of them has to find the pane it means or nothing at all. + fn claim_ids_past(&self, next: u64) { + self.next_id.fetch_max(next, Ordering::Relaxed); + } + fn seed_ids_past(&self, machine: &crate::core::machine::Machine) { let max = machine .panes @@ -291,6 +300,11 @@ macro_rules! startup_note { } pub fn run_daemon() -> anyhow::Result<()> { + #[cfg(unix)] + if let Some(inheritance) = crate::daemon::handoff::requested() { + return run_adopting(inheritance); + } + // Before either endpoint, and before the machine tree is opened. Whoever // holds this is the server; everyone else stands down while it lives. // @@ -332,6 +346,121 @@ pub fn run_daemon() -> anyhow::Result<()> { run_with(registry) } +/// Come up as the far side of a handoff: the panes are already running, and +/// this image only has to recognise them. +/// +/// The seat is adopted rather than claimed — the lock is held by this process, +/// which is the process that held it before the exec. Claiming would ask the +/// kernel for a lock our own descriptor already has, be refused, and take the +/// "another server is already serving" exit, which would leave the machine with +/// no daemon and a set of orphaned shells nobody can reach. +#[cfg(unix)] +fn run_adopting(inheritance: crate::daemon::handoff::Inheritance) -> anyhow::Result<()> { + let _seat = inheritance + .seat_fd + .map(|fd| unsafe { crate::daemon::singleton::adopt(fd) }); + if _seat.is_none() { + startup_note!("tty7-server: handed over without a seat descriptor; serving unprotected"); + } + + let registry = Arc::new(Registry::new()); + + match crate::daemon::handoff::adopt(inheritance.blob_fd) { + Some(adopted) => { + registry.claim_ids_past(adopted.next_pane_id); + let mut kept = 0usize; + for carried in adopted.panes { + let id = carried.id; + let on_dead = reaper(registry.clone(), id); + match crate::daemon::pane::DaemonPane::adopt(carried, on_dead) { + Ok(pane) => { + registry.insert(pane); + kept += 1; + } + // The shell is alive and this image cannot speak to its pty. + // Saying so is all that can be done: the descriptor closes + // when this process eventually exits, and the shell gets the + // hangup it would have got from an ordinary restart. + Err(e) => log::error!("pane {id} could not be adopted: {e}"), + } + } + startup_note!("tty7-server: adopted {kept} pane(s) from the previous build"); + } + None => startup_note!( + "tty7-server: the handoff blob was unreadable; the previous build's panes are lost" + ), + } + + { + let mut services = control_services(); + services.panes = Some(registry.clone()); + match crate::host::server::spawn_control_listener_with( + crate::host::local::LocalHost::shared(), + services, + ) { + Ok(path) => startup_note!("tty7-server: control socket at {}", path.display()), + Err(e) => startup_note!("tty7-server: control listener unavailable: {e}"), + } + } + + run_with(registry) +} + +/// Become `exe` in place, keeping every pane that can survive the crossing. +/// +/// Returns the reason it did not happen; on success there is no return, because +/// by then this program has been replaced by the one it was asked to become. +#[cfg(unix)] +fn hand_over(registry: &Registry, exe: &std::path::Path) -> anyhow::Error { + let mut carried = Vec::new(); + for pane in registry.all() { + match pane.carry() { + Some(c) => carried.push(c), + None => { + // A native-SSH pane's session is cipher state in this process's + // memory; the socket would cross and nothing able to speak on + // it would. Hanging it up here means the far end sees a close + // rather than a connection that has stopped answering. + log::info!("pane {} cannot cross a handoff; closing it", pane.id); + kill_pane(registry, pane.id); + } + } + } + + // The tree is what the window rebuilds itself from when it reconnects, and + // the reconnect happens milliseconds from now. + if let Some(store) = crate::core::machine::observed_store() { + store.flush(); + } + + crate::daemon::handoff::take_over( + exe, + carried, + registry.alloc_id(), + crate::daemon::singleton::held_fd(), + ) +} + +#[cfg(not(unix))] +fn hand_over(_registry: &Registry, _exe: &std::path::Path) -> anyhow::Error { + anyhow::anyhow!( + "this platform has no way to replace a running program while keeping its \ + open consoles, so the daemon has to be stopped and started" + ) +} + +/// What a pane does to the registry when its shell dies. +fn reaper(registry: Arc, id: u64) -> impl FnOnce() + Send + 'static { + move || { + std::thread::Builder::new() + .name("tty7-daemon-pane-reap".to_string()) + .spawn(move || { + registry.remove(id); + }) + .ok(); + } +} + pub fn control_services() -> crate::host::server::Services { use crate::core::machine::MachineStore; match MachineStore::shared() { @@ -631,6 +760,16 @@ fn handle_conn(stream: Stream, registry: Arc) -> anyhow::Result<()> { Ok(()) } + ClientMsg::Handoff { exe } => { + let mut w = write_stream; + // Only ever returns having failed: a handoff that works replaces + // this program mid-call, and there is nobody left to write a reply. + let failure = hand_over(®istry, &exe); + log::error!("handoff to {} did not happen: {failure}", exe.display()); + DaemonMsg::Error(failure.to_string()).encode(&mut w)?; + Ok(()) + } + ClientMsg::EnsureLoopbackForward(req) => { let mut w = write_stream; let Some(pane) = registry.get(req.pane_id) else { diff --git a/crates/tty7-core/src/daemon/singleton.rs b/crates/tty7-core/src/daemon/singleton.rs index a41c0fb9..3308eed8 100644 --- a/crates/tty7-core/src/daemon/singleton.rs +++ b/crates/tty7-core/src/daemon/singleton.rs @@ -52,6 +52,53 @@ fn lock_path() -> Option { crate::core::config::config_path("daemon.lock") } +/// The descriptor the seat is held on, for the one caller that needs it after +/// the fact: a handoff, which has to pass it to the image it is becoming. +/// +/// Recorded rather than threaded through because the seat is deliberately owned +/// by `run_daemon`'s stack frame — it is released when the server stops serving, +/// and that is the property worth keeping. `-1` means no seat is held. +#[cfg(unix)] +static HELD: std::sync::atomic::AtomicI32 = std::sync::atomic::AtomicI32::new(-1); + +#[cfg(unix)] +pub fn held_fd() -> Option { + match HELD.load(std::sync::atomic::Ordering::Relaxed) { + -1 => None, + fd => Some(fd), + } +} + +#[cfg(unix)] +fn note_held(file: &File) { + HELD.store( + std::os::fd::AsRawFd::as_raw_fd(file), + std::sync::atomic::Ordering::Relaxed, + ); +} + +#[cfg(not(unix))] +fn note_held(_file: &File) {} + +/// Take back a seat this process never gave up. +/// +/// After `execve` the lock is still held — by this process, which is the same +/// process, wearing a new program. Calling [`claim`] here would open the file a +/// second time and ask the kernel for a lock the first descriptor still has, be +/// told `Taken`, and stand down in favour of itself: the one way to end up with +/// no daemon at all is to be too careful here. +/// +/// # Safety +/// +/// `fd` must be an open descriptor on the lock file, inherited across the exec +/// and not owned by anything else. +#[cfg(unix)] +pub unsafe fn adopt(fd: std::os::fd::RawFd) -> Singleton { + let file = unsafe { ::from_raw_fd(fd) }; + note_held(&file); + Singleton { _file: file } +} + /// Claims the right to be this machine's server. pub fn claim() -> Claim { let Some(path) = lock_path() else { @@ -61,7 +108,10 @@ pub fn claim() -> Claim { let _ = std::fs::create_dir_all(parent); } match open_exclusive(&path) { - Ok(Some(file)) => Claim::Held(Singleton { _file: file }), + Ok(Some(file)) => { + note_held(&file); + Claim::Held(Singleton { _file: file }) + } Ok(None) => Claim::Taken, Err(e) => Claim::Unavailable(format!("{} could not be locked: {e}", path.display())), } @@ -79,16 +129,23 @@ fn open_exclusive(path: &std::path::Path) -> std::io::Result> { .write(true) .truncate(false) .open(path)?; - // LOCK_NB so a running server answers "taken" instead of parking this - // process on a lock it will hold until it exits. - let locked = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) }; - if locked == 0 { - return Ok(Some(file)); - } - let e = std::io::Error::last_os_error(); - match e.raw_os_error() { - Some(libc::EWOULDBLOCK) => Ok(None), - _ => Err(e), + loop { + // LOCK_NB so a running server answers "taken" instead of parking this + // process on a lock it will hold until it exits. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0 { + return Ok(Some(file)); + } + let e = std::io::Error::last_os_error(); + match e.raw_os_error() { + Some(libc::EWOULDBLOCK) => return Ok(None), + // A signal arriving mid-call says nothing about the lock. Reporting + // it as "could not be evaluated" would start a second server beside + // the first — the split machine this module exists to prevent — + // for no better reason than a `SIGCHLD` landing at the wrong + // microsecond. + Some(libc::EINTR) => continue, + _ => return Err(e), + } } } diff --git a/crates/tty7-core/src/daemon/spawn.rs b/crates/tty7-core/src/daemon/spawn.rs index 13b46388..ae97bdde 100644 --- a/crates/tty7-core/src/daemon/spawn.rs +++ b/crates/tty7-core/src/daemon/spawn.rs @@ -15,6 +15,13 @@ const STARTUP_TIMEOUT: Duration = Duration::from_secs(3); const POLL_INTERVAL: Duration = Duration::from_millis(50); const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(2); const SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(6); +/// How long to wait for a refusal before assuming a handoff went through. +/// +/// The daemon either execs — in which case this socket dies and the read fails +/// at once — or writes back why it did not. Neither takes long; the timeout is +/// only here so a daemon that hangs mid-handoff does not hang the window with +/// it. +const HANDOFF_TIMEOUT: Duration = Duration::from_secs(5); /// How long after the endpoint disappears the daemon process itself gets to /// finish exiting. Under a graceful shutdown this is milliseconds; the margin /// covers the Windows descendant reap that runs before `exit`. @@ -319,6 +326,76 @@ pub fn restart() -> anyhow::Result<()> { ensure_running() } +/// Ask the running daemon to become this build without stopping. +/// +/// It keeps its pid, its ptys and everything running on them; what it loses is +/// this connection, which its new image has never heard of. So the reply to a +/// handoff that worked is the socket closing, and an actual message back means +/// it did not happen. +/// +/// Callers that only want the daemon to be on the current build should treat a +/// failure here as "fall back to [`restart`]": every reason this can fail — an +/// older daemon that does not know the message, a platform with no `execve`, an +/// exec that was refused — leaves the daemon exactly as it was, still serving. +pub fn hand_off() -> anyhow::Result<()> { + use std::io::Write as _; + + if !local_daemon_supports(crate::daemon::protocol::FEATURE_HANDOFF) { + anyhow::bail!("the running daemon cannot replace itself in place"); + } + let exe = std::env::current_exe() + .map_err(|e| anyhow::anyhow!("could not locate own executable: {e}"))?; + + let mut stream = transport::connect()?; + ClientMsg::Handoff { exe: exe.clone() }.encode(&mut stream)?; + stream.flush()?; + + let _ = stream.set_read_timeout(Some(HANDOFF_TIMEOUT)); + match crate::daemon::protocol::DaemonMsg::read(&mut stream) { + // The far end is the new image, which never had this socket. + Err(_) => {} + Ok(crate::daemon::protocol::DaemonMsg::Error(why)) => { + anyhow::bail!("the daemon refused to hand over: {why}") + } + Ok(other) => anyhow::bail!("unexpected daemon reply to Handoff: {other:?}"), + } + drop(stream); + + // The socket is rebound by the new image a moment after the exec. Until it + // is, connecting fails the same way it would against no daemon at all — + // which is exactly what `ensure_running` would misread as "start another + // one", so the wait belongs here rather than there. + let deadline = Instant::now() + STARTUP_TIMEOUT; + loop { + if let Ok(mut stream) = transport::connect() { + match query_daemon_version(&mut stream) { + VersionProbe::Speaks(v) => { + let carried_on = v.build != env!("CARGO_PKG_VERSION"); + note_local_daemon(Some(v)); + if carried_on { + anyhow::bail!("the daemon is still running its old build"); + } + return Ok(()); + } + _ => { + note_local_daemon(None); + anyhow::bail!( + "the daemon that came back does not answer the version handshake" + ); + } + } + } + if Instant::now() >= deadline { + anyhow::bail!( + "the daemon did not start listening again at {} within {:?}", + transport::endpoint_display(), + STARTUP_TIMEOUT + ); + } + std::thread::sleep(POLL_INTERVAL); + } +} + pub fn stop() { use std::io::Write as _; diff --git a/crates/tty7-server/tests/handoff.rs b/crates/tty7-server/tests/handoff.rs new file mode 100644 index 00000000..ea1984ba --- /dev/null +++ b/crates/tty7-server/tests/handoff.rs @@ -0,0 +1,258 @@ +//! The daemon replaces its own binary and the shells never notice. +//! +//! Everything else about a handoff can be checked in a unit test — the blob +//! round-trips, the flags parse, the ids keep climbing. None of that answers +//! the only question that matters, which is whether the process on the other +//! end of the pty is still the same process afterwards. So this runs a real +//! daemon, puts a real shell in a real pty, and asks the shell. +//! +//! The proof is a variable. `tty7_kept=…` lives in that shell's memory and +//! nowhere else: no file has it, the daemon has never seen it, and a shell +//! started after the handoff would answer with an empty string. Getting the +//! value back is something only the original process can do. + +#![cfg(unix)] + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command, Stdio}; +use std::time::{Duration, Instant}; + +use tty7_core::client::PaneClient; +use tty7_core::daemon::protocol::{DaemonMsg, ShellSpec, WinSize}; + +const READY_WITHIN: Duration = Duration::from_secs(30); +const STREAM_WITHIN: Duration = Duration::from_secs(30); + +struct Daemon { + child: Child, + dir: tempfile::TempDir, +} + +impl Daemon { + fn start() -> Daemon { + let dir = tempfile::TempDir::new().unwrap(); + let child = Command::new(Self::binary()) + .arg("--daemon") + .arg("--config-dir") + .arg(dir.path()) + .env("TTY7_DATA_DIR", dir.path()) + .env("TTY7_CONTROL_SOCK", dir.path().join("control.sock")) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .expect("start tty7-server --daemon"); + let daemon = Daemon { child, dir }; + daemon.await_ready(); + daemon + } + + fn binary() -> PathBuf { + PathBuf::from(env!("CARGO_BIN_EXE_tty7-server")) + } + + fn panes(&self) -> PaneClient { + PaneClient::at(self.dir.path().join("daemon.sock")) + } + + /// The pid the daemon records for itself. An `exec` keeps it; stopping and + /// starting cannot. + fn recorded_pid(&self) -> Option { + std::fs::read_to_string(self.dir.path().join("daemon.pid")) + .ok()? + .trim() + .parse() + .ok() + } + + /// A fresh uuid per program image: it is minted on first use and lives in + /// memory, so it survives anything except being replaced. Together with the + /// pid it pins down exactly what happened — same pid and a new instance is + /// an `exec` and nothing else. + fn instance(&self) -> String { + self.panes() + .version() + .expect("the daemon answers its version") + .instance + } + + fn await_ready(&self) { + let deadline = Instant::now() + READY_WITHIN; + loop { + if self.panes().version().is_ok() { + return; + } + assert!( + Instant::now() < deadline, + "the daemon did not open its pane endpoint within {READY_WITHIN:?}" + ); + std::thread::sleep(Duration::from_millis(50)); + } + } +} + +impl Drop for Daemon { + fn drop(&mut self) { + 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 { + ShellSpec { + program: "/bin/sh".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) + ), + } + } +} + +#[test] +fn a_handoff_keeps_the_process_the_pty_and_the_shell_that_is_on_it() { + let daemon = Daemon::start(); + let panes = daemon.panes(); + let before_pid = daemon.recorded_pid().expect("the daemon records its pid"); + let before_instance = daemon.instance(); + + let mut session = panes + .spawn(None, size(), Some(interactive_shell()), None, None) + .expect("spawn an interactive pane"); + let pane_id = session.pane_id(); + session + .set_recv_timeout(Some(STREAM_WITHIN)) + .expect("bound the stream reads"); + + // Put something in this shell's memory that exists nowhere else, and print + // a marker so we know the shell has read its input before we hand over. + session + .input(b"tty7_kept=survivor; echo tty7_before_$tty7_kept\r") + .expect("the shell takes input"); + collect_until(&mut session, b"tty7_before_survivor"); + drop(session); + + panes + .hand_off(&Daemon::binary()) + .expect("the daemon hands over"); + daemon.await_ready(); + + assert_eq!( + daemon.recorded_pid(), + Some(before_pid), + "an exec keeps the process; a different pid here would mean the daemon stopped and \ + started, which is the thing this is supposed to avoid" + ); + assert_ne!( + daemon.instance(), + before_instance, + "and a *new image* has to be what is answering — without this the test would pass just \ + as well if the handoff had quietly done nothing at all" + ); + + let mut session = panes + .attach(pane_id, size()) + .expect("the pane is still there under the same id"); + session + .set_recv_timeout(Some(STREAM_WITHIN)) + .expect("bound the stream reads"); + + // The replay is the ring the previous image was holding. + let replayed = collect_until(&mut session, b"tty7_before_survivor"); + assert!( + windows_contain(&replayed, b"tty7_before_survivor"), + "the pane came back without the output it had before the handoff" + ); + + // And the variable. Only the shell that ran the first line can answer this; + // the command line echoes back unexpanded, so the expansion in the output + // is unambiguous. + session + .input(b"echo tty7_after_$tty7_kept\r") + .expect("the shell still takes input"); + collect_until(&mut session, b"tty7_after_survivor"); + + session.kill().expect("kill the pane"); +} + +#[test] +fn a_handoff_to_something_that_will_not_exec_leaves_the_daemon_serving() { + let daemon = Daemon::start(); + let panes = daemon.panes(); + let before_pid = daemon.recorded_pid().expect("the daemon records its pid"); + let before_instance = daemon.instance(); + + let mut session = panes + .spawn(None, size(), Some(interactive_shell()), None, None) + .expect("spawn an interactive pane"); + let pane_id = session.pane_id(); + session + .set_recv_timeout(Some(STREAM_WITHIN)) + .expect("bound the stream reads"); + session + .input(b"echo tty7_still_here\r") + .expect("the shell takes input"); + collect_until(&mut session, b"tty7_still_here"); + + let err = panes + .hand_off(Path::new("/nonexistent/tty7-that-is-not-there")) + .expect_err("a binary that cannot be executed must be reported, not assumed"); + assert!( + err.to_string().contains("could not exec"), + "the refusal was {err}" + ); + + // The state is staged before the exec and the exec is the last step, so a + // failure at that step has to cost nothing at all. + assert_eq!(daemon.recorded_pid(), Some(before_pid)); + assert_eq!( + daemon.instance(), + before_instance, + "nothing was replaced, so the same image has to still be answering" + ); + session + .input(b"echo tty7_unharmed\r") + .expect("the pane still has its shell"); + collect_until(&mut session, b"tty7_unharmed"); + assert_eq!( + session.pane_id(), + pane_id, + "the pane the caller was holding is the pane it still holds" + ); + + session.kill().expect("kill the pane"); +} diff --git a/src/ui/app.rs b/src/ui/app.rs index 975d62fc..efb89a7a 100644 --- a/src/ui/app.rs +++ b/src/ui/app.rs @@ -1409,10 +1409,21 @@ impl Tty7App { } pub(crate) fn restart_daemon(&mut self, window: &mut Window, cx: &mut Context) { + // Two different actions wearing one name. Where the service can rewrite + // itself in place, nothing in a pane is interrupted and promising the + // user a bloodbath would be a lie that costs them the feature; where it + // cannot, every running command really does end, and that is the one + // thing they need to be told before they agree. + let in_place = + crate::daemon::spawn::local_daemon_supports(crate::daemon::protocol::FEATURE_HANDOFF); let answer = window.prompt( PromptLevel::Warning, t(L10nKey::AppRestartServerTitle), - Some(t(L10nKey::AppRestartServerBody)), + Some(t(if in_place { + L10nKey::AppRestartServerBodyInPlace + } else { + L10nKey::AppRestartServerBody + })), &crate::ui::confirm_answers( t(L10nKey::AppRestart), t(crate::ui::i18n::L10nKey::Cancel), @@ -1443,7 +1454,20 @@ impl Tty7App { return; } let restarted = cx - .background_spawn(async move { crate::daemon::spawn::restart() }) + .background_spawn(async move { + // In place if it can be: the panes and everything running + // in them carry straight over. Every way this fails leaves + // the daemon serving exactly as it was, so falling back to + // stopping and starting it loses nothing that was not + // already going to be lost. + match crate::daemon::spawn::hand_off() { + Ok(()) => Ok(()), + Err(e) => { + log::info!("service could not hand over in place ({e}); restarting it"); + crate::daemon::spawn::restart() + } + } + }) .await; let _ = this.update_in(cx, |this, window, cx| { match &restarted { diff --git a/src/ui/i18n/en.rs b/src/ui/i18n/en.rs index 166aeb6e..68fbbb6c 100644 --- a/src/ui/i18n/en.rs +++ b/src/ui/i18n/en.rs @@ -1340,6 +1340,18 @@ pub fn translate_en(key: L10nKey) -> &'static str { } L10nKey::Replace => "Replace", L10nKey::SftpErrorInvalidOctalMode => "invalid octal mode", + L10nKey::SettingsDaemonStaleDescInPlace => { + "tty7 was updated in place, so the app is new but your panes are still served by the \ + previous build. The server can replace itself with the new one without stopping: \ + your shells and whatever is running in them carry straight over. Panes on tty7's \ + built-in SSH client are the exception — those connections close and need reopening." + } + L10nKey::AppRestartServerBodyInPlace => { + "The background server replaces itself with this build without stopping. Your shells \ + keep running — commands, agents and `ssh` sessions in a pane are not interrupted — \ + and the window reconnects to them a moment later. Panes on tty7's built-in SSH \ + client are the exception: those connections close and need reopening." + } L10nKey::PaneRestoredScreenBanner => { "restored screen — this shell is new, nothing above it is still running" } diff --git a/src/ui/i18n/ja.rs b/src/ui/i18n/ja.rs index a6a37a26..13959cab 100644 --- a/src/ui/i18n/ja.rs +++ b/src/ui/i18n/ja.rs @@ -1387,6 +1387,18 @@ pub fn translate_ja(key: L10nKey) -> Option<&'static str> { } L10nKey::Replace => "置き換える", L10nKey::SftpErrorInvalidOctalMode => "無効な 8 進数モードです", + L10nKey::SettingsDaemonStaleDescInPlace => { + "tty7 はその場で更新されたため、アプリは新しくても、ペインは前のビルドが提供したままです。\ + サーバーは停止せずに新しいビルドへ自分自身を置き換えられます。\ + シェルとその中で動いているものはそのまま引き継がれます。\ + tty7 内蔵の SSH クライアントを使うペインだけは例外で、その接続は閉じられ、開き直しが必要です" + } + L10nKey::AppRestartServerBodyInPlace => { + "バックグラウンドサーバーは停止せずに、自分自身をこのビルドに置き換えます。\ + シェルは動いたままで、ペイン内のコマンド・エージェント・`ssh` セッションは中断されません。\ + ウィンドウはすぐに再接続します。\ + tty7 内蔵の SSH クライアントを使うペインだけは例外で、その接続は閉じられ、開き直しが必要です" + } L10nKey::PaneRestoredScreenBanner => { "復元された画面 — 以下は新しいシェルで、これより上のものは動いていません" } diff --git a/src/ui/i18n/mod.rs b/src/ui/i18n/mod.rs index 7d509b7b..04efe5be 100644 --- a/src/ui/i18n/mod.rs +++ b/src/ui/i18n/mod.rs @@ -1108,6 +1108,8 @@ l10n_keys! { Replace, SftpErrorInvalidOctalMode, PaneRestoredScreenBanner, + AppRestartServerBodyInPlace, + SettingsDaemonStaleDescInPlace, SettingsPersistScrollback, SettingsPersistScrollbackDescription, } diff --git a/src/ui/i18n/zh.rs b/src/ui/i18n/zh.rs index 5a20b48f..568d30ac 100644 --- a/src/ui/i18n/zh.rs +++ b/src/ui/i18n/zh.rs @@ -1273,6 +1273,16 @@ pub fn translate_zh(key: L10nKey) -> Option<&'static str> { L10nKey::SftpReplaceBody => "{names} 在这个文件夹里已经存在,上传会覆盖它们。", L10nKey::Replace => "覆盖", L10nKey::SftpErrorInvalidOctalMode => "无效的八进制模式", + L10nKey::SettingsDaemonStaleDescInPlace => { + "tty7 是原地更新的,所以应用是新的,但你的面板仍由上一个版本在服务。\ + server 可以在不停止的情况下把自己换成新版本:你的 shell 和里面正在跑的东西会直接延续下来。\ + 用 tty7 内置 SSH 客户端的面板除外——那些连接会断开,需要重新打开。" + } + L10nKey::AppRestartServerBodyInPlace => { + "后台 server 会在不停止的情况下把自己换成当前这个版本。\ + 你的 shell 会继续运行——面板里的命令、agent、`ssh` 会话都不会被打断——窗口稍后会重新连上它们。\ + 用 tty7 内置 SSH 客户端的面板除外:那些连接会断开,需要重新打开。" + } L10nKey::PaneRestoredScreenBanner => { "已恢复的画面 —— 下面是新的 shell,上面的内容都已不在运行" } diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 589b156f..afdf65c5 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -5734,6 +5734,15 @@ impl Tty7App { }); let failure = update_status.failure.clone(); let stale_daemon = crate::daemon::spawn::local_daemon_stale_build(); + // Whether picking up the new build costs the user their running panes + // decides what this offer is, so it decides what it says. + let stale_daemon_note = if crate::daemon::spawn::local_daemon_supports( + crate::daemon::protocol::FEATURE_HANDOFF, + ) { + L10nKey::SettingsDaemonStaleDescInPlace + } else { + L10nKey::SettingsDaemonStaleDesc + }; let check_for_updates = cx.global::().check_for_updates; let auto_download = cx.global::().auto_download_updates; let channel_idx = match cx.global::().update_channel { @@ -6072,7 +6081,7 @@ impl Tty7App { div() .text_xs() .text_color(muted_fg) - .child(t(L10nKey::SettingsDaemonStaleDesc)), + .child(t(stale_daemon_note)), ) .child( h_flex().child(