alktty 0.5.0

Terminal session protocol: wire format, TtyBackend trait, TtyAdapter, and typed consumer client. Producer/consumer protocol crate on top of alkcall channels.
Documentation
#![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;
}