Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
113 changes: 83 additions & 30 deletions src/server/handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,11 @@ const HANDOFF_VERSION: u32 = 1;
const READY_TIMEOUT: Duration = Duration::from_secs(30);
#[cfg(unix)]
const OWNED_ACK_TIMEOUT: Duration = Duration::from_millis(500);
// Descriptors are transferred in batches of this size. A single SCM_RIGHTS
// control message caps out at 253 descriptors on Linux and 254 on macOS, so the
// batch stays well below both limits and the number of panes stays unbounded.
#[cfg(unix)]
pub(crate) const MAX_FDS_PER_HANDOFF: usize = 64;
const FDS_PER_MESSAGE: usize = 64;
#[cfg(unix)]
pub(crate) const MAX_REPLAY_BYTES_PER_PANE: usize = 8 * 1024;
#[cfg(unix)]
Expand Down Expand Up @@ -174,12 +177,6 @@ pub(crate) fn accept_and_validate_on(

#[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))?;
Expand Down Expand Up @@ -382,6 +379,14 @@ fn read_line_unbuffered(stream: &mut UnixStream) -> io::Result<String> {

#[cfg(unix)]
fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> {
for batch in fds.chunks(FDS_PER_MESSAGE) {
send_fd_batch(stream, batch)?;
}
Ok(())
}

#[cfg(unix)]
fn send_fd_batch(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> {
if fds.is_empty() {
return Ok(());
}
Expand Down Expand Up @@ -414,17 +419,48 @@ fn send_fds(stream: &UnixStream, fds: &[RawFd]) -> io::Result<()> {
Ok(())
}

#[cfg(unix)]
fn close_raw_fds(fds: &[RawFd]) {
for fd in fds {
let _ = unsafe { libc::close(*fd) };
}
}

#[cfg(unix)]
fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result<Vec<RawFd>> {
if expected == 0 {
return Ok(Vec::new());
let mut out: Vec<RawFd> = Vec::with_capacity(expected);
while out.len() < expected {
let wanted = (expected - out.len()).min(FDS_PER_MESSAGE);
let batch = match recv_fd_batch(stream, wanted) {
Ok(batch) => batch,
Err(err) => {
close_raw_fds(&out);
return Err(err);
}
};
if batch.is_empty() {
let received = out.len();
close_raw_fds(&out);
return Err(io::Error::new(
io::ErrorKind::UnexpectedEof,
format!(
"handoff stream closed after {received} of {expected} pane file descriptors"
),
));
}
out.extend(batch);
}
Ok(out)
}

#[cfg(unix)]
fn recv_fd_batch(stream: &UnixStream, wanted: usize) -> io::Result<Vec<RawFd>> {
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 fd_bytes = wanted * 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();
Expand All @@ -436,34 +472,51 @@ fn recv_fds(stream: &UnixStream, expected: usize) -> io::Result<Vec<RawFd>> {
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));
let control_end = control.as_ptr() as usize + msg.msg_controllen as usize;
let mut cmsg = libc::CMSG_FIRSTHDR(&msg);
while !cmsg.is_null() {
if (*cmsg).cmsg_level == libc::SOL_SOCKET && (*cmsg).cmsg_type == libc::SCM_RIGHTS {
let data = libc::CMSG_DATA(cmsg);
// Bound the payload by both the header's own length and the
// bytes the kernel wrote into `control`, so the read below can
// never run past the buffer.
let available = control_end.saturating_sub(data as usize);
let data_len = ((*cmsg).cmsg_len as usize)
.saturating_sub(libc::CMSG_LEN(0) as usize)
.min(available);
let count = data_len / std::mem::size_of::<RawFd>();
let data = data as *const RawFd;
for idx in 0..count {
out.push(*data.add(idx));
}
}
cmsg = libc::CMSG_NXTHDR(&msg, cmsg);
}
}
if out.len() != expected {
for fd in out {
let _ = unsafe { libc::close(fd) };
}

// Truncation means the kernel closed the descriptors that did not fit, so
// the batch is unrecoverable rather than merely short.
if msg.msg_flags & libc::MSG_CTRUNC != 0 {
close_raw_fds(&out);
return Err(io::Error::other("handoff fd control message was truncated"));
}
if read == 0 {
close_raw_fds(&out);
return Ok(Vec::new());
}
if out.len() > wanted {
let received = out.len();
close_raw_fds(&out);
return Err(io::Error::other(format!(
"expected {expected} handoff fds, received fewer"
"handoff fd message carried {received} descriptors, expected at most {wanted}"
)));
}
if out.is_empty() {
return Err(io::Error::other("handoff fd message missing SCM_RIGHTS"));
}
Ok(out)
}

Expand Down
10 changes: 0 additions & 10 deletions src/server/headless/lifecycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -53,16 +53,6 @@ impl HeadlessServer {
}
}
}
if pane_by_terminal.len() > crate::server::handoff::MAX_FDS_PER_HANDOFF {
let _ = std::fs::remove_file(&socket_path);
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!(
"live handoff supports at most {} panes in one update; close panes or restart herdr normally",
crate::server::handoff::MAX_FDS_PER_HANDOFF
),
));
}

self.handoff_in_progress = true;
self.disconnect_all_clients_for_handoff();
Expand Down
73 changes: 73 additions & 0 deletions tests/live_handoff.rs
Original file line number Diff line number Diff line change
Expand Up @@ -702,6 +702,79 @@ fn live_handoff_unknown_pane_exit_preserves_session_on_shutdown() {
cleanup_test_base(&base);
}

#[cfg(any(target_os = "linux", target_os = "macos"))]
#[test]
fn live_handoff_carries_more_panes_than_one_scm_rights_message() {
const PANES: usize = 70;

let _lock = test_lock();
let base = unique_test_dir();
let config_home = base.join("config");
let runtime_dir = base.join("runtime");
let api_socket = runtime_dir.join("herdr.sock");

let spawned = spawn_server(&config_home, &runtime_dir, &api_socket);
wait_for_socket(&api_socket, Duration::from_secs(10));
register_runtime_dir(&runtime_dir);
let server_pid = spawned
.child
.process_id()
.expect("test server should expose pid");

let created = request(
&api_socket,
serde_json::json!({
"id": "test:workspace:create",
"method": "workspace.create",
"params": {"cwd": "/tmp", "focus": true}
}),
);
let workspace_id = created["result"]["workspace"]["workspace_id"]
.as_str()
.unwrap()
.to_string();

// One pane per tab keeps the layout shallow, so this exercises the fd
// transfer rather than the depth of a single split tree.
for index in 1..PANES {
assert_ok(request(
&api_socket,
serde_json::json!({
"id": format!("test:tab:create-{index}"),
"method": "tab.create",
"params": {"workspace_id": workspace_id, "focus": false}
}),
));
}
wait_for_server_ptmx_fd_count(server_pid, PANES, Duration::from_secs(60));

assert_ok(request(
&api_socket,
serde_json::json!({"id":"test:handoff","method":"server.live_handoff","params":{}}),
));
let replacement_pid =
wait_for_replacement_server_pid(&runtime_dir, server_pid, Duration::from_secs(30));
wait_for_api(&api_socket, Duration::from_secs(30));
wait_for_server_ptmx_fd_count(replacement_pid, PANES, Duration::from_secs(30));

let panes = request(
&api_socket,
serde_json::json!({"id":"test:pane:list","method":"pane.list","params":{}}),
);
assert_eq!(
panes["result"]["panes"].as_array().map(Vec::len),
Some(PANES),
"replacement server should report every pane after handoff"
);

let _ = request(
&api_socket,
serde_json::json!({"id":"test:stop","method":"server.stop","params":{}}),
);
drop(spawned);
cleanup_test_base(&base);
}

#[test]
fn live_handoff_preserves_named_session_socket_paths() {
let _lock = test_lock();
Expand Down
Loading