frame-cli 0.4.0

CLI for Frame — six intention-verbs over one application: frame new scaffolds it, frame run serves it, frame dev hot-reloads it against the running node, frame test proves it (real browser included), frame check verifies it statically, frame doctor walks the prerequisites
Documentation
//! The shared boot spine: spawn the generated host as an owned
//! process-group child, learn its URL from its own boot line, confirm the
//! address accepts, and tear down via one ordered SIGTERM inside a
//! deadline.
//!
//! `frame run` and `frame dev` BOTH call this module (F-7b ruling C2):
//! how the node boots and how it stops must remain a single
//! implementation — two boot paths that must agree is divergence-by-drift.
//! Everything here is a pure move out of `run.rs`; behavior is the landed
//! `frame run` behavior.

use std::net::SocketAddr;
use std::os::unix::process::CommandExt;
use std::path::{Path, PathBuf};
use std::process::{ExitStatus, Stdio};
use std::time::Duration;

use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::{Child, ChildStdout};
use tokio::sync::oneshot;

use crate::error::CliError;

/// Bound on waiting for the booted application to announce its URL and accept
/// connections, matching the generated application's own boot-proof deadline.
pub(crate) const READY_DEADLINE: Duration = Duration::from_secs(30);

/// Bound on waiting for the application to exit after SIGTERM, matching the
/// generated application's own teardown-proof deadline.
pub(crate) const TEARDOWN_DEADLINE: Duration = Duration::from_secs(15);

/// Spawns the host binary in its own process group so terminal signals
/// reach frame alone and teardown is always the one ordered SIGTERM.
pub(crate) fn spawn_host(
    root: &Path,
    binary: &PathBuf,
    arguments: &[&str],
) -> Result<Child, CliError> {
    let mut command = std::process::Command::new(binary);
    command.args(arguments);
    command.current_dir(root);
    // Its own process group: terminal signals reach frame alone, so
    // teardown is always the one ordered SIGTERM below.
    command.process_group(0);
    tokio::process::Command::from(command)
        .stdout(Stdio::piped())
        .spawn()
        .map_err(|source| CliError::CommandSpawn {
            command: binary.display().to_string(),
            source,
        })
}

/// Forwards every line the host writes to stdout verbatim to frame's own
/// stdout, and sends the first announced page-server address (parsed from the
/// host's `serving at http://HOST:PORT` boot line) back through `url_tx`. The
/// forwarding continues for the child's whole lifetime so the host never
/// blocks on a full stdout pipe.
pub(crate) async fn forward_and_detect_url(
    stdout: ChildStdout,
    url_tx: oneshot::Sender<SocketAddr>,
) {
    let mut lines = BufReader::new(stdout).lines();
    let mut url_tx = Some(url_tx);
    loop {
        match lines.next_line().await {
            Ok(Some(line)) => {
                println!("{line}");
                if let Some(addr) = parse_serving_url(&line)
                    && let Some(sender) = url_tx.take()
                {
                    // The receiver is dropped once readiness is established;
                    // a failed send simply means the URL is already known.
                    let _ = sender.send(addr);
                }
            }
            Ok(None) => return,
            Err(error) => {
                eprintln!("frame: reading host output failed: {error}");
                return;
            }
        }
    }
}

/// Parses the page-server address out of a host boot line of the form
/// `... serving at http://127.0.0.1:4191`. Returns `None` for any other line.
pub(crate) fn parse_serving_url(line: &str) -> Option<SocketAddr> {
    let after = line.split("http://").nth(1)?;
    let token: String = after.chars().take_while(|c| !c.is_whitespace()).collect();
    token.parse().ok()
}

/// How the boot wait ended: the application announced its URL and is
/// accepting connections, or a stop signal arrived first.
pub(crate) enum BootOutcome {
    /// The host announced this address and it accepts connections.
    Ready(SocketAddr),
    /// Ctrl-C or SIGTERM arrived during boot.
    Signaled,
}

/// Waits for the host to announce its chosen URL, then confirms the address
/// accepts a connection — racing the child's own exit (a failed boot surfaces
/// as that failure, never as a timeout), the stop signals (a Ctrl-C during
/// boot still gets the ordered teardown), and a hard deadline.
pub(crate) async fn wait_until_ready(
    child: &mut Child,
    url_rx: oneshot::Receiver<SocketAddr>,
    interrupt: &mut tokio::signal::unix::Signal,
    terminate: &mut tokio::signal::unix::Signal,
) -> Result<BootOutcome, CliError> {
    let deadline = tokio::time::sleep(READY_DEADLINE);
    tokio::pin!(deadline);
    let addr = tokio::select! {
        announced = url_rx => match announced {
            Ok(addr) => addr,
            // The stdout pump ended without ever announcing a URL — the host
            // closed its output, which means it exited before boot completed.
            Err(_) => return Err(CliError::CommandFailed {
                command: "the application host".to_owned(),
                status: "exited before announcing its serving URL".to_owned(),
            }),
        },
        status = child.wait() => {
            let status = status.map_err(|source| CliError::CommandSpawn {
                command: "the application host".to_owned(),
                source,
            })?;
            return Err(CliError::CommandFailed {
                command: "the application host".to_owned(),
                status: status.to_string(),
            });
        }
        _ = interrupt.recv() => return Ok(BootOutcome::Signaled),
        _ = terminate.recv() => return Ok(BootOutcome::Signaled),
        () = &mut deadline => return Err(CliError::NeverReady {
            bind: "the page server (no URL announced)".to_owned(),
            deadline: READY_DEADLINE,
        }),
    };
    // The URL line is printed the instant the listener is bound; confirm the
    // address actually accepts before declaring the app ready, still racing a
    // late boot failure and the stop signals.
    confirm_accepting(child, addr, interrupt, terminate).await
}

/// Polls `addr` until it accepts a connection, racing the child's exit, the
/// stop signals, and the boot deadline.
///
/// This bounded startup-readiness probe is landed `frame run` behavior and
/// is NOT a change-discovery timer: it confirms one boot, under one hard
/// deadline, and never re-arms after readiness. The dev loop's Ruling B
/// tripwire scopes to the watch/debounce/swap paths, where no such wait
/// exists.
pub(crate) async fn confirm_accepting(
    child: &mut Child,
    addr: SocketAddr,
    interrupt: &mut tokio::signal::unix::Signal,
    terminate: &mut tokio::signal::unix::Signal,
) -> Result<BootOutcome, CliError> {
    let deadline = tokio::time::Instant::now() + READY_DEADLINE;
    loop {
        if let Ok(Some(status)) = child.try_wait() {
            return Err(CliError::CommandFailed {
                command: "the application host".to_owned(),
                status: status.to_string(),
            });
        }
        if tokio::net::TcpStream::connect(addr).await.is_ok() {
            return Ok(BootOutcome::Ready(addr));
        }
        if tokio::time::Instant::now() >= deadline {
            return Err(CliError::NeverReady {
                bind: addr.to_string(),
                deadline: READY_DEADLINE,
            });
        }
        tokio::select! {
            () = tokio::time::sleep(Duration::from_millis(50)) => {}
            _ = interrupt.recv() => return Ok(BootOutcome::Signaled),
            _ = terminate.recv() => return Ok(BootOutcome::Signaled),
        }
    }
}

/// Forwards one SIGTERM and requires the application's ordered shutdown to
/// finish inside the deadline; a deadline miss is killed and reported as
/// the application bug it is.
pub(crate) async fn stop_child(binary: &Path, mut child: Child) -> Result<(), CliError> {
    eprintln!("stopping…");
    if let Some(id) = child.id() {
        send_sigterm(id)?;
    }
    match tokio::time::timeout(TEARDOWN_DEADLINE, child.wait()).await {
        Ok(status) => {
            let status = status.map_err(|source| CliError::CommandSpawn {
                command: binary.display().to_string(),
                source,
            })?;
            let verdict = exit_verdict(binary, status);
            if verdict.is_ok() {
                eprintln!("stopped cleanly");
            }
            verdict
        }
        Err(_elapsed) => {
            child
                .kill()
                .await
                .map_err(|source| CliError::CommandSpawn {
                    command: binary.display().to_string(),
                    source,
                })?;
            Err(CliError::TeardownTimeout {
                deadline: TEARDOWN_DEADLINE,
            })
        }
    }
}

/// Sends SIGTERM (never SIGKILL) so the application takes its graceful,
/// ordered-teardown path.
fn send_sigterm(pid: u32) -> Result<(), CliError> {
    let status = std::process::Command::new("kill")
        .arg("-TERM")
        .arg(pid.to_string())
        .status()
        .map_err(|source| CliError::CommandSpawn {
            command: format!("kill -TERM {pid}"),
            source,
        })?;
    if status.success() {
        Ok(())
    } else {
        Err(CliError::CommandFailed {
            command: format!("kill -TERM {pid}"),
            status: status.to_string(),
        })
    }
}

/// Maps the child's exit status to the verb's own verdict.
pub(crate) fn exit_verdict(binary: &Path, status: ExitStatus) -> Result<(), CliError> {
    if status.success() {
        Ok(())
    } else {
        Err(CliError::CommandFailed {
            command: binary.display().to_string(),
            status: status.to_string(),
        })
    }
}

#[cfg(test)]
mod tests {
    use super::parse_serving_url;

    #[test]
    fn parses_the_host_boot_line() {
        assert_eq!(
            parse_serving_url("demo_app serving at http://127.0.0.1:4191")
                .map(|addr| addr.to_string()),
            Some("127.0.0.1:4191".to_owned())
        );
        assert_eq!(
            parse_serving_url("frame-host serving at http://127.0.0.1:6010")
                .map(|addr| addr.to_string()),
            Some("127.0.0.1:6010".to_owned())
        );
    }

    #[test]
    fn ignores_unrelated_lines() {
        assert!(parse_serving_url("INFO booting embedded bus component").is_none());
        assert!(parse_serving_url("no url here").is_none());
        // A non-address after the scheme is not a serving line.
        assert!(parse_serving_url("see http://example.com/docs").is_none());
    }
}