diff --git a/.github/workflows/core.yml b/.github/workflows/core.yml index 2fe071d..303a3f4 100644 --- a/.github/workflows/core.yml +++ b/.github/workflows/core.yml @@ -63,4 +63,4 @@ jobs: # Match the release workflow's macOS playground exclusion. - name: Test macOS if: runner.os == 'macOS' - run: cargo test --locked --lib --test service --test ownership --test rows --test ghostty-effects + run: cargo test --locked --lib --test service --test runtime --test ownership --test rows --test ghostty-effects diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml index 5473b6a..8860481 100644 --- a/.github/workflows/windows.yml +++ b/.github/workflows/windows.yml @@ -69,7 +69,7 @@ jobs: if (!$source.Contains($old)) { throw 'Zig alignment workaround no longer matches; re-evaluate it' } [System.IO.File]::WriteAllText($file, $source.Replace($old, '@ptrCast(@alignCast(try si.loadNtdllProc(@tagName(proc))))')) - name: Install Rust toolchain and target - run: rustup toolchain install $env:RUSTUP_TOOLCHAIN --force-non-host --profile minimal --component rustfmt --target $env:CARGO_BUILD_TARGET + run: rustup toolchain install $env:RUSTUP_TOOLCHAIN --force-non-host --profile minimal --component rustfmt --component clippy --target $env:CARGO_BUILD_TARGET - name: Verify runner and compiler architecture env: EXPECTED_ARCH: ${{ matrix.arch }} @@ -91,6 +91,8 @@ jobs: cache-on-failure: true - name: Check formatting run: cargo fmt --check + - name: Check Clippy + run: cargo clippy --locked --all-targets --all-features -- -D warnings - name: Build executable id: build run: cargo build --locked @@ -120,6 +122,10 @@ jobs: if: failure() && steps.tests.outcome == 'failure' timeout-minutes: 10 run: cargo test --locked --lib --all-features -- --test-threads=1 --nocapture + - name: Diagnose failed runtime tests + if: failure() && steps.tests.outcome == 'failure' + timeout-minutes: 3 + run: cargo test --locked --test runtime -- --test-threads=1 --nocapture - name: Smoke test executable if: ${{ !cancelled() && steps.build.outcome == 'success' }} run: | @@ -139,8 +145,9 @@ jobs: Target: $env:CARGO_BUILD_TARGET - This job builds the executable and runs all enabled tests natively, including libghostty parser and protocol tests. - The existing service, ownership, playground, and rows integration suites are Unix-only; they do not exercise Windows yet. - A passing run does not imply Windows named-pipe transport or ConPTY lifecycle support is implemented. + This job builds the executable and runs all enabled tests natively, including libghostty parser, protocol, and direct TerminalService ConPTY runtime tests. + The runtime suite spawns real Rust console children and checks input/output, Unicode, cwd/environment/argv/PATH, OS resize, snapshots, replay, terminal replies, and independent terminals. + The older service, ownership, playground, and rows integration suites remain Unix-only. + A passing run does not imply Windows named-pipe transport or complete ConPTY shutdown coverage is implemented. No packages or releases are published. "@ >> $env:GITHUB_STEP_SUMMARY diff --git a/Cargo.toml b/Cargo.toml index 0b87bd2..e6236ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,6 +35,9 @@ windows-sys = { version = "0.61.2", features = [ "Win32_System_Threading", ] } +[target.'cfg(windows)'.dev-dependencies] +windows-sys = { version = "0.61.2", features = ["Win32_System_Console"] } + [profile.release] codegen-units = 1 lto = "thin" diff --git a/README.md b/README.md index 8b2fdd9..09fe4db 100644 --- a/README.md +++ b/README.md @@ -247,10 +247,27 @@ It checks formatting, builds the executable, runs all enabled tests, and checks the build tree and runs it without Cargo's DLL search paths, verifying that libghostty is statically linked. It does not publish packages or releases. -The current service, ownership, playground, and rows integration suites are +`tests/runtime.rs` exercises `TerminalService` directly on both Unix and Windows, +using real self-spawned Rust console children. It covers input/output, Unicode, +cwd/environment/argument and executable-path handling, OS console resize, +snapshots, bounded replay, terminal replies, and independent terminals. ConPTY +can consume application terminal queries itself; the tests also check its +cursor-inheritance query reaches our Ghostty parser/writer path. + +The reusable fixture in `tests/support/terminal_fixture.rs` returns a +`CreateTerminal` request and uses a separate control/observation channel, so +daemon tests can reuse it without a shell or Rust client. Include it as +`terminal_fixture`, call `Fixture::request`, create the terminal, then call +`Fixture::connect`. `Command::Output` writes real console stdout; `Command::Read` +observes actual console stdin without echo; `Size` and `Context` inspect the +child's OS console and process context. The ignored `child` test is only its +subprocess entry point, not skipped runtime coverage. + +The older service, ownership, playground, and rows integration suites remain Unix-only. Windows library tests also exercise real named-pipe roundtrips, multiple connections, namespace ownership, cancellation, and final-frame -completion. They do not yet verify Windows daemon or ConPTY lifecycle support. +completion. These checks do not yet establish Windows daemon support or +complete ConPTY shutdown behavior. Once the workflow is on `master`, it can also be run manually against a branch: diff --git a/tests/runtime.rs b/tests/runtime.rs new file mode 100644 index 0000000..b8d3e5b --- /dev/null +++ b/tests/runtime.rs @@ -0,0 +1,233 @@ +//! 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, 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); + assert_eq!(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(); + assert_eq!( + child.command(Command::Size), + serde_json::json!([cols, rows]) + ); + 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 consumes application DSR itself, but portable-pty enables cursor + // inheritance: ConPTY's own DSR must pass through Ghostty and our writer to + // initialize the console. On Unix, the application's query passes through. + 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()); +} diff --git a/tests/support/terminal_fixture.rs b/tests/support/terminal_fixture.rs new file mode 100644 index 0000000..c5c3255 --- /dev/null +++ b/tests/support/terminal_fixture.rs @@ -0,0 +1,267 @@ +//! A real PTY child shared by direct-service and daemon tests. +//! +//! Include this module as `terminal_fixture`. `Fixture::request` launches this +//! test executable's ignored `terminal_fixture::child` test. The private TCP +//! channel controls the fixture and observes stdin without echoing it back into +//! the PTY (which could conceal lost or duplicated terminal replies). + +use std::env; +use std::io::{self, BufRead, BufReader, Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::PathBuf; +use std::sync::mpsc; +use std::thread; +use std::time::{Duration, Instant}; + +use opencode_pty::service::CreateTerminal; +use serde::{Deserialize, Serialize}; + +const ADDRESS: &str = "OPENCODE_PTY_FIXTURE_ADDRESS"; +const TIMEOUT: Duration = Duration::from_secs(15); + +#[derive(Serialize, Deserialize)] +pub enum Command { + Output(String), + Read(usize), + Size, + Context, + Exit(i32), +} + +pub struct Fixture { + listener: TcpListener, +} + +impl Fixture { + pub fn new() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + Self { listener } + } + + pub fn request(&self) -> CreateTerminal { + CreateTerminal { + program: env::current_exe().unwrap().to_str().unwrap().to_owned(), + args: [ + "--ignored", + "--exact", + "terminal_fixture::child", + "--nocapture", + "--test-threads=1", + ] + .map(str::to_owned) + .into(), + cwd: env::current_dir().unwrap(), + title: "terminal-fixture".into(), + group_id: "terminal-fixture".into(), + env: [( + ADDRESS.into(), + self.listener.local_addr().unwrap().to_string(), + )] + .into(), + cols: 80, + rows: 24, + } + } + + pub fn connect(&self) -> Connection { + let deadline = Instant::now() + TIMEOUT; + let stream = loop { + match self.listener.accept() { + Ok((stream, _)) => break stream, + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + assert!(Instant::now() < deadline, "PTY child did not connect"); + thread::sleep(Duration::from_millis(10)); + } + Err(error) => panic!("PTY child connection: {error}"), + } + }; + // BSD sockets inherit the listener's nonblocking mode on accept. + stream.set_nonblocking(false).unwrap(); + stream.set_read_timeout(Some(TIMEOUT)).unwrap(); + stream.set_write_timeout(Some(TIMEOUT)).unwrap(); + let mut connection = Connection(BufReader::new(stream)); + assert_eq!(connection.receive(), serde_json::json!("ready")); + connection + } +} + +pub struct Connection(BufReader); + +impl Connection { + pub fn command(&mut self, command: Command) -> serde_json::Value { + send(self.0.get_mut(), &command); + self.receive() + } + + fn receive(&mut self) -> serde_json::Value { + let mut line = String::new(); + assert_ne!( + self.0.read_line(&mut line).unwrap(), + 0, + "PTY child disconnected" + ); + serde_json::from_str(&line).unwrap() + } +} + +fn send(stream: &mut TcpStream, value: &impl Serialize) { + serde_json::to_writer(&mut *stream, value).unwrap(); + stream.write_all(b"\n").unwrap(); +} + +/// Fail a hung runtime test instead of leaving the native CI worker stuck in a +/// destructor. Successful tests cancel this watchdog; it is not runtime cleanup. +pub struct Deadline(mpsc::Sender<()>); + +impl Deadline { + pub fn new() -> Self { + let (send, receive) = mpsc::channel(); + thread::spawn(move || { + if receive.recv_timeout(Duration::from_secs(45)).is_err() { + eprintln!("PTY runtime test exceeded 45 seconds"); + std::process::exit(124); + } + }); + Self(send) + } +} + +impl Drop for Deadline { + fn drop(&mut self) { + let _ = self.0.send(()); + } +} + +#[test] +#[ignore = "subprocess fixture, launched with a private control channel"] +fn child() { + let address = env::var(ADDRESS).expect("fixture must be launched by a test"); + configure_console(); + let stream = TcpStream::connect(address).unwrap(); + stream.set_read_timeout(Some(TIMEOUT)).unwrap(); + stream.set_write_timeout(Some(TIMEOUT)).unwrap(); + let mut channel = BufReader::new(stream); + send(channel.get_mut(), &"ready"); + loop { + let mut line = String::new(); + if channel.read_line(&mut line).unwrap() == 0 { + std::process::exit(0); + } + let result = match serde_json::from_str(&line).unwrap() { + Command::Output(text) => { + let mut stdout = io::stdout().lock(); + stdout.write_all(text.as_bytes()).unwrap(); + stdout.flush().unwrap(); + serde_json::Value::Null + } + Command::Read(len) => { + let mut bytes = vec![0; len]; + io::stdin().read_exact(&mut bytes).unwrap(); + serde_json::json!(bytes) + } + Command::Size => serde_json::json!(console_size()), + Command::Context => serde_json::json!({ + "cwd": env::current_dir().unwrap(), + "args": env::args().skip(1).collect::>(), + "value": env::var("PTY_FIXTURE_VALUE").ok(), + "term": env::var("TERM").ok(), + "colorterm": env::var("COLORTERM").ok(), + }), + Command::Exit(code) => { + send(channel.get_mut(), &serde_json::Value::Null); + std::process::exit(code); + } + }; + send(channel.get_mut(), &result); + } +} + +#[cfg(unix)] +fn configure_console() { + use nix::sys::termios::{SetArg, cfmakeraw, tcgetattr, tcsetattr}; + let stdin = io::stdin(); + let mut attributes = tcgetattr(&stdin).unwrap(); + cfmakeraw(&mut attributes); + tcsetattr(&stdin, SetArg::TCSANOW, &attributes).unwrap(); +} + +#[cfg(unix)] +fn console_size() -> (u16, u16) { + let mut size = std::mem::MaybeUninit::::uninit(); + // SAFETY: ioctl initializes this correctly sized winsize on success. + assert_eq!( + unsafe { libc::ioctl(0, libc::TIOCGWINSZ, size.as_mut_ptr()) }, + 0 + ); + let size = unsafe { size.assume_init() }; + (size.ws_col, size.ws_row) +} + +#[cfg(windows)] +fn configure_console() { + use windows_sys::Win32::System::Console::*; + // SAFETY: these are this dedicated child's console handles. No handle is + // retained or closed; all pointers refer to initialized stack storage. + unsafe { + let input = GetStdHandle(STD_INPUT_HANDLE); + let output = GetStdHandle(STD_OUTPUT_HANDLE); + assert_ne!(SetConsoleCP(65001), 0); + assert_ne!(SetConsoleOutputCP(65001), 0); + assert_ne!(SetConsoleMode(input, ENABLE_VIRTUAL_TERMINAL_INPUT), 0); + assert_ne!( + SetConsoleMode( + output, + ENABLE_PROCESSED_OUTPUT + | ENABLE_WRAP_AT_EOL_OUTPUT + | ENABLE_VIRTUAL_TERMINAL_PROCESSING + | DISABLE_NEWLINE_AUTO_RETURN, + ), + 0 + ); + } +} + +#[cfg(windows)] +fn console_size() -> (u16, u16) { + use windows_sys::Win32::System::Console::*; + let mut info = std::mem::MaybeUninit::::uninit(); + // SAFETY: the console API initializes the complete output on success. + assert_ne!( + unsafe { GetConsoleScreenBufferInfo(GetStdHandle(STD_OUTPUT_HANDLE), info.as_mut_ptr()) }, + 0 + ); + let info = unsafe { info.assume_init() }; + ( + (info.srWindow.Right - info.srWindow.Left + 1) as u16, + (info.srWindow.Bottom - info.srWindow.Top + 1) as u16, + ) +} + +pub struct TempDir(pub PathBuf); + +impl TempDir { + pub fn new() -> Self { + let path = env::temp_dir().join(format!("pty fixture 界 {:016x}", rand::random::())); + std::fs::create_dir(&path).unwrap(); + Self(path) + } + + pub fn executable(&self, name: &str) -> PathBuf { + let path = self.0.join(name); + let executable = env::current_exe().unwrap(); + // Avoid an open executable write descriptor being inherited by another + // concurrent Unix fork and causing ETXTBSY. Windows has no fork race. + #[cfg(unix)] + std::os::unix::fs::symlink(executable, &path).unwrap(); + #[cfg(windows)] + std::fs::copy(executable, &path).unwrap(); + path + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +}