feat(daemon): upgrade the daemon in place instead of killing every shell

Picking up a new build meant stopping the daemon, and stopping the daemon
means every pane dies: the pty master is a descriptor this process holds,
so when the process goes the slave side raises SIGHUP and takes the shell,
the agent and the half-finished command with it. That is why the update
path leaves the old daemon serving and Settings has to offer the restart
as a thing you schedule for a quiet moment.

`execve` does not have that problem. It replaces the image and keeps the
process: same pid, same children, same descriptors, same file locks. The
daemon now rewrites itself that way on `ClientMsg::Handoff` — it writes
what it knows about each pane into a blob, clears FD_CLOEXEC on the pty
masters, the blob and the singleton lock, and execs the new binary, which
picks the panes back up on the other side.

- **the seat travels on the command line, not in the blob.** The lock is
  still held by this process, so the new image must adopt the descriptor
  rather than ask for the lock again — asking would be refused by its own
  lock and it would stand down in favour of itself. A daemon that loses
  its panes is a bad afternoon; a daemon that exits leaves the machine
  with nothing serving, so that one fact has to survive an unreadable blob.
- **the blob is unlinked before it is written.** It holds every pane's
  ring, which is the output `scrollback` makes people opt into storing;
  a handoff must not be a back door for writing it to disk.
- **the exec is the last step.** Everything is staged first, so any
  failure before it costs a log line and the daemon carries on serving —
  which is what lets callers treat a failed handoff as "fall back to a
  restart" without having lost anything on the way.

Native SSH panes cannot cross — their session is cipher state in memory,
not a descriptor — so they are hung up first and the far end sees a clean
close. Windows has neither execve nor a transferable ConPTY handle, so it
keeps the stop/start path; the dialogs there still promise what they
always did, and the new copy is shown only where it is true.

Also retries flock on EINTR: a signal landing mid-call said nothing about
the lock, but was reported as "could not be evaluated", which starts a
second daemon beside the first — the split machine singleton exists to
prevent.

The end-to-end test sets a variable in the shell, hands over, and reads it
back. Nothing but the original process can answer that, and the daemon's
instance id changing while its pid does not is what says an exec really
happened.
This commit is contained in:
l0ng-ai
2026-08-09 23:03:40 +08:00
parent e00cbb41c2
commit 422ac027d8
15 changed files with 1589 additions and 39 deletions
+28
View File
@@ -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 {
+474
View File
@@ -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<PaneRecord>,
}
#[derive(Serialize, Deserialize)]
struct PaneRecord {
id: u64,
owner: Option<String>,
master_fd: RawFd,
child_pid: u32,
integration_dir: Option<PathBuf>,
size: WinSize,
cwd: Option<PathBuf>,
shell_active: bool,
at_prompt: bool,
last_exit: Option<i32>,
remote: Option<crate::daemon::protocol::RemoteContext>,
agent: Option<crate::core::cli_agent::CLIAgent>,
agent_argv: Option<Vec<String>>,
agent_session: Option<crate::core::cli_agent::AgentSessionState>,
/// 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<RawFd>,
}
pub fn requested() -> Option<Inheritance> {
let args: Vec<std::ffi::OsString> = 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<RawFd> {
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<Carried>,
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<Carried>,
next_pane_id: u64,
seat_fd: Option<RawFd>,
) -> 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<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);
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<std::fs::File> {
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<Adopted> {
let mut file = unsafe { <std::fs::File as std::os::fd::FromRawFd>::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::<Vec<_>>();
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"
);
}
}
+7
View File
@@ -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;
+435 -20
View File
@@ -760,6 +760,17 @@ struct PtyBackend {
integration_dir: Option<PathBuf>,
}
/// 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<dyn MasterPty + Send>,
child: Arc<Mutex<Box<dyn Child + Send + Sync>>>,
shell_pid: Option<u32>,
integration_dir: Option<PathBuf>,
reader_handle: Box<dyn Read + Send>,
writer: Arc<Mutex<Box<dyn Write + Send>>>,
}
struct NativeSshBackend {
handle: Arc<crate::daemon::ssh::SshSessionHandle>,
connection: crate::daemon::ssh::SharedConnection,
@@ -921,6 +932,235 @@ fn push_image_frame(frames: &mut Vec<GraphicsFrame>, frame: Vec<u8>) {
}
}
/// 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<String>,
pub master_fd: std::os::fd::RawFd,
pub child_pid: u32,
pub integration_dir: Option<PathBuf>,
pub size: WinSize,
pub ring: Vec<crate::daemon::scrollback::Segment>,
pub cwd: Option<PathBuf>,
pub shell_active: bool,
pub at_prompt: bool,
pub last_exit: Option<i32>,
pub remote: Option<RemoteContext>,
pub agent: Option<crate::core::cli_agent::CLIAgent>,
pub agent_argv: Option<Vec<String>>,
pub agent_session: Option<crate::core::cli_agent::AgentSessionState>,
}
/// 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<Self> {
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<std::fs::File> {
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<PtySize> {
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<Box<dyn Read + Send>> {
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<Box<dyn Write + Send>> {
Ok(Box::new(self.dup()?))
}
fn as_raw_fd(&self) -> Option<std::os::fd::RawFd> {
use std::os::fd::AsRawFd as _;
Some(self.fd.as_raw_fd())
}
fn process_group_leader(&self) -> Option<libc::pid_t> {
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<portable_pty::ExitStatus>,
}
#[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<Option<portable_pty::ExitStatus>> {
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<Option<portable_pty::ExitStatus>> {
self.reap(libc::WNOHANG)
}
fn wait(&mut self) -> std::io::Result<portable_pty::ExitStatus> {
loop {
if let Some(status) = self.reap(0)? {
return Ok(status);
}
}
}
fn process_id(&self) -> Option<u32> {
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<dyn portable_pty::ChildKiller + Send + Sync> {
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<dyn portable_pty::ChildKiller + Send + Sync> {
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<String>,
on_dead: impl FnOnce() + Send + 'static,
) -> Arc<Self> {
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<crate::daemon::pane::Carried> {
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<Arc<Self>> {
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<crate::daemon::scrollback::Segment> {
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"));
+31 -5
View File
@@ -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)?),
+139
View File
@@ -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<Registry>, 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<Registry>) -> 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(&registry, &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 {
+68 -11
View File
@@ -52,6 +52,53 @@ fn lock_path() -> Option<PathBuf> {
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<std::os::fd::RawFd> {
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 { <File as std::os::fd::FromRawFd>::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<Option<File>> {
.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),
}
}
}
+77
View File
@@ -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 _;
+258
View File
@@ -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<u32> {
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<u8> {
let mut seen: Vec<u8> = Vec::new();
loop {
match session.recv() {
Ok(DaemonMsg::Output(bytes)) | Ok(DaemonMsg::Snapshot(bytes)) => {
seen.extend_from_slice(&bytes);
if windows_contain(&seen, marker) {
return seen;
}
}
Ok(DaemonMsg::Exited { code }) => panic!(
"the pane exited ({code:?}) before {:?} appeared; saw {:?}",
String::from_utf8_lossy(marker),
String::from_utf8_lossy(&seen)
),
Ok(_) => {}
Err(e) => panic!(
"the pane stream ended early: {e}; saw {:?}",
String::from_utf8_lossy(&seen)
),
}
}
}
#[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");
}
+26 -2
View File
@@ -1409,10 +1409,21 @@ impl Tty7App {
}
pub(crate) fn restart_daemon(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// 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 {
+12
View File
@@ -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"
}
+12
View File
@@ -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 => {
"復元された画面 — 以下は新しいシェルで、これより上のものは動いていません"
}
+2
View File
@@ -1108,6 +1108,8 @@ l10n_keys! {
Replace,
SftpErrorInvalidOctalMode,
PaneRestoredScreenBanner,
AppRestartServerBodyInPlace,
SettingsDaemonStaleDescInPlace,
SettingsPersistScrollback,
SettingsPersistScrollbackDescription,
}
+10
View File
@@ -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,上面的内容都已不在运行"
}
+10 -1
View File
@@ -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::<Config>().check_for_updates;
let auto_download = cx.global::<Config>().auto_download_updates;
let channel_idx = match cx.global::<Config>().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(