aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-31 16:00:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-31 16:00:03 +0300
commitb778162a9f57b64ce9a067386b7de27d4cbcbafc (patch)
tree6bbce8729acba59aebbdaaa78eb2acb37a144b81 /tests
parent77819797385c5538f624a8881208921342911db0 (diff)
refactor: centralize pty fd ownership
refs #357 refs #327
Diffstat (limited to 'tests')
-rw-r--r--tests/live_handoff.rs172
-rw-r--r--tests/support/mod.rs15
2 files changed, 187 insertions, 0 deletions
diff --git a/tests/live_handoff.rs b/tests/live_handoff.rs
index 7a9fb4df..c56e7c3f 100644
--- a/tests/live_handoff.rs
+++ b/tests/live_handoff.rs
@@ -301,6 +301,96 @@ fn wait_for_file_contains(path: &Path, needle: &str, timeout: Duration) -> Strin
);
}
+#[cfg(target_os = "linux")]
+fn server_ptmx_fd_count(pid: u32) -> usize {
+ let Ok(entries) = fs::read_dir(format!("/proc/{pid}/fd")) else {
+ return 0;
+ };
+ entries
+ .filter_map(Result::ok)
+ .filter_map(|entry| fs::read_link(entry.path()).ok())
+ .filter(|target| target == Path::new("/dev/ptmx"))
+ .count()
+}
+
+#[cfg(target_os = "macos")]
+fn server_ptmx_fd_count(pid: u32) -> usize {
+ let Ok(output) = std::process::Command::new("lsof")
+ .args(["-nP", "-p", &pid.to_string()])
+ .output()
+ else {
+ return 0;
+ };
+ String::from_utf8_lossy(&output.stdout)
+ .lines()
+ .filter(|line| line.contains("/dev/ptmx"))
+ .count()
+}
+
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+fn wait_for_server_ptmx_fd_count(pid: u32, expected: usize, timeout: Duration) {
+ let deadline = Instant::now() + timeout;
+ let mut last_count = 0;
+ while Instant::now() < deadline {
+ last_count = server_ptmx_fd_count(pid);
+ if last_count == expected {
+ return;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!("server pid {pid} had {last_count} /dev/ptmx fds; expected {expected}");
+}
+
+#[cfg(target_os = "linux")]
+fn wait_for_replacement_server_pid(runtime_dir: &Path, old_pid: u32, timeout: Duration) -> u32 {
+ let deadline = Instant::now() + timeout;
+ let mut last_pids = Vec::new();
+ while Instant::now() < deadline {
+ last_pids = support::herdr_server_pids_for_runtime_dir(runtime_dir).unwrap_or_default();
+ if let Some(pid) = last_pids.iter().copied().find(|pid| *pid != old_pid) {
+ return pid;
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!(
+ "replacement server for {} did not appear; last pids: {:?}",
+ runtime_dir.display(),
+ last_pids
+ );
+}
+
+#[cfg(target_os = "macos")]
+fn wait_for_replacement_server_pid(_runtime_dir: &Path, old_pid: u32, timeout: Duration) -> u32 {
+ let handoff_socket_pattern = format!("herdr-handoff-{old_pid}.sock");
+ let deadline = Instant::now() + timeout;
+ let mut last_stdout = String::new();
+ while Instant::now() < deadline {
+ if let Ok(output) = std::process::Command::new("pgrep")
+ .args(["-af", &handoff_socket_pattern])
+ .output()
+ {
+ last_stdout = String::from_utf8_lossy(&output.stdout).into_owned();
+ for line in last_stdout.lines() {
+ let Some(pid_text) = line.split_whitespace().next() else {
+ continue;
+ };
+ let Ok(pid) = pid_text.parse::<u32>() else {
+ continue;
+ };
+ if pid != old_pid {
+ return pid;
+ }
+ }
+ }
+ thread::sleep(Duration::from_millis(25));
+ }
+ panic!(
+ "replacement server for {} did not appear; last pgrep output: {}",
+ _runtime_dir.display(),
+ last_stdout
+ );
+}
+
fn unused_local_port() -> u16 {
TcpListener::bind("127.0.0.1:0")
.unwrap()
@@ -330,6 +420,88 @@ fn wait_for_http_contains(port: u16, needle: &str, timeout: Duration) -> String
);
}
+#[cfg(any(target_os = "linux", target_os = "macos"))]
+#[test]
+fn live_server_holds_one_pty_master_fd_per_pane() {
+ 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");
+ wait_for_server_ptmx_fd_count(server_pid, 0, Duration::from_secs(5));
+
+ let created = request(
+ &api_socket,
+ serde_json::json!({
+ "id": "test:workspace:create",
+ "method": "workspace.create",
+ "params": {"cwd": "/tmp", "focus": true}
+ }),
+ );
+ let pane_id = created["result"]["root_pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ wait_for_server_ptmx_fd_count(server_pid, 1, Duration::from_secs(5));
+
+ let second = request(
+ &api_socket,
+ serde_json::json!({
+ "id": "test:pane:split-second",
+ "method": "pane.split",
+ "params": {
+ "target_pane_id": pane_id,
+ "direction": "right",
+ "focus": true
+ }
+ }),
+ );
+ assert_ok(second.clone());
+ let second_pane_id = second["result"]["pane"]["pane_id"]
+ .as_str()
+ .unwrap()
+ .to_string();
+ wait_for_server_ptmx_fd_count(server_pid, 2, Duration::from_secs(5));
+
+ assert_ok(request(
+ &api_socket,
+ serde_json::json!({
+ "id": "test:pane:split-third",
+ "method": "pane.split",
+ "params": {
+ "target_pane_id": second_pane_id,
+ "direction": "down",
+ "focus": true
+ }
+ }),
+ ));
+ wait_for_server_ptmx_fd_count(server_pid, 3, Duration::from_secs(5));
+
+ 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(10));
+ wait_for_api(&api_socket, Duration::from_secs(10));
+ wait_for_server_ptmx_fd_count(replacement_pid, 3, Duration::from_secs(5));
+
+ 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();
diff --git a/tests/support/mod.rs b/tests/support/mod.rs
index 0e357ff7..5359997f 100644
--- a/tests/support/mod.rs
+++ b/tests/support/mod.rs
@@ -61,6 +61,21 @@ pub fn unregister_runtime_dir(path: &Path) {
}
}
+#[cfg(target_os = "linux")]
+pub fn herdr_server_pids_for_runtime_dir(runtime_dir: &Path) -> std::io::Result<Vec<u32>> {
+ let mut pids = Vec::new();
+ for pid in iter_worktree_server_pids()? {
+ let Some(process_runtime_dir) = process_runtime_dir(pid)? else {
+ continue;
+ };
+ if process_runtime_dir == runtime_dir {
+ pids.push(pid);
+ }
+ }
+ pids.sort_unstable();
+ Ok(pids)
+}
+
pub fn cleanup_test_base(base: &Path) {
let runtime_dir = base.join("runtime");
let runtime_dirs = HashSet::from([runtime_dir.clone()]);