diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-31 16:00:03 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-31 16:00:03 +0300 |
| commit | b778162a9f57b64ce9a067386b7de27d4cbcbafc (patch) | |
| tree | 6bbce8729acba59aebbdaaa78eb2acb37a144b81 /tests | |
| parent | 77819797385c5538f624a8881208921342911db0 (diff) | |
refactor: centralize pty fd ownership
refs #357
refs #327
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/live_handoff.rs | 172 | ||||
| -rw-r--r-- | tests/support/mod.rs | 15 |
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()]); |
