Files
alktty/tests/pty.rs
T
glm-5.3-flash a701247ca6 chore: bump to 0.8.2, changelog for the PTY exit-signal fidelity fix
- version 0.8.1 -> 0.8.2 (patch: behavior-only bug fix in the local
  feature's PTY waiter from db0b1fc; ADR-004 decision 1's negative-code
  convention now holds on the PTY path; no API, wire-format, or
  dependency change)
- CHANGELOG entry for 0.8.2 (review #004 resolution: the waitpid reap
  mechanism, the signal round-trip notes, the tests, the docs)
- finish the em-dash sweep in prose 58da42a missed: doc comments in
  tests/ (negotiation, pipe, pty, common) and fuzz/shared
  (control_json, negotiation_frame, session_opseq); prose rewrites
  only, no code, JSON literals, or wire shapes touched
- README checked: no version pins, nothing to update

Verification: cargo test; cargo test --all-features; cargo clippy
--all-targets -D warnings (default and --all-features); cargo fmt
--check; cargo doc --no-deps; fuzz/shared corpus replay (9 seeds pass);
cargo check + clippy for wasm32-unknown-unknown -D warnings; cargo
publish --dry-run --allow-dirty (43 files, 194.4 KiB); detached
campaigns over the four targets (chunk_frame 632s, negotiation_frame
602s, control_json 610s, session_opseq 608s) all exiting 0 with
0 crash/oom/timeout findings.
2026-11-02 08:27:32 +00:00

295 lines
11 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 1–8 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;
// `cat` with no stdin to close doesn't need a readiness signal:
// eof just makes the backend's stdin close, cat exits, and the
// reader drains. No sleep needed (N4).
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;
// No readiness sleep needed (N4): the adapter's input pump
// processes chunks in order, and `PtyControl::resize` is safe to
// call whenever the control handle exists.
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 `bash -c 'echo ready > <marker>;
/// exec sleep 60'`, wait for the marker file (the child has exec'd),
/// 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 marker = std::env::temp_dir().join(format!(
"alktty_pty_sigint_ready_{}_{}.txt",
std::process::id(),
nanos_seed()
));
let cmd = format!("echo ready > '{}'; exec sleep 60", marker.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;
// Readiness signal (N4): the child exec'd `sleep` once the marker
// file exists; no fixed sleep.
assert!(
common::wait_for_file(&marker, Duration::from_secs(5)).await,
"child never became ready"
);
let _ = std::fs::remove_file(&marker);
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 'echo ready >
/// <marker>; sleep 60'` (sleep is a child of bash, bash stays in the
/// foreground waiting), wait for the marker, 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 marker = std::env::temp_dir().join(format!(
"alktty_pty_pgroup_ready_{}_{}.txt",
std::process::id(),
nanos_seed()
));
let cmd = format!("echo ready > '{}'; sleep 60", marker.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;
// Readiness signal (N4): bash has parsed the script and started
// `sleep` once the marker exists.
assert!(
common::wait_for_file(&marker, Duration::from_secs(5)).await,
"child never became ready"
);
let _ = std::fs::remove_file(&marker);
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;
// No readiness sleep needed (N4): the input pump processes the
// sentinel in order.
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);
drop(client);
server.abort();
let _ = server.await;
// Poll for the child's death (the pid file is written before exec,
// so the kill lands on the exec'd process; bounded, no fixed sleep).
let mut alive = true;
let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
while alive {
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;
}
if tokio::time::Instant::now() >= deadline {
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;
}