Files
alktty/tests/pty.rs
T
deepseek-v4-pro 99441530ab fix: address code review #001 findings (M1, M2, L2, L4, L5, N1-N3, N5)
- M1: TtySession now handles the negotiation-rejection error frame.
  from_halves peeks the first response byte (ADR-052 §5 disambiguation)
  and returns NegotiationRejected on a 0x00-prefixed error frame;
  ChunkReader gains peek_stream_type/read_chunk_after_peek.
- M2: wait() now surfaces MalformedExitChunk instead of collapsing it
  to NoExitChunk. The exit watch channel carries a cloneable
  ExitOutcome enum; MalformedExitChunk carries a String.
- L2: add EmittingBackend + recv_stdout_and_stderr_route_backend_data
  test covering the consumer read-pump stdout/stderr routing.
- L4: MockBackend/MockControl/MockStdinSink are now #[cfg(test)]
  pub(crate), removing them from the public API.
- L5: cargo fmt (the BAST drift test was unformatted).
- N1: fix all 9 rustdoc intra-doc links.
- N2: fix stale doc paths (crates/tty/ and docs/research/).
- N3: amend AGENTS.md §14 to accurately describe the local module's
  libc::kill unsafe blocks.
- N5: consolidate nanos_seed into tests/common/mod.rs.

Verification: cargo test (84), cargo test --all-features (107),
clippy clean (native + wasm), fmt clean, doc clean, wasm check clean.
Coverage: session.rs 79.71% -> 87.43%, total 90.74% -> 91.47%.
2026-08-17 12:07:44 +00:00

261 lines
9.5 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#![cfg(feature = "local")]
//! End-to-end integration tests for PTY mode (`terminal: Some`) —
//! `LocalTtyBackend` + `TtyAdapter::drive_session` over a
//! `tokio::io::duplex` transport stand-in, running real commands.
//!
//! Covers scenarios 18 of `tasks/tty/integration-test.md`:
//! 1. Happy path (echo): stdout contains "hello", exit 0, exit chunk last
//! 2. Interactive (cat): stdin round-trips, eof → exit 0
//! 3. Resize: control chunk accepted, no error
//! 4. Signal (SIGINT, Unix): exit code signal-terminated, child reaped
//! 5. Process-group signal (Unix): bash -c "sleep 60", INT reaches sleep child
//! 6. Stdin EOF (zero-length chunk): backend stdin closes, exit chunk sent
//! 7. Cancel cleanup (ADR-056): drop duplex → child killed, no orphan
//! 8. Exit-chunk-is-last: no stdout chunk after exit chunk
mod common;
use std::sync::Arc;
use std::time::Duration;
use alktty::local::LocalTtyBackend;
use alktty::wire::{STREAM_CTRL_OUT, STREAM_STDIN};
use common::{nanos_seed, negotiate_pty_json, spawn_session};
const PTY_NEG_ECHO: &str = r#"{"carriage":"raw","backend":"local","tty":{"cols":80,"rows":24,"pixel_width":0,"pixel_height":0},"cmd":["echo","hello"]}"#;
/// 1. Happy path (echo): stdout contains "hello", exit 0, exit chunk is
/// the last chunk before stream close (ADR-055).
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_happy_path_echo() {
let backend = Arc::new(LocalTtyBackend::new());
let (mut client, server) = spawn_session("local", backend);
client.write_negotiation(PTY_NEG_ECHO).await;
let (stdout, _stderr, code) = client
.read_until_exit()
.await
.expect("expected exit chunk before stream close");
let s = String::from_utf8_lossy(&stdout);
assert!(
s.contains("hello"),
"stdout should contain 'hello'; got: {s:?}"
);
assert_eq!(code, 0, "echo should exit 0");
client.assert_no_more_chunks().await;
let _ = server.await;
}
/// 2. Interactive (cat): write stdin chunks, then send `eof` and
/// drain stdout. The adapter's drainer writes chunks to the client
/// after the exit chunk resolves, so the test sends all input first
/// (closing cat's stdin with `eof`), then reads the echoed stdout
/// and the exit chunk together. Asserts the echoed "ping" appears in
/// stdout and cat exits 0.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_interactive_cat_round_trip() {
let backend = Arc::new(LocalTtyBackend::new());
let (mut client, server) = spawn_session("local", backend);
client
.write_negotiation(negotiate_pty_json("local", &["cat"]).as_str())
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
client.write_chunk(STREAM_STDIN, b"ping\n").await;
client.write_control(br#"{"type":"eof"}"#).await;
let (stdout, _stderr, code) = client
.read_until_exit()
.await
.expect("expected exit chunk before stream close");
let s = String::from_utf8_lossy(&stdout);
assert!(
s.contains("ping"),
"stdin did not round-trip via the PTY echo; got: {s:?}"
);
assert_eq!(code, 0, "cat should exit 0 on eof");
let _ = server.await;
}
/// 3. Resize: send a `resize` control chunk mid-session; assert no
/// error (the PTY resizes). Send `eof`, await exit.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_resize_no_error() {
let backend = Arc::new(LocalTtyBackend::new());
let (mut client, server) = spawn_session("local", backend);
client
.write_negotiation(negotiate_pty_json("local", &["cat"]).as_str())
.await;
tokio::time::sleep(Duration::from_millis(150)).await;
client
.write_control(br#"{"type":"resize","cols":120,"rows":40}"#)
.await;
client.write_control(br#"{"type":"eof"}"#).await;
let (_out, _err, code) = client
.read_until_exit_timeout(Duration::from_secs(5))
.await
.expect("expected exit chunk");
assert_eq!(code, 0, "cat should exit 0 after resize + eof");
let _ = server.await;
}
/// 4. Signal (SIGINT, Unix): negotiate `sleep 60`, send `signal:"INT"`,
/// await the exit chunk. Assert exit code is signal-terminated (non-zero,
/// negative on Unix). Assert the child is reaped (no zombie).
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_signal_sigint_kills_child() {
let backend = Arc::new(LocalTtyBackend::new());
let (mut client, server) = spawn_session("local", backend);
client
.write_negotiation(negotiate_pty_json("local", &["sleep", "60"]).as_str())
.await;
tokio::time::sleep(Duration::from_millis(200)).await;
client
.write_control(br#"{"type":"signal","name":"INT"}"#)
.await;
let (_out, _err, code) = client
.read_until_exit_timeout(Duration::from_secs(5))
.await
.expect("expected exit chunk after signal");
assert_ne!(
code, 0,
"child killed by SIGINT should report non-zero exit; got {code}"
);
let _ = server.await;
}
/// 5. Process-group signal (Unix): negotiate `bash -c "sleep 60"`,
/// send `signal:"INT"`, assert the `sleep` child also receives the
/// signal (the process group is targeted — REQ-TTY-02).
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_signal_reaches_process_group_child() {
let backend = Arc::new(LocalTtyBackend::new());
let (mut client, server) = spawn_session("local", backend);
client
.write_negotiation(negotiate_pty_json("local", &["bash", "-c", "sleep 60"]).as_str())
.await;
tokio::time::sleep(Duration::from_millis(250)).await;
client
.write_control(br#"{"type":"signal","name":"INT"}"#)
.await;
let (_out, _err, code) = client
.read_until_exit_timeout(Duration::from_secs(5))
.await
.expect("expected exit chunk after group signal");
assert_ne!(
code, 0,
"process group should have been killed (REQ-TTY-02); got {code}"
);
let _ = server.await;
}
/// 6. Stdin EOF (zero-length chunk): negotiate `cat`, send a
/// zero-length stdin chunk (the sentinel), assert the backend's stdin
/// closes, stdout drains, exit chunk is sent.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_stdin_eof_zero_length_chunk() {
let backend = Arc::new(LocalTtyBackend::new());
let (mut client, server) = spawn_session("local", backend);
client
.write_negotiation(negotiate_pty_json("local", &["cat"]).as_str())
.await;
tokio::time::sleep(Duration::from_millis(150)).await;
client.write_chunk(STREAM_STDIN, b"").await;
let (_out, _err, code) = client
.read_until_exit_timeout(Duration::from_secs(5))
.await
.expect("expected exit chunk after zero-length stdin sentinel");
assert_eq!(code, 0, "cat should exit 0 on stdin EOF");
let _ = server.await;
}
/// 7. Cancel cleanup (ADR-056): negotiate `sleep 60`, drop the duplex
/// (simulating connection drop) mid-session, assert the child is
/// killed (no orphan). The child writes its pid to a temp file so we
/// can probe it after the drop.
#[cfg(unix)]
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_cancel_cleanup_kills_child_no_orphan() {
let pid_file = std::env::temp_dir().join(format!(
"alktty_pty_cancel_pid_{}_{}.txt",
std::process::id(),
nanos_seed()
));
let cmd = format!("echo $$ > '{}'; exec sleep 60", pid_file.display());
let backend = Arc::new(LocalTtyBackend::new());
let (mut client, server) = spawn_session("local", backend);
client
.write_negotiation(negotiate_pty_json("local", &["bash", "-c", cmd.as_str()]).as_str())
.await;
for _ in 0..200 {
if pid_file.exists() {
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
let pid_str = std::fs::read_to_string(&pid_file).expect("pid file written");
let pid: i32 = pid_str.trim().parse().expect("pid parses");
let _ = std::fs::remove_file(&pid_file);
tokio::time::sleep(Duration::from_millis(150)).await;
drop(client);
server.abort();
let _ = server.await;
let mut alive = true;
for _ in 0..100 {
let r = unsafe { libc::kill(pid, 0) };
if r != 0 && std::io::Error::last_os_error().raw_os_error() == Some(libc::ESRCH) {
alive = false;
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert!(!alive, "child (pid={pid}) should be killed after cancel");
}
/// 8. Exit-chunk-is-last: in the happy path, assert no stdout chunk
/// arrives after the exit chunk. Read all chunks, find the exit chunk,
/// assert it is the last chunk before stream close.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn pty_exit_chunk_is_last() {
let backend = Arc::new(LocalTtyBackend::new());
let (mut client, server) = spawn_session("local", backend);
client.write_negotiation(PTY_NEG_ECHO).await;
let mut saw_exit = false;
while let Some((st, bytes)) = client.read_chunk_timeout(Duration::from_secs(5)).await {
if saw_exit {
panic!("chunk arrived after exit: stream_type={st}, bytes={bytes:?} (ADR-055)");
}
if st == STREAM_CTRL_OUT {
let v: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
if v["type"] == "exit" {
assert_eq!(v["code"], 0);
saw_exit = true;
}
}
}
assert!(saw_exit, "did not see the exit chunk");
let _ = server.await;
}