yog 0.0.1

yog: a balls-oriented session manager for lernie loops (egui frontend)
Documentation
//! The fire-and-forget detached spawn (DESIGN §8.1): a long-lived driver yog's
//! own exit can never kill. Split from [`super`] so that shape — no retained
//! handle, no wait, no signal, in contrast to the piped
//! [`run`](super::Cli::run) family whose [`Stream`](super::Stream) drop SIGTERMs
//! — keeps [`super`] under the 300-line cap.
//!
//! **stdin/stdout are null; stderr is not.** A detached child has no waitable
//! status, so if its stderr went to `/dev/null` too, a driver that dies right
//! after launch would be indistinguishable from a clean launch (§13.3 as
//! amended). The caller names a per-spawn **sink file** instead; the ops-row
//! projection folds its tail back in at read time
//! ([`opslog::detached`](crate::opslog::detached)). This crate stays generic —
//! it opens whatever path it is handed and knows nothing of the naming.

use std::fs;
use std::path::Path;
use std::process::{Command, Stdio};

use super::{Cli, CliError};

impl Cli {
    /// Spawn `<binary> <args...>` fully detached and return only the child pid.
    /// The child gets its own process group (`process_group(0)`), stdin/stdout
    /// bound to null, stderr bound to the `stderr` sink file, and NO retained
    /// handle — nothing here waits on or kills it. This launches long-lived
    /// drivers (§8.1: `lernie prompt` detached) so yog's exit can never kill a
    /// running loop; the child reparents to init when yog dies. `cwd`, when set,
    /// is the child's working directory (a bound workspace's work-worktree,
    /// §3.1). The new group (not a new session — see [`super`]'s semantic-delta
    /// note) is what keeps yog's terminal signals from reaching the child.
    pub fn spawn_detached(
        &self,
        cwd: Option<&Path>,
        stderr: &Path,
        args: &[&str],
    ) -> Result<u32, CliError> {
        use std::os::unix::process::CommandExt;
        let mut cmd = Command::new(self.binary());
        cmd.args(args)
            // The detached driver nests too (§16.6 W2): the standing world env
            // rides the long-lived `lernie prompt` so its agents' own tool
            // subprocesses inherit the nested `$XDG_STATE_HOME` (§16.4).
            .envs(self.standing_env())
            .stdin(Stdio::null())
            .stdout(Stdio::null())
            .stderr(sink(stderr))
            // Own process group (`0` = a new group led by the child):
            // escapes yog's terminal signal group with safe std, no FFI.
            .process_group(0);
        if let Some(dir) = cwd {
            cmd.current_dir(dir);
        }
        // Direct fork like `run`: callers hold `SPAWN_LOCK` across it (the
        // detach fixtures) so the fork never lands while a peer holds a
        // not-yet-closed recorder write fd (ETXTBSY, `crate::test_support`).
        let child = cmd.spawn().map_err(|e| CliError::Spawn {
            binary: self.binary().to_path_buf(),
            source: e,
        })?;
        // `child` is dropped here WITHOUT wait or kill: a bare
        // `std::process::Child` drop orphans, it does not signal. The
        // process survives us — that is the whole point.
        Ok(child.id())
    }
}

/// Open `path` (creating its parent chain) as the child's stderr, **degrading to
/// `/dev/null`** when it cannot be created: an unwritable sink loses the capture,
/// but it must never block the launch — the driver is the point, the log is the
/// diagnosis.
fn sink(path: &Path) -> Stdio {
    let reachable = path.parent().is_none_or(|p| fs::create_dir_all(p).is_ok());
    match reachable
        .then(|| fs::File::create(path))
        .and_then(Result::ok)
    {
        Some(file) => Stdio::from(file),
        None => Stdio::null(),
    }
}