aboutsummaryrefslogtreecommitdiff
path: root/src/server/handoff.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/server/handoff.rs')
-rw-r--r--src/server/handoff.rs446
1 files changed, 446 insertions, 0 deletions
diff --git a/src/server/handoff.rs b/src/server/handoff.rs
new file mode 100644
index 00000000..d38bdc29
--- /dev/null
+++ b/src/server/handoff.rs
@@ -0,0 +1,446 @@
+#[cfg(unix)]
+use std::io::{self, Read, Write};
+#[cfg(unix)]
+use std::os::fd::{AsRawFd, RawFd};
+#[cfg(unix)]
+use std::os::unix::net::{UnixListener, UnixStream};
+#[cfg(unix)]
+use std::os::unix::process::CommandExt;
+#[cfg(unix)]
+use std::path::{Path, PathBuf};
+#[cfg(unix)]
+use std::process::Command;
+#[cfg(unix)]
+use std::time::Duration;
+
+#[cfg(unix)]
+use serde::{Deserialize, Serialize};
+#[cfg(unix)]
+use tracing::{info, warn};
+
+#[cfg(unix)]
+const HANDOFF_VERSION: u32 = 1;
+#[cfg(unix)]
+const READY_TIMEOUT: Duration = Duration::from_secs(30);
+#[cfg(unix)]
+pub(crate) const MAX_FDS_PER_HANDOFF: usize = 64;
+#[cfg(unix)]
+pub(crate) const MAX_REPLAY_BYTES_PER_PANE: usize = 8 * 1024;
+#[cfg(unix)]
+pub(crate) const COMMIT_TIMEOUT: Duration = READY_TIMEOUT;
+
+#[cfg(unix)]
+#[derive(Serialize, Deserialize)]
+pub(crate) struct HandoffManifest {
+ pub version: u32,
+ pub source_version: String,
+ pub source_protocol: u32,
+ pub expected_version: Option<String>,
+ pub expected_protocol: Option<u32>,
+ pub snapshot: crate::persist::SessionSnapshot,
+ pub panes: Vec<HandoffPane>,
+}
+
+#[cfg(unix)]
+#[derive(Debug, Serialize, Deserialize)]
+pub(crate) struct HandoffPane {
+ pub pane_id: u32,
+ pub child_pid: u32,
+ pub rows: u16,
+ pub cols: u16,
+ pub cell_width_px: u32,
+ pub cell_height_px: u32,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub initial_history_ansi: Option<String>,
+}
+
+#[cfg(unix)]
+pub(crate) struct ReceivedHandoff {
+ pub manifest: HandoffManifest,
+ pub fds: Vec<RawFd>,
+ pub stream: UnixStream,
+}
+
+#[cfg(unix)]
+pub(crate) fn handoff_socket_path() -> PathBuf {
+ crate::session::data_dir().join(format!("herdr-handoff-{}.sock", std::process::id()))
+}
+
+#[cfg(unix)]
+pub(crate) fn spawn_handoff_import(
+ import_exe: Option<&Path>,
+ socket_path: &Path,
+ token: &str,
+) -> io::Result<u32> {
+ let fallback_exe;
+ let exe = if let Some(import_exe) = import_exe {
+ import_exe
+ } else {
+ fallback_exe = std::env::current_exe().map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!("failed to determine herdr executable path: {err}"),
+ )
+ })?;
+ &fallback_exe
+ };
+ let mut command = Command::new(exe);
+ command
+ .arg("server")
+ .arg("--handoff-import")
+ .arg(socket_path)
+ .arg(token)
+ .process_group(0)
+ .stdin(std::process::Stdio::null())
+ .stdout(std::process::Stdio::null())
+ .stderr(std::process::Stdio::null());
+ let child = command.spawn().map_err(|err| {
+ io::Error::new(
+ err.kind(),
+ format!(
+ "failed to spawn handoff import server at {}: {err}",
+ exe.display()
+ ),
+ )
+ })?;
+ Ok(child.id())
+}
+
+#[cfg(unix)]
+pub(crate) fn bind_listener(socket_path: &Path) -> io::Result<UnixListener> {
+ let _ = std::fs::remove_file(socket_path);
+ let listener = UnixListener::bind(socket_path)?;
+ listener.set_nonblocking(true)?;
+ restrict_socket_permissions(socket_path)?;
+ Ok(listener)
+}
+
+#[cfg(unix)]
+pub(crate) fn accept_and_validate_on(
+ listener: UnixListener,
+ socket_path: &Path,
+ token: &str,
+ manifest: &HandoffManifest,
+) -> io::Result<UnixStream> {
+ let (mut stream, _) = accept_with_timeout(&listener, READY_TIMEOUT)?;
+ stream.set_nonblocking(false)?;
+ stream.set_read_timeout(Some(READY_TIMEOUT))?;
+ stream.set_write_timeout(Some(READY_TIMEOUT))?;
+ let token_line = read_line_unbuffered(&mut stream)?;
+ if token_line.trim_end() != token {
+ return Err(io::Error::new(
+ io::ErrorKind::PermissionDenied,
+ "handoff import token mismatch",
+ ));
+ }
+
+ serde_json::to_writer(&mut stream, manifest).map_err(io::Error::other)?;
+ stream.write_all(b"\n")?;
+ stream.flush()?;
+
+ stream.set_read_timeout(Some(READY_TIMEOUT))?;
+ let validated = read_line_unbuffered(&mut stream)?;
+ if validated.trim_end() != "validated" {
+ return Err(io::Error::other("handoff import did not validate manifest"));
+ }
+ let _ = std::fs::remove_file(socket_path);
+ Ok(stream)
+}
+
+#[cfg(unix)]
+pub(crate) fn send_fds_and_wait_restored(stream: &mut UnixStream, fds: &[RawFd]) -> io::Result<()> {
+ if fds.len() > MAX_FDS_PER_HANDOFF {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidInput,
+ format!("handoff supports at most {MAX_FDS_PER_HANDOFF} pane file descriptors at once"),
+ ));
+ }
+ send_fds(stream, fds)?;
+
+ stream.set_read_timeout(Some(READY_TIMEOUT))?;
+ let restored = read_line_unbuffered(&mut *stream)?;
+ if restored.trim_end() != "restored" {
+ return Err(io::Error::other(
+ "handoff import did not report restored runtimes",
+ ));
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+pub(crate) fn wait_ready(stream: &mut UnixStream) -> io::Result<()> {
+ stream.set_read_timeout(Some(READY_TIMEOUT))?;
+ let ready = read_line_unbuffered(&mut *stream)?;
+ if ready.trim_end() != "ready" {
+ return Err(io::Error::other("handoff import did not report ready"));
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+pub(crate) fn report_committed(stream: &mut UnixStream) -> io::Result<()> {
+ stream.write_all(b"committed\n")?;
+ stream.flush()
+}
+
+#[cfg(unix)]
+pub(crate) fn wait_owned_ack(stream: &mut UnixStream) {
+ if let Err(err) = stream.set_read_timeout(Some(READY_TIMEOUT)) {
+ warn!(err = %err, "failed to set handoff ownership ack timeout");
+ return;
+ }
+ match read_line_unbuffered(&mut *stream) {
+ Ok(owned) if owned.trim_end() == "owned" => {}
+ Ok(other) => {
+ warn!(
+ response = %other.trim_end(),
+ "handoff import sent unexpected ownership ack after commit"
+ );
+ }
+ Err(err) => {
+ warn!(err = %err, "handoff import ownership ack was not received after commit");
+ }
+ }
+}
+
+#[cfg(unix)]
+pub(crate) fn receive(socket_path: &Path, token: &str) -> io::Result<ReceivedHandoff> {
+ let mut stream = UnixStream::connect(socket_path)?;
+ stream.write_all(token.as_bytes())?;
+ stream.write_all(b"\n")?;
+ stream.flush()?;
+
+ let manifest_line = read_line_unbuffered(&mut stream)?;
+ let manifest: HandoffManifest =
+ serde_json::from_str(&manifest_line).map_err(io::Error::other)?;
+ if manifest.version != HANDOFF_VERSION {
+ return Err(io::Error::other(format!(
+ "unsupported handoff version {}",
+ manifest.version
+ )));
+ }
+ if manifest
+ .expected_protocol
+ .is_some_and(|protocol| protocol != crate::protocol::PROTOCOL_VERSION)
+ {
+ return Err(io::Error::other(format!(
+ "handoff expected protocol {}, but this server speaks protocol {}",
+ manifest.expected_protocol.unwrap_or_default(),
+ crate::protocol::PROTOCOL_VERSION
+ )));
+ }
+ if manifest
+ .expected_version
+ .as_deref()
+ .is_some_and(|version| version != env!("CARGO_PKG_VERSION"))
+ {
+ return Err(io::Error::other(format!(
+ "handoff expected herdr v{}, but this server is v{}",
+ manifest.expected_version.as_deref().unwrap_or("unknown"),
+ env!("CARGO_PKG_VERSION")
+ )));
+ }
+ stream.write_all(b"validated\n")?;
+ stream.flush()?;
+ let fds = recv_fds(&stream, manifest.panes.len())?;
+ Ok(ReceivedHandoff {
+ manifest,
+ fds,
+ stream,
+ })
+}
+
+#[cfg(unix)]
+pub(crate) fn report_restored(stream: &mut UnixStream) -> io::Result<()> {
+ stream.write_all(b"restored\n")?;
+ stream.flush()
+}
+
+#[cfg(unix)]
+pub(crate) fn report_ready(stream: &mut UnixStream) -> io::Result<()> {
+ stream.write_all(b"ready\n")?;
+ stream.flush()
+}
+
+#[cfg(unix)]
+pub(crate) fn wait_committed(stream: &mut UnixStream) -> io::Result<()> {
+ stream.set_read_timeout(Some(READY_TIMEOUT))?;
+ let committed = read_line_unbuffered(&mut *stream)?;
+ if committed.trim_end() != "committed" {
+ return Err(io::Error::other("handoff source did not commit"));
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+pub(crate) fn report_owned(stream: &mut UnixStream) -> io::Result<()> {
+ stream.write_all(b"owned\n")?;
+ stream.flush()
+}
+
+#[cfg(unix)]
+pub(crate) fn manifest_for(
+ snapshot: crate::persist::SessionSnapshot,
+ panes: Vec<HandoffPane>,
+ expected_protocol: Option<u32>,
+ expected_version: Option<String>,
+) -> HandoffManifest {
+ HandoffManifest {
+ version: HANDOFF_VERSION,
+ source_version: env!("CARGO_PKG_VERSION").to_string(),
+ source_protocol: crate::protocol::PROTOCOL_VERSION,
+ expected_version,
+ expected_protocol,
+ snapshot,
+ panes,
+ }
+}
+
+#[cfg(unix)]
+fn restrict_socket_permissions(path: &Path) -> io::Result<()> {
+ use std::os::unix::fs::PermissionsExt;
+
+ std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
+}
+
+#[cfg(unix)]
+fn accept_with_timeout(
+ listener: &UnixListener,
+ timeout: Duration,
+) -> io::Result<(UnixStream, std::os::unix::net::SocketAddr)> {
+ let deadline = std::time::Instant::now() + timeout;
+ loop {
+ match listener.accept() {
+ Ok(accepted) => return Ok(accepted),
+ Err(err) if err.kind() == io::ErrorKind::WouldBlock => {
+ if std::time::Instant::now() >= deadline {
+ return Err(io::Error::new(
+ io::ErrorKind::TimedOut,
+ "timed out waiting for handoff import connection",
+ ));
+ }
+ std::thread::sleep(Duration::from_millis(25));
+ }
+ Err(err) if err.kind() == io::ErrorKind::Interrupted => {}
+ Err(err) => return Err(err),
+ }
+ }
+}
+
+#[cfg(unix)]
+fn read_line_unbuffered(stream: &mut UnixStream) -> io::Result<String> {
+ let mut bytes = Vec::new();
+ let mut byte = [0u8; 1];
+ loop {
+ let read = stream.read(&mut byte)?;
+ if read == 0 {
+ return Err(io::Error::new(
+ io::ErrorKind::UnexpectedEof,
+ "handoff stream closed while reading line",
+ ));
+ }
+ bytes.push(byte[0]);
+ if byte[0] == b'\n' {
+ return String::from_utf8(bytes)
+ .map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err));
+ }
+ if bytes.len() > 16 * 1024 * 1024 {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ "handoff line exceeded maximum size",
+ ));
+ }
+ }
+}
+
+#[cfg(unix)]
+fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> {
+ if fds.is_empty() {
+ return Ok(());
+ }
+ let byte = [b'F'];
+ let iov = [libc::iovec {
+ iov_base: byte.as_ptr() as *mut libc::c_void,
+ iov_len: byte.len(),
+ }];
+ let fd_bytes = std::mem::size_of_val(fds);
+ let mut control = vec![0u8; unsafe { libc::CMSG_SPACE(fd_bytes as u32) as usize }];
+ let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
+ msg.msg_iov = iov.as_ptr() as *mut libc::iovec;
+ msg.msg_iovlen = iov.len() as _;
+ msg.msg_control = control.as_mut_ptr() as *mut libc::c_void;
+ msg.msg_controllen = control.len() as _;
+
+ unsafe {
+ let cmsg = libc::CMSG_FIRSTHDR(&msg);
+ if cmsg.is_null() {
+ return Err(io::Error::other("failed to allocate fd control message"));
+ }
+ (*cmsg).cmsg_level = libc::SOL_SOCKET;
+ (*cmsg).cmsg_type = libc::SCM_RIGHTS;
+ (*cmsg).cmsg_len = libc::CMSG_LEN(fd_bytes as u32) as _;
+ std::ptr::copy_nonoverlapping(fds.as_ptr() as *const u8, libc::CMSG_DATA(cmsg), fd_bytes);
+ if libc::sendmsg(stream.as_raw_fd(), &msg, 0) < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ }
+ Ok(())
+}
+
+#[cfg(unix)]
+fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result<Vec<RawFd>> {
+ if expected == 0 {
+ return Ok(Vec::new());
+ }
+ let mut byte = [0u8; 1];
+ let mut iov = [libc::iovec {
+ iov_base: byte.as_mut_ptr() as *mut libc::c_void,
+ iov_len: byte.len(),
+ }];
+ let fd_bytes = expected * std::mem::size_of::<RawFd>();
+ let mut control = vec![0u8; unsafe { libc::CMSG_SPACE(fd_bytes as u32) as usize }];
+ let mut msg: libc::msghdr = unsafe { std::mem::zeroed() };
+ msg.msg_iov = iov.as_mut_ptr();
+ msg.msg_iovlen = iov.len() as _;
+ msg.msg_control = control.as_mut_ptr() as *mut libc::c_void;
+ msg.msg_controllen = control.len() as _;
+
+ let read = unsafe { libc::recvmsg(stream.as_raw_fd(), &mut msg, 0) };
+ if read < 0 {
+ return Err(io::Error::last_os_error());
+ }
+ if msg.msg_flags & libc::MSG_CTRUNC != 0 {
+ return Err(io::Error::other("handoff fd control message was truncated"));
+ }
+
+ let mut out = Vec::new();
+ unsafe {
+ let cmsg = libc::CMSG_FIRSTHDR(&msg);
+ if cmsg.is_null()
+ || (*cmsg).cmsg_level != libc::SOL_SOCKET
+ || (*cmsg).cmsg_type != libc::SCM_RIGHTS
+ {
+ return Err(io::Error::other("handoff fd message missing SCM_RIGHTS"));
+ }
+ let data_len = ((*cmsg).cmsg_len as usize).saturating_sub(libc::CMSG_LEN(0) as usize);
+ let count = data_len / std::mem::size_of::<RawFd>();
+ let data = libc::CMSG_DATA(cmsg) as *const RawFd;
+ for idx in 0..count {
+ out.push(*data.add(idx));
+ }
+ }
+ if out.len() != expected {
+ for fd in out {
+ let _ = unsafe { libc::close(fd) };
+ }
+ return Err(io::Error::other(format!(
+ "expected {expected} handoff fds, received fewer"
+ )));
+ }
+ Ok(out)
+}
+
+#[cfg(unix)]
+pub(crate) fn log_import_result(panes: usize) {
+ info!(panes, "handoff import ready");
+}