yog 0.0.43

yog: the standalone server for litany loops — the world, the balls and the conversations, behind one wire
Documentation
//! Spawn failure and teardown: a missing binary surfaces a spawn error,
//! and dropping a live `Stream` terminates the child — SIGTERM first,
//! escalating to SIGKILL when SIGTERM is trapped.

use super::*;
use std::thread;
use std::time::{Duration, Instant};
use tempfile::tempdir;

#[test]
fn run_errors_on_missing_binary() {
    // A failing spawn still forks before exec reports ENOENT; hold the lock
    // so that transient child can't inherit a peer's recorder write fd.
    let cli = Cli::new("/definitely/not/a/real/binary/litany-xyz");
    let Err(err) = cli.run(&[]) else {
        panic!("expected spawn failure");
    };
    let msg = err.to_string();
    assert!(msg.contains("failed to spawn"), "{msg}");
}

/// bl-6191: `std::process` reports a bad `current_dir` as ENOENT **against the
/// program path**, so the raw error reads "failed to spawn <yog binary>: No such
/// file or directory" — the operator typed a bad directory and is told their
/// binary is missing. The spawn boundary asks the cwd its own question first.
#[test]
fn a_missing_work_directory_is_named_instead_of_the_binary() {
    let dir = tempdir().unwrap();
    let bin = write_script(dir.path(), "fake_litany", "#!/bin/sh\nexit 0\n");
    let missing = dir.path().join("nonexistent-uat-dir");
    let cli = Cli::new(bin);
    let Err(err) = cli.run_in(&missing, &[]) else {
        panic!("expected spawn failure");
    };
    assert_eq!(
        err.to_string(),
        format!("work directory does not exist: {}", missing.display())
    );
}

/// The other half of the same question (bl-6191, bl-33e9): a path that is
/// plainly *there* but is not a directory. Its own sentence, because "does not
/// exist" would be a second lie about it and the fix the operator needs is a
/// different one.
#[test]
fn a_work_directory_that_is_not_a_directory_is_named_as_such() {
    let dir = tempdir().unwrap();
    let bin = write_script(dir.path(), "fake_litany", "#!/bin/sh\nexit 0\n");
    let file = dir.path().join("a-file");
    std::fs::write(&file, b"x").unwrap();
    let cli = Cli::new(bin);
    let Err(err) = cli.run_in(&file, &[]) else {
        panic!("expected spawn failure");
    };
    assert_eq!(
        err.to_string(),
        format!("work directory is not a directory: {}", file.display())
    );
}

#[test]
fn drop_terminates_long_running_child() {
    let dir = tempdir().unwrap();
    let bin = write_script(dir.path(), "fake_litany", "#!/bin/sh\nsleep 30\n");
    let cli = Cli::new(bin);
    let stream = cli.run(&[]).unwrap();
    let pid = stream.pid().unwrap();
    drop(stream);
    let deadline = Instant::now() + Duration::from_secs(2);
    while Instant::now() < deadline {
        if !process_exists(pid) {
            return;
        }
        thread::sleep(Duration::from_millis(25));
    }
    panic!("child pid {pid} still alive after drop");
}

#[test]
fn drop_escalates_to_sigkill_if_sigterm_ignored() {
    let dir = tempdir().unwrap();
    // Trap SIGTERM to ignore it, THEN emit a readiness marker. Without the
    // marker, the parent's `drop(stream)` can race the shell's startup and
    // send SIGTERM before `trap '' TERM` is installed — which quietly kills
    // the shell and leaves the SIGKILL-escalation branch in
    // `Stream::drop` uncovered. Waiting for "ready\n" on stdout pins the
    // ordering.
    let bin = write_script(
        dir.path(),
        "fake_litany",
        "#!/bin/sh\ntrap '' TERM\nprintf 'ready\\n'\nwhile :; do sleep 1; done\n",
    );
    let cli = Cli::new(bin);
    let mut stream = cli.run(&[]).unwrap();
    let pid = stream.pid().unwrap();
    let ready_deadline = Instant::now() + Duration::from_secs(3);
    loop {
        assert!(
            Instant::now() < ready_deadline,
            "child pid {pid} did not signal ready before drop"
        );
        match stream.next() {
            Some(Chunk::Stdout(b)) if b.starts_with(b"ready") => break,
            Some(_) => {}
            None => panic!("child pid {pid} exited before signaling ready"),
        }
    }
    drop(stream);
    let deadline = Instant::now() + Duration::from_secs(3);
    while Instant::now() < deadline {
        if !process_exists(pid) {
            return;
        }
        thread::sleep(Duration::from_millis(50));
    }
    panic!("child pid {pid} survived SIGKILL escalation");
}