waos-cli 0.1.0

cargo waos: writes a new program, builds a program as a component, runs it in the WaOS on this machine, and packages it as a signed .wapk.
//! A run: the program sent to the WaOS on this machine over `waos-dev-protocol`'s connection, which is the
//! run (the design's decision 29), as the program its id names, which runs with that program's
//! settings and folders in WaOS. What the program writes comes to this process's stdout and
//! stderr as it writes it; how it ended is said and becomes the exit status; Ctrl-C closes the
//! connection, which stops the program.
//!
//! The invariant: nothing here runs a program. It runs in the WaOS app, and this process holds
//! only the connection, so closing it is the one way to stop the run.

use std::io::{self, ErrorKind, Write};
use std::process::ExitCode;
use std::time::{Duration, Instant};

use anyhow::{Context, Result, bail};
use tokio::net::UnixStream;
use waos_dev_protocol::{Connection, FromWaOS, OutputStream, RunEnd, ToWaOS};
use waos_package::ProgramId;

use crate::build::BuiltProgram;
use crate::waos_app;

/// How long a WaOS just started may take to answer, and how often it is tried.
const START_WAIT: Duration = Duration::from_secs(10);
const RETRY_EVERY: Duration = Duration::from_millis(200);

/// The exit status of a run Ctrl-C stopped: the shell's for a command the interrupt ended.
const INTERRUPTED: u8 = 130;

/// Runs `program` in WaOS, as the program `id` names, until it ends, or until Ctrl-C.
pub async fn run(id: ProgramId, program: &BuiltProgram) -> Result<ExitCode> {
    let component = std::fs::read(&program.component)
        .with_context(|| format!("reading {}", program.component.display()))?;
    let mut connection = connect_or_start().await?;
    connection
        .agree_on_protocol()
        .await
        .context("the WaOS that answered")?;
    let name = &program.name;
    connection
        .send(&ToWaOS::Run { id, component })
        .await
        .with_context(|| format!("sending {name} to WaOS"))?;
    let interrupted = tokio::signal::ctrl_c();
    tokio::pin!(interrupted);
    loop {
        tokio::select! {
            received = connection.receive::<FromWaOS>() => match received.context("reading from WaOS")? {
                Some(FromWaOS::Started) => eprintln!("cargo waos: {name} is running in WaOS"),
                Some(FromWaOS::Output { stream, bytes }) => write_output(stream, &bytes)?,
                Some(FromWaOS::Ended(end)) => return Ok(ended(name, &end)),
                Some(FromWaOS::Refused(why)) => bail!("WaOS refused {name}: {why}"),
                Some(FromWaOS::Opened) => bail!("WaOS answered an opening, which a run never asks"),
                None => bail!("WaOS closed the connection before {name} ended"),
            },
            _ = &mut interrupted => {
                drop(connection);
                eprintln!("cargo waos: {name} stopped");
                return Ok(ExitCode::from(INTERRUPTED));
            }
        }
    }
}

/// The WaOS on this machine: connected to at once when one answers, and started and waited for
/// otherwise.
async fn connect_or_start() -> Result<Connection<UnixStream>> {
    let socket = waos_dev_protocol::socket_path();
    match Connection::connect(&socket).await {
        Ok(connection) => return Ok(connection),
        Err(error) if nobody_answers(&error) => {}
        Err(error) => {
            return Err(error)
                .with_context(|| format!("connecting to WaOS at {}", socket.display()));
        }
    }
    eprintln!("cargo waos: no WaOS answers; starting WaOS.app");
    let log = waos_app::start()?;
    eprintln!("cargo waos: WaOS writes its log to {}", log.display());
    let deadline = Instant::now() + START_WAIT;
    loop {
        tokio::time::sleep(RETRY_EVERY).await;
        match Connection::connect(&socket).await {
            Ok(connection) => return Ok(connection),
            Err(error) if nobody_answers(&error) && Instant::now() < deadline => {}
            Err(error) => {
                return Err(error).with_context(|| {
                    format!(
                        "WaOS.app was started, but nothing answers at {}",
                        socket.display()
                    )
                });
            }
        }
    }
}

/// Whether `error` says no WaOS listens: no socket file, or one nobody answers on.
fn nobody_answers(error: &io::Error) -> bool {
    matches!(
        error.kind(),
        ErrorKind::NotFound | ErrorKind::ConnectionRefused
    )
}

/// Writes `bytes` to this process's own stream of the same name, at once.
fn write_output(stream: OutputStream, bytes: &[u8]) -> Result<()> {
    let written = match stream {
        OutputStream::Stdout => io::stdout()
            .lock()
            .write_all(bytes)
            .and_then(|()| io::stdout().flush()),
        OutputStream::Stderr => io::stderr()
            .lock()
            .write_all(bytes)
            .and_then(|()| io::stderr().flush()),
    };
    written.context("writing the program's output")
}

/// Says how `name` ended, and answers the exit status that says the same.
fn ended(name: &str, end: &RunEnd) -> ExitCode {
    let (said, status) = match end {
        RunEnd::Returned { success: true } => (format!("{name} returned"), ExitCode::SUCCESS),
        RunEnd::Returned { success: false } => {
            (format!("{name} returned failure"), ExitCode::FAILURE)
        }
        RunEnd::Failed(error) => (format!("{name} failed: {error}"), ExitCode::FAILURE),
        RunEnd::Stopped => (format!("{name} was stopped by WaOS"), ExitCode::FAILURE),
        RunEnd::Replaced => (
            format!("{name} was replaced by a later run"),
            ExitCode::FAILURE,
        ),
    };
    eprintln!("cargo waos: {said}");
    status
}