kernal-api 0.1.21

Async OS HAL, profiling, symbolization, and allocator instrumentation
//! Public conformance checks for the bounded streaming process-session facade.

#[cfg(unix)]
use std::time::Duration;

#[cfg(unix)]
use kernal_api::ProcessPostExitDrain;

#[cfg(target_os = "linux")]
use kernal_api::run_bounded_command;
#[cfg(target_os = "linux")]
use kernal_api::ProcessOutputCompletion;
use kernal_api::{shell_spec, ProcessPriority, ProcessSessionOptions, SpawnSpec, StreamMode};
#[cfg(unix)]
use kernal_api::{ProcessOutputChunk, ProcessOutputEvent};

fn streamed_shell(command: &str) -> SpawnSpec {
    shell_spec(command)
        .stdin(StreamMode::Piped)
        .stdout(StreamMode::Piped)
        .stderr(StreamMode::Piped)
}

#[tokio::test]
#[cfg(unix)]
async fn first_output_chunk_arrives_before_direct_child_exit() {
    let session = streamed_shell("printf first; sleep 1")
        .spawn_session(ProcessSessionOptions {
            max_queued_chunks: 2,
            max_chunk_bytes: 64,
            post_exit_drain: ProcessPostExitDrain::AbandonAfter(Duration::from_millis(50)),
            kill_on_drop: true,
        })
        .await
        .expect("start session");

    let first =
        kernal_api::async_engine::timeout(Duration::from_millis(300), session.next_output())
            .await
            .expect("the first chunk must not wait for child exit");
    assert!(matches!(
        first,
        Some(ProcessOutputEvent::Chunk(ProcessOutputChunk::Stdout(bytes))) if bytes == b"first"
    ));
    assert!(session.poll().await.expect("poll child").is_none());
    assert_eq!(
        session.wait().await.expect("wait child").exit_code(),
        Some(0)
    );
}

#[tokio::test]
#[cfg(target_os = "linux")]
async fn direct_exit_is_observable_while_output_waits_on_a_descendant_pipe() {
    let session = streamed_shell("(sleep 0.5) &")
        .spawn_session(ProcessSessionOptions {
            max_queued_chunks: 2,
            max_chunk_bytes: 64,
            post_exit_drain: ProcessPostExitDrain::AbandonAfter(Duration::from_millis(75)),
            kill_on_drop: true,
        })
        .await
        .expect("start session");

    let output = session.next_output();
    tokio::pin!(output);
    assert!(
        kernal_api::async_engine::timeout(Duration::from_millis(20), &mut output)
            .await
            .is_err(),
        "output must remain pending while the descendant retains both pipes"
    );

    let exit = kernal_api::async_engine::timeout(Duration::from_secs(1), session.wait())
        .await
        .expect("direct child exit must not wait for inherited pipe EOF")
        .expect("wait child");
    assert_eq!(exit.exit_code(), Some(0));
    let completion = kernal_api::async_engine::timeout(Duration::from_secs(1), &mut output)
        .await
        .expect("the held pipe must complete by explicit grace abandonment")
        .expect("an output completion event");
    assert!(matches!(
        completion,
        ProcessOutputEvent::Completion(
            ProcessOutputCompletion::StdoutAbandoned | ProcessOutputCompletion::StderrAbandoned
        )
    ));
}

#[tokio::test]
#[cfg(target_os = "linux")]
async fn wait_for_eof_preserves_the_explicit_unbounded_post_exit_contract() {
    let session = streamed_shell("(sleep 0.25) &")
        .spawn_session(ProcessSessionOptions {
            max_queued_chunks: 2,
            max_chunk_bytes: 64,
            post_exit_drain: ProcessPostExitDrain::WaitForEof,
            kill_on_drop: true,
        })
        .await
        .expect("start session");

    assert_eq!(
        session.wait().await.expect("wait direct child").exit_code(),
        Some(0)
    );
    assert!(
        kernal_api::async_engine::timeout(Duration::from_millis(20), session.next_output())
            .await
            .is_err(),
        "WaitForEof must not turn a descendant-held pipe into an immediate abandonment"
    );
    let completion =
        kernal_api::async_engine::timeout(Duration::from_secs(1), session.next_output())
            .await
            .expect("the inherited pipe eventually reaches EOF")
            .expect("stream completion");
    assert!(matches!(
        completion,
        ProcessOutputEvent::Completion(
            ProcessOutputCompletion::StdoutEof | ProcessOutputCompletion::StderrEof
        )
    ));
}

#[tokio::test]
#[cfg(unix)]
async fn a_full_output_queue_applies_backpressure_without_losing_chunks() {
    let session = streamed_shell("printf 12345678")
        .spawn_session(ProcessSessionOptions {
            max_queued_chunks: 1,
            max_chunk_bytes: 1,
            post_exit_drain: ProcessPostExitDrain::AbandonAfter(Duration::from_millis(50)),
            kill_on_drop: true,
        })
        .await
        .expect("start session");

    let mut stdout = Vec::new();
    while let Some(event) = session.next_output().await {
        if let ProcessOutputEvent::Chunk(ProcessOutputChunk::Stdout(bytes)) = event {
            stdout.extend(bytes);
        }
    }
    assert_eq!(
        session.wait().await.expect("wait child").exit_code(),
        Some(0)
    );
    assert_eq!(stdout, b"12345678");
}

#[tokio::test]
#[cfg(target_os = "linux")]
async fn kill_on_drop_terminates_and_reaps_the_direct_child() {
    let session = streamed_shell("sleep 30")
        .spawn_session(ProcessSessionOptions::default())
        .await
        .expect("start session");
    let pid = session.id();
    drop(session);
    assert_pid_gone(pid).await;
}

#[tokio::test]
#[cfg(target_os = "linux")]
async fn detach_on_drop_keeps_draining_then_reaps_the_direct_child() {
    let session = streamed_shell("printf drained; sleep 0.1")
        .spawn_session(ProcessSessionOptions {
            kill_on_drop: false,
            ..ProcessSessionOptions::default()
        })
        .await
        .expect("start session");
    let pid = session.id();
    drop(session);
    assert_pid_gone(pid).await;
}

#[tokio::test]
#[cfg(unix)]
async fn blocked_stdin_never_blocks_direct_kill_or_wait() {
    let session = streamed_shell("sleep 30")
        .spawn_session(ProcessSessionOptions {
            max_queued_chunks: 1,
            max_chunk_bytes: 1024 * 1024,
            ..ProcessSessionOptions::default()
        })
        .await
        .expect("start session");
    let input = vec![b'x'; 1024 * 1024];
    let write = session.write_stdin(&input);
    tokio::pin!(write);
    assert!(
        kernal_api::async_engine::timeout(Duration::from_millis(20), &mut write)
            .await
            .is_err(),
        "a megabyte write to a child that never reads stdin must be pending"
    );
    let (write, kill) = tokio::join!(&mut write, session.kill());
    assert!(
        kill.is_ok(),
        "kill must not wait for a child that ignores stdin: {kill:?}"
    );
    let _ = write;
    assert!(
        kernal_api::async_engine::timeout(Duration::from_secs(1), session.wait())
            .await
            .expect("wait must remain live after a blocked stdin write")
            .is_ok()
    );
}

#[tokio::test]
async fn priority_is_semantic() {
    let session = streamed_shell("exit 0")
        .priority(ProcessPriority::Low)
        .spawn_session(ProcessSessionOptions::default())
        .await
        .expect("start low-priority session");
    let exit = session.wait().await.expect("wait child");
    assert_eq!(exit.exit_code(), Some(0));
}

#[test]
#[cfg(target_os = "linux")]
fn bounded_priority_is_forwarded_to_the_native_launch_policy() {
    let output = run_bounded_command(
        shell_spec("ps -o ni= -p $$").priority(ProcessPriority::Low),
        Duration::from_secs(2),
        1024,
    )
    .expect("run bounded background process");
    assert_eq!(output.exit.raw_code(), 0);
    assert_eq!(
        String::from_utf8(output.stdout)
            .expect("ps output is UTF-8")
            .trim(),
        "10"
    );
}

#[test]
#[cfg(target_os = "linux")]
fn idle_priority_retains_a_distinct_native_band() {
    let output = run_bounded_command(
        shell_spec("ps -o ni= -p $$").priority(ProcessPriority::Idle),
        Duration::from_secs(2),
        1024,
    )
    .expect("run bounded idle process");
    assert_eq!(output.exit.raw_code(), 0);
    assert_eq!(
        String::from_utf8(output.stdout)
            .expect("ps output is UTF-8")
            .trim(),
        "19"
    );
}

#[tokio::test]
#[cfg(not(windows))]
async fn cpu_time_unavailable_is_none() {
    let session = streamed_shell("exit 0")
        .spawn_session(ProcessSessionOptions::default())
        .await
        .expect("start session");
    assert_eq!(
        session.wait().await.expect("wait child").exit_code(),
        Some(0)
    );
    assert_eq!(session.cpu_time().await.expect("sample cpu"), None);
}

#[tokio::test]
#[cfg(unix)]
async fn facade_exit_preserves_signal_and_native_status() {
    let session = streamed_shell("kill -TERM $$")
        .spawn_session(ProcessSessionOptions::default())
        .await
        .expect("start session");
    let exit = session.wait().await.expect("wait child");
    assert_eq!(exit.exit_code(), None);
    assert_eq!(exit.signal(), Some(15));
    assert_ne!(exit.native_status(), 0);
}

#[tokio::test]
#[cfg(windows)]
async fn facade_exit_preserves_windows_raw_status_bits() {
    let session = streamed_shell("exit /b -1073741819")
        .spawn_session(ProcessSessionOptions::default())
        .await
        .expect("start session");
    let exit = session.wait().await.expect("wait child");
    assert_eq!(exit.exit_code(), Some(0xC000_0005_u32 as i32));
    assert_eq!(exit.signal(), None);
    assert_eq!(exit.native_status(), 0xC000_0005);
}

#[cfg(target_os = "linux")]
async fn assert_pid_gone(pid: u32) {
    let deadline = tokio::time::Instant::now() + Duration::from_secs(2);
    loop {
        if !std::path::Path::new(&format!("/proc/{pid}")).exists() {
            return;
        }
        assert!(
            tokio::time::Instant::now() < deadline,
            "child {pid} survived session cleanup"
        );
        tokio::time::sleep(Duration::from_millis(20)).await;
    }
}