ek-ui 0.9.8

ek 生态常驻主界面(egui/eframe):插件即界面——观测页 + 日志页,车头与 UI 同插件
Documentation
//! spawn + 就绪等待(launcher 的子进程协议核心)。
//!
//! 协议(与 ek-station bin 既有 `--ready-file` 一致):子进程 start 成功后写
//! `"ready\n"`;失败/超时无标记。launcher 只轮询标记 + 子进程存活,不压入套管。
//!
//! detached 语义(BD-UI-14):子进程 Handle 由调用方收/丢 —— `launch` 成功后
//! **不等待不杀**(drop 即脱管,进程独立存活);失败路径 kill 后收 stderr 尾。

use std::process::{Child, Command, Stdio};
use std::time::{Duration, Instant};

use crate::pick::PickErr;

/// 就绪标记文本(与 ek-station bin / runtime 的写入端同一常量口径)。
pub const READY_TEXT: &str = "ready\n";

/// 缺省就绪等待(UI 子进程冷起含 wgpu 初始化,给足余量)。
pub const DEFAULT_READY_TIMEOUT: Duration = Duration::from_secs(30);

/// 失败时保留的 stderr 尾字节数。
const STDERR_TAIL_LEN: usize = 2048;

/// 启动失败分类(业务枚举;launcher 决定呈现)。
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum LaunchFail {
    /// 子进程退出但没有就绪标记(exit 0 也算 —— 协议违约同样失败)。
    Exited { code: i32, stderr_tail: String },
    /// 等满超时仍在跑(子进程已被 kill)。
    NoReadySignal { timeout: Duration },
}

impl std::fmt::Display for LaunchFail {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Exited { code, stderr_tail } => write!(
                f,
                "child exited (code {code}) without ready signal; stderr tail: {stderr_tail}"
            ),
            Self::NoReadySignal { timeout } => {
                write!(f, "no ready signal within {:?} (child killed)", timeout)
            }
        }
    }
}

impl std::error::Error for LaunchFail {}

impl From<PickErr> for LaunchFail {
    fn from(e: PickErr) -> Self {
        // 语义差异:清单错也会让子进程退 1;launcher 前置校验时直接文本带出。
        Self::Exited {
            code: 0,
            stderr_tail: e.to_string(),
        }
    }
}

/// launcher 工作目录(pick 清单 + 就绪标记的收纳处)。
#[must_use]
pub fn launch_workdir() -> std::path::PathBuf {
    std::env::temp_dir().join("ek-launch")
}

/// 唯一就绪标记路径(进程 id + 纳秒;同目录并发不撞)。
#[must_use]
pub fn unique_marker(dir: &std::path::Path) -> std::path::PathBuf {
    let nanos = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .map(|d| d.as_nanos() as u64)
        .unwrap_or(0);
    let _ = std::fs::create_dir_all(dir);
    dir.join(format!("ek-ready-{}{}-.marker", std::process::id(), nanos))
}

/// 分离式 spawn:stdin/stdout 静默,stderr 管道(失败时收尾)。
///
/// Windows 加 `CREATE_NO_WINDOW`(不占控制台风;GUI 子窗不受影响)。
/// (0.4.3 审核 P9:这一句原先串在上一行文档注释尾部,编译无碍但 docs.rs 上这段文本是坏的。)
pub fn spawn_detached(program: &std::path::Path, args: &[String]) -> std::io::Result<Child> {
    let mut cmd = Command::new(program);
    cmd.args(args)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::piped());
    #[cfg(windows)]
    {
        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
        use std::os::windows::process::CommandExt;
        cmd.creation_flags(CREATE_NO_WINDOW);
    }
    cmd.spawn()
}

/// 读 stderr 尾(读满为止再截 TAB;只 happen 在子进程已退出/已 kill 之时)。
fn stderr_tail(child: &mut Child) -> String {
    let Some(mut pipe) = child.stderr.take() else {
        return String::new();
    };
    use std::io::Read;
    let mut buf = Vec::new();
    let _ = pipe.read_to_end(&mut buf);
    if buf.len() > STDERR_TAIL_LEN {
        let tail = &buf[buf.len() - STDERR_TAIL_LEN..];
        String::from_utf8_lossy(tail).to_string()
    } else {
        String::from_utf8_lossy(&buf).to_string()
    }
}

/// 轮询:就绪标记 → Ok;子进程退出 → Exited;超时 → NoReadySignal(kill 交付)。
pub fn wait_ready(
    child: &mut Child,
    marker: &std::path::Path,
    timeout: Duration,
) -> Result<(), LaunchFail> {
    let deadline = Instant::now() + timeout;
    loop {
        if marker.is_file() {
            return Ok(());
        }
        match child.try_wait() {
            Ok(Some(status)) => {
                let tail = stderr_tail(child);
                return Err(LaunchFail::Exited {
                    code: status.code().unwrap_or(-1),
                    stderr_tail: tail,
                });
            }
            Ok(None) => {}
            Err(_) => {} // 底座朝向:进程直走视权位面,继续走到超时退出兜底
        }
        if Instant::now() >= deadline {
            let _ = child.kill();
            return Err(LaunchFail::NoReadySignal { timeout });
        }
        std::thread::sleep(Duration::from_millis(50));
    }
}

/// 一次全步骤:spawn → wait_ready;失败时子进程已收场(超时路径 kill)。
/// 成功:Handle 已 drop = 分离脱管,子进程独立活着(这正是 launcher 放手的方式)。
pub fn launch(
    program: &std::path::Path,
    args: &[String],
    marker: &std::path::Path,
    timeout: Duration,
) -> Result<(), LaunchFail> {
    let mut child = match spawn_detached(program, args) {
        Ok(c) => c,
        Err(e) => {
            return Err(LaunchFail::Exited {
                code: 0,
                stderr_tail: format!("spawn failed: {e}"),
            });
        }
    };
    match wait_ready(&mut child, marker, timeout) {
        Ok(()) => Ok(()), // child 在此 drop:分离脱管
        Err(e) => Err(e),
    }
}