//! Integration tests for detach/reattach flow. //! #![cfg(unix)] pub mod support; use std::fs; use std::io::{BufRead, BufReader, Write}; use std::os::unix::net::UnixStream; use std::path::PathBuf; use std::sync::{Mutex, MutexGuard, OnceLock}; use std::thread; use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; use portable_pty::{native_pty_system, Child, CommandBuilder, MasterPty, PtySize}; use serde_json::Value; use support::{ cleanup_test_base, client_shell_handshake, drain_messages, register_runtime_dir, register_spawned_herdr_pid, send_detach, unregister_spawned_herdr_pid, wait_for_disconnect, wait_for_socket, wait_until, CURRENT_ENDPOINT_PROTOCOL_GENERATION as CURRENT_PROTOCOL, }; const CUSTOM_HEADLESS_SIZE_CONFIG: &str = r#"onboarding = false [server] headless_cols = 132 headless_rows = 41 [ui] sidebar_start_collapsed = true sidebar_collapsed_mode = "hidden" hide_tab_bar_when_single_tab = true pane_scrollbars = false "#; fn unique_test_dir() -> PathBuf { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); PathBuf::from(format!( "/tmp/herdr-detach-test-{}-{nanos}", std::process::id() )) } struct SpawnedHerdr { _master: Box, child: Box, } impl Drop for SpawnedHerdr { fn drop(&mut self) { let pid = self.child.process_id(); let _ = self.child.kill(); if let Some(pid) = pid { let deadline = Instant::now() + Duration::from_secs(2); while Instant::now() < deadline { let mut status = 0; let result = unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }; if result == pid as libc::pid_t || result == -1 { break; } thread::sleep(Duration::from_millis(20)); } unregister_spawned_herdr_pid(Some(pid)); } } } fn cleanup_spawned_herdr(spawned: SpawnedHerdr, base: PathBuf) { drop(spawned); cleanup_test_base(&base); } fn test_lock() -> MutexGuard<'static, ()> { static LOCK: OnceLock> = OnceLock::new(); LOCK.get_or_init(|| Mutex::new(())) .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) } fn spawn_server( config_home: &PathBuf, runtime_dir: &PathBuf, api_socket_path: &PathBuf, client_socket_path: &PathBuf, ) -> SpawnedHerdr { spawn_server_with_config( config_home, runtime_dir, api_socket_path, client_socket_path, "onboarding = false\n", ) } fn spawn_server_with_config( config_home: &PathBuf, runtime_dir: &PathBuf, api_socket_path: &PathBuf, _client_socket_path: &PathBuf, config: &str, ) -> SpawnedHerdr { fs::create_dir_all(config_home.join("herdr")).unwrap(); fs::create_dir_all(runtime_dir).unwrap(); register_runtime_dir(runtime_dir); fs::write(config_home.join("herdr/config.toml"), config).unwrap(); let pair = native_pty_system() .openpty(PtySize { rows: 24, cols: 80, pixel_width: 0, pixel_height: 0, }) .unwrap(); let mut cmd = CommandBuilder::new(env!("CARGO_BIN_EXE_herdr")); cmd.arg("server"); cmd.env("XDG_CONFIG_HOME", config_home); cmd.env("XDG_RUNTIME_DIR", runtime_dir); cmd.env("HERDR_SOCKET_PATH", api_socket_path); cmd.env_remove("HERDR_CLIENT_SOCKET_PATH"); cmd.env("HERDR_CONFIG_PATH", config_home.join("herdr/config.toml")); cmd.env("SHELL", "/bin/sh"); cmd.env_remove("HERDR_ENV"); let child = pair.slave.spawn_command(cmd).unwrap(); register_spawned_herdr_pid(child.process_id()); drop(pair.slave); SpawnedHerdr { _master: pair.master, child, } } fn ping_socket(socket_path: &PathBuf) -> String { let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); let request = r#"{"id":"1","method":"ping","params":{}}"#; writeln!(stream, "{}", request).unwrap(); let mut reader = BufReader::new(stream); let mut response = String::new(); reader.read_line(&mut response).unwrap(); response.trim().to_string() } fn send_json_request(socket_path: &PathBuf, request: &str) -> Value { let mut stream = UnixStream::connect(socket_path).expect("should connect to API socket"); writeln!(stream, "{}", request).unwrap(); let mut reader = BufReader::new(stream); let mut response = String::new(); reader.read_line(&mut response).unwrap(); serde_json::from_str(&response).expect("response should be valid JSON") } fn workspace_create(socket_path: &PathBuf, label: &str) -> Value { send_json_request( socket_path, &format!( r#"{{"id":"workspace_create","method":"workspace.create","params":{{"label":"{label}"}}}}"# ), ) } fn workspace_list(socket_path: &PathBuf) -> Value { send_json_request( socket_path, r#"{"id":"workspace_list","method":"workspace.list","params":{}}"#, ) } fn pane_list(socket_path: &PathBuf, workspace_id: &str) -> Value { send_json_request( socket_path, &format!( r#"{{"id":"pane_list","method":"pane.list","params":{{"workspace_id":"{workspace_id}"}}}}"# ), ) } fn pane_read_recent(socket_path: &PathBuf, pane_id: &str) -> Value { send_json_request( socket_path, &format!( r#"{{"id":"pane_read","method":"pane.read","params":{{"pane_id":"{pane_id}","source":"recent"}}}}"# ), ) } fn pane_read_recent_text(socket_path: &PathBuf, pane_id: &str) -> String { pane_read_recent(socket_path, pane_id)["result"]["read"]["text"] .as_str() .unwrap_or_default() .to_string() } fn pane_send_text(socket_path: &PathBuf, pane_id: &str, text: &str) -> Value { send_json_request( socket_path, &format!( r#"{{"id":"pane_send_text","method":"pane.send_text","params":{{"pane_id":"{pane_id}","text":{}}}}}"#, serde_json::to_string(text).unwrap() ), ) } fn parse_size_after_marker(text: &str, marker: &str) -> Option<(u16, u16)> { let lines: Vec<&str> = text.lines().collect(); for (idx, line) in lines.iter().enumerate() { if !line.contains(marker) { continue; } for candidate in lines.iter().skip(idx + 1).take(6) { let mut parts = candidate.split_whitespace(); let Some(rows) = parts.next().and_then(|part| part.parse::().ok()) else { continue; }; let Some(cols) = parts.next().and_then(|part| part.parse::().ok()) else { continue; }; if parts.next().is_none() { return Some((rows, cols)); } } } None } fn read_pane_tty_size_after_marker( socket_path: &PathBuf, pane_id: &str, marker: &str, timeout: Duration, ) -> (u16, u16) { pane_send_text(socket_path, pane_id, &format!("echo {marker}; stty size\n")); let deadline = Instant::now() + timeout; let mut last_text = String::new(); while Instant::now() < deadline { last_text = pane_read_recent_text(socket_path, pane_id); if let Some(size) = parse_size_after_marker(&last_text, marker) { return size; } thread::sleep(Duration::from_millis(50)); } panic!("did not observe tty size after marker {marker}. pane output:\n{last_text}"); } fn workspace_id_by_label(response: &Value, label: &str) -> String { response["result"]["workspaces"] .as_array() .expect("workspace.list should return an array") .iter() .find(|workspace| workspace["label"] == label) .and_then(|workspace| workspace["workspace_id"].as_str()) .expect("workspace with expected label should exist") .to_string() } fn first_pane_id(response: &Value) -> String { response["result"]["panes"] .as_array() .expect("pane.list should return an array") .first() .and_then(|pane| pane["pane_id"].as_str()) .expect("pane.list should contain at least one pane") .to_string() } // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- #[test] fn explicit_detach_message_causes_clean_disconnect() { // Client sends ClientMessage::Detach // directly (not via keybind), server handles it gracefully. // This is the flow when the client process is exiting cleanly (Ctrl+C). 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 client_socket = runtime_dir.join("herdr-client.sock"); let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frames. drain_messages(&mut stream); // Send ClientMessage::Detach directly. send_detach(&mut stream).expect("send detach message"); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { ping_socket(&api_socket).contains("pong") }), "server should persist after client Detach message" ); // Verify server is still alive. let response = ping_socket(&api_socket); assert!( response.contains("pong"), "server should persist after client Detach message: {response}" ); // The client connection should eventually be closed. // After sending Detach, the server removes the client. // We may still receive a few queued frames before the connection closes. let got_eof = wait_for_disconnect(&mut stream, Duration::from_secs(2)).expect("wait for disconnect"); assert!( got_eof, "client connection should be closed after explicit Detach message" ); cleanup_spawned_herdr(spawned, base); } #[test] fn reattach_after_detach_shows_current_state() { // Flow: // 1. Start server // 2. Connect client A, create a workspace via API // 3. Client A detaches // 4. Connect client B (reattach), verify it receives a frame // 5. Verify client B can see the workspace created by client A 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 client_socket = runtime_dir.join("herdr-client.sock"); let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); // --- Client A --- let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frames. drain_messages(&mut stream_a); // Create a workspace via API while client A is attached. let mut ws_stream = UnixStream::connect(&api_socket).expect("connect to API"); let request = r#"{"id":"1","method":"workspace.create","params":{"label":"reattach-test"}}"#; writeln!(ws_stream, "{}", request).unwrap(); let mut reader = BufReader::new(ws_stream); let mut ws_response = String::new(); reader.read_line(&mut ws_response).unwrap(); assert!( ws_response.contains("workspace_created") || ws_response.contains("ok"), "workspace creation should succeed: {ws_response}" ); // Client A detaches (send ClientMessage::Detach). send_detach(&mut stream_a).expect("send detach"); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { ping_socket(&api_socket).contains("pong") }), "server should persist after detach" ); // Verify server is still alive. let response = ping_socket(&api_socket); assert!( response.contains("pong"), "server should persist after detach: {response}" ); // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!( error.is_none(), "reattach handshake should succeed: {:?}", error ); // Client B receives the client-owned shell projection. // including the workspace created while client A was attached. support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) .expect("client shell bootstrap"); // Verify the workspace still exists via API. let mut list_stream = UnixStream::connect(&api_socket).expect("connect to API"); let list_request = r#"{"id":"2","method":"workspace.list","params":{}}"#; writeln!(list_stream, "{}", list_request).unwrap(); let mut list_reader = BufReader::new(list_stream); let mut list_response = String::new(); list_reader.read_line(&mut list_response).unwrap(); assert!( list_response.contains("reattach-test"), "workspace should still exist after detach/reattach: {list_response}" ); cleanup_spawned_herdr(spawned, base); } #[test] fn processes_survive_during_and_after_detach() { // PTY processes continue running during and after detach. // // Simplified flow: // 1. Start server // 2. Connect client, send "echo SURVIVED" to the pane // 3. Detach client // 4. Verify server is still alive and API works // 5. Reattach and verify we can receive a frame 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 client_socket = runtime_dir.join("herdr-client.sock"); let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); // Verify server starts with a workspace (session restore or fresh state). let response = ping_socket(&api_socket); assert!( response.contains("pong"), "server should respond to ping: {response}" ); // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frames. drain_messages(&mut stream); // Drive the pane through the JSON API; the shell transport carries only // client-composed UI state, not legacy raw shell input. let created = workspace_create(&api_socket, "process-survival"); let pane_id = created["result"]["root_pane"]["pane_id"] .as_str() .expect("root pane id") .to_string(); pane_send_text(&api_socket, &pane_id, "echo SURVIVED_DETACH\n"); assert!(wait_until( Duration::from_secs(5), Duration::from_millis(50), || pane_read_recent_text(&api_socket, &pane_id).contains("SURVIVED_DETACH") )); // Drain any shell projection updates. drain_messages(&mut stream); // Detach the client via explicit Detach message. send_detach(&mut stream).expect("send detach"); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { ping_socket(&api_socket).contains("pong") }), "server should persist after detach" ); // Verify server is still alive after detach. let response = ping_socket(&api_socket); assert!( response.contains("pong"), "server should persist after detach: {response}" ); assert!( wait_for_disconnect(&mut stream, Duration::from_secs(2)).expect("wait for detach"), "detached client connection should close" ); // Reattach — verify we can connect and receive a frame. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Verify the reattached client receives a frame. support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) .expect("client shell bootstrap"); cleanup_spawned_herdr(spawned, base); } #[test] fn server_persists_after_client_connection_drop() { // Server continues running after a client // disconnects (not just detach — also connection drop). // Verify that after a client connection is abruptly closed (not via Detach), // the server continues running and can accept new connections. 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 client_socket = runtime_dir.join("herdr-client.sock"); let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); // Connect and handshake. let mut stream = UnixStream::connect(&client_socket).expect("should connect"); let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Drain initial frames. drain_messages(&mut stream); // Drop the connection abruptly (simulating client crash). drop(stream); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { ping_socket(&api_socket).contains("pong") }), "server should persist after client connection drop" ); // Verify server is still alive. let response = ping_socket(&api_socket); assert!( response.contains("pong"), "server should persist after client connection drop: {response}" ); // Reattach — verify we can connect and handshake again. let mut stream_b = UnixStream::connect(&client_socket).expect("should reattach"); let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "reattach should succeed: {:?}", error); cleanup_spawned_herdr(spawned, base); } #[test] fn pane_created_without_client_uses_configured_headless_size() { 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 client_socket = runtime_dir.join("herdr-client.sock"); let spawned = spawn_server_with_config( &config_home, &runtime_dir, &api_socket, &client_socket, CUSTOM_HEADLESS_SIZE_CONFIG, ); wait_for_socket(&api_socket, Duration::from_secs(10)); let create = workspace_create(&api_socket, "headless-size"); let pane_id = create["result"]["root_pane"]["pane_id"] .as_str() .expect("root pane id") .to_string(); // A second request cannot run until the full render triggered by workspace // creation has applied the virtual headless geometry. assert!(ping_socket(&api_socket).contains("pong")); let size = read_pane_tty_size_after_marker( &api_socket, &pane_id, "HEADLESS_SIZE", Duration::from_secs(5), ); assert_eq!(size, (41, 132)); cleanup_spawned_herdr(spawned, base); } #[test] fn pane_created_after_detach_uses_configured_headless_size() { 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 client_socket = runtime_dir.join("herdr-client.sock"); let spawned = spawn_server_with_config( &config_home, &runtime_dir, &api_socket, &client_socket, CUSTOM_HEADLESS_SIZE_CONFIG, ); wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 160, 50) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{error:?}"); support::wait_for_client_shell_bootstrap(&mut stream, Duration::from_secs(5)) .expect("client shell bootstrap"); let first = workspace_create(&api_socket, "attached-size"); let first_pane_id = first["result"]["root_pane"]["pane_id"] .as_str() .expect("first root pane id") .to_string(); let attached_size = read_pane_tty_size_after_marker( &api_socket, &first_pane_id, "ATTACHED_SIZE", Duration::from_secs(5), ); assert_eq!(attached_size, (50, 160)); send_detach(&mut stream).expect("send detach"); assert!( wait_for_disconnect(&mut stream, Duration::from_secs(2)).expect("wait for detach"), "detached client connection should close" ); drop(stream); let second = workspace_create(&api_socket, "headless-size"); let second_pane_id = second["result"]["root_pane"]["pane_id"] .as_str() .expect("second root pane id") .to_string(); let headless_size = read_pane_tty_size_after_marker( &api_socket, &second_pane_id, "HEADLESS_SIZE_AFTER_DETACH", Duration::from_secs(5), ); let preserved_size = read_pane_tty_size_after_marker( &api_socket, &first_pane_id, "PRESERVED_SIZE_AFTER_DETACH", Duration::from_secs(5), ); assert_eq!(headless_size, (41, 132)); assert_eq!(preserved_size, attached_size); cleanup_spawned_herdr(spawned, base); } #[test] fn detached_output_preserves_last_attached_pty_size() { 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 client_socket = runtime_dir.join("herdr-client.sock"); let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); let mut stream = UnixStream::connect(&client_socket).expect("client should connect"); let (version, error) = client_shell_handshake(&mut stream, CURRENT_PROTOCOL, 120, 40) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); drain_messages(&mut stream); let create = workspace_create(&api_socket, "detached-size"); let pane_id = create["result"]["root_pane"]["pane_id"] .as_str() .expect("root pane id") .to_string(); let before = read_pane_tty_size_after_marker( &api_socket, &pane_id, "SIZE_BEFORE_DETACH", Duration::from_secs(5), ); send_detach(&mut stream).expect("send detach"); drop(stream); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { ping_socket(&api_socket).contains("pong") }), "server should persist after detach" ); let while_detached = read_pane_tty_size_after_marker( &api_socket, &pane_id, "SIZE_WHILE_DETACHED", Duration::from_secs(5), ); assert_eq!( while_detached, before, "detached renders should not resize live pane PTYs to a fallback size" ); cleanup_spawned_herdr(spawned, base); } #[test] fn output_accumulated_while_detached_visible_on_reattach() { // Output produced while detached is visible in the // reattached client's scrollback. // // Simplified flow: // 1. Start server, attach client A // 2. Detach client A (without sending any special input) // 3. Use API to send text to a pane while detached // 4. Reattach as client B // 5. Verify client B receives a frame // 6. Verify the pane content via API includes the text sent while detached 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 client_socket = runtime_dir.join("herdr-client.sock"); let spawned = spawn_server(&config_home, &runtime_dir, &api_socket, &client_socket); wait_for_socket(&api_socket, Duration::from_secs(10)); wait_for_socket(&client_socket, Duration::from_secs(10)); // Connect and handshake client A. let mut stream_a = UnixStream::connect(&client_socket).expect("client A should connect"); let (version, error) = client_shell_handshake(&mut stream_a, CURRENT_PROTOCOL, 80, 24) .expect("handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Detach client A immediately. send_detach(&mut stream_a).expect("send detach"); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { ping_socket(&api_socket).contains("pong") }), "server should persist" ); // Verify server alive. let response = ping_socket(&api_socket); assert!( response.contains("pong"), "server should persist: {response}" ); // Use API to send text to a pane while no client is attached. // First create a workspace and find its pane. let ws_create_response = workspace_create(&api_socket, "scrollback-test"); assert_eq!(ws_create_response["result"]["type"], "workspace_created"); // Find the workspace and pane IDs. let ws_response = workspace_list(&api_socket); let ws_id = workspace_id_by_label(&ws_response, "scrollback-test"); // Get pane list for this workspace. let pane_response = pane_list(&api_socket, &ws_id); let pane_id = first_pane_id(&pane_response); // Send text to the pane via API while detached. let mut send_stream = UnixStream::connect(&api_socket).expect("connect to API"); let send_request = format!( r#"{{"id":"4","method":"pane.send_text","params":{{"pane_id":"{pane_id}","text":"echo DURING_DETACH\n"}}}}"# ); writeln!(send_stream, "{}", send_request).unwrap(); let mut send_reader = BufReader::new(send_stream); let mut send_response = String::new(); send_reader.read_line(&mut send_response).unwrap(); assert!( wait_until(Duration::from_secs(2), Duration::from_millis(25), || { let read_response = pane_read_recent(&api_socket, &pane_id); read_response["result"]["read"]["text"] .as_str() .unwrap_or_default() .contains("DURING_DETACH") }), "pane should contain output produced while detached before reattach" ); // --- Client B (reattach) --- let mut stream_b = UnixStream::connect(&client_socket).expect("client B should connect"); let (version, error) = client_shell_handshake(&mut stream_b, CURRENT_PROTOCOL, 80, 24) .expect("reattach handshake should succeed"); assert_eq!(version, CURRENT_PROTOCOL); assert!(error.is_none(), "{:?}", error); // Client B should receive a frame with the current state. support::wait_for_client_shell_bootstrap(&mut stream_b, Duration::from_secs(5)) .expect("client shell bootstrap"); cleanup_spawned_herdr(spawned, base); }