#[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, pub expected_protocol: Option, pub snapshot: crate::persist::SessionSnapshot, pub panes: Vec, } #[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, } #[cfg(unix)] pub(crate) struct ReceivedHandoff { pub manifest: HandoffManifest, pub fds: Vec, 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 { 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 { 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 { 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 { 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, expected_protocol: Option, expected_version: Option, ) -> 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 { 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> { 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::(); 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::(); 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"); }