//! Native Unix PTY / Windows ConPTY coverage, without daemon or client transport. #[path = "support/terminal_fixture.rs"] mod terminal_fixture; use std::thread; use std::time::{Duration, Instant}; use opencode_pty::protocol::AttachmentRole; use opencode_pty::service::{ StreamEvent, TerminalId, TerminalLifecycle, TerminalService, TerminalSnapshot, }; use terminal_fixture::{Command, Deadline, Fixture, TempDir}; fn wait_text(service: &TerminalService, id: TerminalId, expected: &str) -> TerminalSnapshot { let deadline = Instant::now() + Duration::from_secs(15); loop { let snapshot = service.snapshot(id).unwrap(); if snapshot.text.contains(expected) { return snapshot; } assert!( Instant::now() < deadline, "missing {expected:?}: {snapshot:?}" ); thread::sleep(Duration::from_millis(10)); } } #[test] fn real_child_input_output_unicode_and_snapshots() { let _deadline = Deadline::new(); let fixture = Fixture::new(); let service = TerminalService::default(); let info = service.create(fixture.request()).unwrap(); assert!(info.pid.is_some_and(|pid| pid != 0)); let mut child = fixture.connect(); child.command(Command::Output( "\x1b[2J\x1b[Hplain café 界 🙂\r\nREADY".into(), )); let snapshot = wait_text(&service, info.id, "READY"); assert!(snapshot.text.contains("plain café 界 🙂"), "{snapshot:?}"); assert!(!snapshot.checkpoint.is_empty()); let rows = service.read_rows(info.id, None).unwrap(); assert!(rows.lines.iter().any(|line| line == "plain café 界 🙂")); assert_eq!((rows.cursor_x, rows.cursor_y), (5, 1)); let input = "typed café 界 🙂\r".as_bytes(); service.write(info.id, input.to_vec()).unwrap(); assert_eq!( child.command(Command::Read(input.len())), serde_json::json!(input) ); let replay = service.replay(info.id, 0).unwrap(); assert!(!replay.truncated); // ConPTY may repaint after the earlier snapshot, so offsets only grow. assert!(replay.end_offset >= snapshot.info.output_tail); assert_eq!(replay.bytes.len() as u64, replay.end_offset); assert!( service .replay(info.id, replay.end_offset) .unwrap() .bytes .is_empty() ); service.terminate(info.id).unwrap(); } #[test] fn child_cwd_environment_and_quoted_program_path() { let _deadline = Deadline::new(); let directory = TempDir::new(); let fixture = Fixture::new(); let service = TerminalService::default(); let executable = directory.executable(if cfg!(windows) { "child 界.exe" } else { "child 界" }); let mut request = fixture.request(); request.program = executable.to_str().unwrap().to_owned(); request.cwd = directory.0.clone(); // Additional exact test filters exercise argv quoting, including an empty // argument and backslashes immediately preceding a closing quote. request .args .extend(["with spaces", "a\"quote", "trailing slash \\", "", "界"].map(str::to_owned)); request .env .insert("PTY_FIXTURE_VALUE".into(), "value café 界 = ok".into()); let expected_args = request.args.clone(); let info = service.create(request).unwrap(); let mut child = fixture.connect(); let context = child.command(Command::Context); assert_eq!( std::fs::canonicalize(context["cwd"].as_str().unwrap()).unwrap(), std::fs::canonicalize(&directory.0).unwrap() ); assert_eq!(context["args"], serde_json::json!(expected_args)); assert_eq!(context["value"], "value café 界 = ok"); assert_eq!(context["term"], "xterm-256color"); assert_eq!(context["colorterm"], "truecolor"); service.terminate(info.id).unwrap(); } #[test] fn executable_is_resolved_from_child_path() { let _deadline = Deadline::new(); let directory = TempDir::new(); let fixture = Fixture::new(); let service = TerminalService::default(); let name = if cfg!(windows) { "pty-path-fixture.exe" } else { "pty-path-fixture" }; directory.executable(name); let mut request = fixture.request(); request.program = name.into(); request .env .insert("PATH".into(), directory.0.to_str().unwrap().into()); let info = service.create(request).unwrap(); let mut child = fixture.connect(); child.command(Command::Output("\r\nPATH_OK".into())); wait_text(&service, info.id, "PATH_OK"); service.terminate(info.id).unwrap(); } #[test] fn resize_updates_real_console_and_parser() { let _deadline = Deadline::new(); let fixture = Fixture::new(); let service = TerminalService::default(); let info = service.create(fixture.request()).unwrap(); let mut child = fixture.connect(); assert_eq!(child.command(Command::Size), serde_json::json!([80, 24])); let observer = service .attach( info.id, 0, "resize-observer".into(), AttachmentRole::Observer, false, ) .unwrap(); for (cols, rows) in [(100, 40), (60, 18)] { service.resize(info.id, cols, rows).unwrap(); // ConPTY applies a resize asynchronously in conhost, so the child can // briefly observe the old size after `resize` returns. let expected = serde_json::json!([cols, rows]); let deadline = Instant::now() + Duration::from_secs(15); loop { let size = child.command(Command::Size); if size == expected { break; } assert!(Instant::now() < deadline, "console size stayed {size}"); thread::sleep(Duration::from_millis(10)); } let snapshot = service.snapshot(info.id).unwrap(); assert_eq!((snapshot.info.cols, snapshot.info.rows), (cols, rows)); assert_eq!( service.read_rows(info.id, None).unwrap().lines.len(), rows as usize ); loop { if let StreamEvent::Resized { cols: actual_cols, rows: actual_rows, checkpoint, .. } = observer .events .recv_timeout(Duration::from_secs(15)) .unwrap() { assert_eq!((actual_cols, actual_rows), (cols, rows)); assert!(!checkpoint.is_empty()); break; } } } drop(observer); service.terminate(info.id).unwrap(); } #[test] fn terminal_query_response_reaches_child() { let _deadline = Deadline::new(); let fixture = Fixture::new(); let service = TerminalService::default(); let info = service.create(fixture.request()).unwrap(); let mut child = fixture.connect(); child.command(Command::Output("\x1b[2J\x1b[H\x1b[3;7H\x1b[6n".into())); let response = b"\x1b[3;7R"; assert_eq!( child.command(Command::Read(response.len())), serde_json::json!(response) ); service.write(info.id, b"!".to_vec()).unwrap(); assert_eq!(child.command(Command::Read(1)), serde_json::json!(b"!")); // ConPTY answers application DSR itself; on Unix, Ghostty answers it. With // cursor inheritance enabled, ConPTY's own startup DSR still appears in the // output stream, so this checks the query reaches the reader and parser. // It does not prove a reply was written back on Windows. let replay = service.replay(info.id, 0).unwrap(); assert!( replay.bytes.windows(4).any(|bytes| bytes == b"\x1b[6n"), "no terminal query in {replay:?}" ); service.terminate(info.id).unwrap(); } #[test] fn independent_terminals_keep_bounded_replay() { let _deadline = Deadline::new(); let alpha_fixture = Fixture::new(); let beta_fixture = Fixture::new(); let service = TerminalService::new(64); let alpha = service.create(alpha_fixture.request()).unwrap(); let beta = service.create(beta_fixture.request()).unwrap(); assert_ne!(alpha.id, beta.id); let mut a = alpha_fixture.connect(); let mut b = beta_fixture.connect(); a.command(Command::Output(format!( "\x1b[2J\x1b[H{}\r\nALPHA", "a".repeat(160) ))); b.command(Command::Output("\x1b[2J\x1b[HBETA".into())); let snapshot = wait_text(&service, alpha.id, "ALPHA"); assert!(!snapshot.text.contains("BETA")); assert!(!wait_text(&service, beta.id, "BETA").text.contains("ALPHA")); let replay = service.replay(alpha.id, 0).unwrap(); assert!(replay.truncated); assert_eq!(replay.bytes.len(), 64); assert_eq!(replay.end_offset - replay.available_offset, 64); service.terminate(alpha.id).unwrap(); service.write(beta.id, b"still-alive".to_vec()).unwrap(); assert_eq!( b.command(Command::Read(11)), serde_json::json!(b"still-alive") ); service.terminate(beta.id).unwrap(); assert!(service.list().unwrap().is_empty()); } #[test] fn normal_exit_follows_final_output_and_preserves_exit_code() { check_normal_exit(23); } #[test] #[cfg(windows)] fn windows_exit_code_259_is_not_mistaken_for_a_running_child() { check_normal_exit(259); } fn check_normal_exit(code: u32) { let _deadline = Deadline::new(); let fixture = Fixture::new(); let service = TerminalService::default(); let info = service.create(fixture.request()).unwrap(); let mut child = fixture.connect(); let observer = service .attach( info.id, 0, "exit-observer".into(), AttachmentRole::Observer, false, ) .unwrap(); let mut bytes = observer.replay.bytes.clone(); let mut offset = observer.replay.end_offset; child.command(Command::Output("\r\nFINAL_OUTPUT_BEFORE_EXIT\r\n".into())); child.command(Command::Exit(code as i32)); loop { match observer .events .recv_timeout(Duration::from_secs(15)) .unwrap_or_else(|error| { panic!( "missing exit event: {error}; snapshot={:?}", service.snapshot(info.id) ) }) { StreamEvent::Output { start, end, bytes: output, } => { assert_eq!(start, offset); assert_eq!(end - start, output.len() as u64); bytes.extend(output); offset = end; } StreamEvent::Exited { exit_code, final_offset, } => { assert_eq!(exit_code, Some(code)); assert_eq!(final_offset, offset); break; } _ => {} } } assert_eq!(service.replay(info.id, 0).unwrap().bytes, bytes); let snapshot = service.snapshot(info.id).unwrap(); assert!( snapshot.text.contains("FINAL_OUTPUT_BEFORE_EXIT"), "{snapshot:?}" ); assert_eq!( snapshot.info.lifecycle, TerminalLifecycle::Exited { exit_code: Some(code) } ); assert_eq!(snapshot.info.output_tail, offset); assert!(observer.events.try_recv().is_err()); #[cfg(windows)] { assert!( service .resize(info.id, 100, 40) .unwrap_err() .to_string() .contains("child has exited") ); assert!(service.write(info.id, b"late input".to_vec()).is_err()); assert_eq!( service.snapshot(info.id).unwrap().info.lifecycle, snapshot.info.lifecycle ); assert!( service .read_rows(info.id, None) .unwrap() .lines .iter() .any(|line| line.contains("FINAL_OUTPUT_BEFORE_EXIT")) ); } drop(observer); service.terminate(info.id).unwrap(); } #[test] fn repeated_termination_and_drop_release_children() { termination_cycles(false); } // Linux has a pre-existing portable-pty blocking-write hang after child exit; // that repro is retained outside this Windows cleanup suite, not claimed fixed. #[test] #[cfg(windows)] fn repeated_termination_and_drop_release_nonreading_children() { termination_cycles(true); } fn termination_cycles(block_input: bool) { let _deadline = Deadline::new(); let service = TerminalService::default(); for _ in 0..3 { let fixture = Fixture::new(); let info = service.create(fixture.request()).unwrap(); let _child = fixture.connect(); let process = ChildProcess::open(info.pid.unwrap()); // Child waits on the private control channel, never draining PTY stdin. if block_input { service.write(info.id, vec![b'x'; 256 * 1024]).unwrap(); } service.terminate(info.id).unwrap(); process.assert_exited(); assert!(service.list().unwrap().is_empty()); assert!(service.terminate(info.id).is_err()); } let fixture = Fixture::new(); let info = service.create(fixture.request()).unwrap(); let _child = fixture.connect(); let process = ChildProcess::open(info.pid.unwrap()); if block_input { service.write(info.id, vec![b'x'; 256 * 1024]).unwrap(); } drop(service); process.assert_exited(); } struct ChildProcess { #[cfg(unix)] pid: u32, #[cfg(windows)] handle: std::os::windows::io::OwnedHandle, } #[test] #[cfg(windows)] fn terminating_a_shell_releases_its_console_child() { use base64::Engine; let _deadline = Deadline::new(); let fixture = Fixture::new(); let service = TerminalService::default(); let mut request = fixture.request(); request .env .insert("PTY_FIXTURE_EXE".into(), request.program); request.program = std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()) .join("System32/WindowsPowerShell/v1.0/powershell.exe") .to_str() .unwrap() .to_owned(); // EncodedCommand avoids imposing cmd.exe's distinct quoting rules on the // portable-pty CommandBuilder's normal Windows argv quoting. let script = "& $env:PTY_FIXTURE_EXE --ignored --exact terminal_fixture::child --nocapture --test-threads=1; exit $LASTEXITCODE"; let script = script .encode_utf16() .flat_map(u16::to_le_bytes) .collect::>(); request.args = vec![ "-NoLogo".into(), "-NoProfile".into(), "-NonInteractive".into(), "-EncodedCommand".into(), base64::engine::general_purpose::STANDARD.encode(script), ]; let info = service.create(request).unwrap(); let root = ChildProcess::open(info.pid.unwrap()); let mut child = fixture.connect(); let pid = u32::try_from(child.command(Command::Context)["pid"].as_u64().unwrap()).unwrap(); assert_ne!(pid, info.pid.unwrap()); let descendant = ChildProcess::open(pid); child.command(Command::Output("\r\nSHELL_DESCENDANT_READY".into())); wait_text(&service, info.id, "SHELL_DESCENDANT_READY"); service.terminate(info.id).unwrap(); root.assert_exited(); descendant.assert_exited(); } impl ChildProcess { fn open(pid: u32) -> Self { #[cfg(unix)] { Self { pid } } #[cfg(windows)] { use std::os::windows::io::FromRawHandle; use windows_sys::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE}; // SAFETY: request only wait access to this known-live test child. let handle = unsafe { OpenProcess(PROCESS_SYNCHRONIZE, 0, pid) }; assert!(!handle.is_null(), "{}", std::io::Error::last_os_error()); Self { handle: unsafe { std::os::windows::io::OwnedHandle::from_raw_handle(handle) }, } } } fn assert_exited(&self) { #[cfg(unix)] { // SAFETY: signal zero checks liveness without signalling a process. assert_eq!(unsafe { libc::kill(self.pid as i32, 0) }, -1); assert_eq!( std::io::Error::last_os_error().raw_os_error(), Some(libc::ESRCH) ); } #[cfg(windows)] { use std::os::windows::io::AsRawHandle; use windows_sys::Win32::{ Foundation::WAIT_OBJECT_0, System::Threading::WaitForSingleObject, }; // Descendants exit asynchronously once their console closes, so allow // a bounded wait. The root is already signaled when terminate returns. // SAFETY: the owned process handle has wait access and stays live. assert_eq!( unsafe { WaitForSingleObject(self.handle.as_raw_handle(), 5000) }, WAIT_OBJECT_0 ); } } } #[test] #[cfg(windows)] fn shutdown_drains_conpty_while_a_child_is_producing_output() { let _deadline = Deadline::new(); let fixture = Fixture::new(); let service = TerminalService::default(); let info = service.create(fixture.request()).unwrap(); let mut child = fixture.connect(); let process = ChildProcess::open(info.pid.unwrap()); child.command(Command::Flood); wait_text(&service, info.id, "FLOOD_OUTPUT"); drop(service); process.assert_exited(); }