Skip to main content

ek_ui/
launch.rs

1//! spawn + 就绪等待(launcher 的子进程协议核心)。
2//!
3//! 协议(与 ek-station bin 既有 `--ready-file` 一致):子进程 start 成功后写
4//! `"ready\n"`;失败/超时无标记。launcher 只轮询标记 + 子进程存活,不压入套管。
5//!
6//! detached 语义(BD-UI-14):子进程 Handle 由调用方收/丢 —— `launch` 成功后
7//! **不等待不杀**(drop 即脱管,进程独立存活);失败路径 kill 后收 stderr 尾。
8
9use std::process::{Child, Command, Stdio};
10use std::time::{Duration, Instant};
11
12use crate::pick::PickErr;
13
14/// 就绪标记文本(与 ek-station bin / runtime 的写入端同一常量口径)。
15pub const READY_TEXT: &str = "ready\n";
16
17/// 缺省就绪等待(UI 子进程冷起含 wgpu 初始化,给足余量)。
18pub const DEFAULT_READY_TIMEOUT: Duration = Duration::from_secs(30);
19
20/// 失败时保留的 stderr 尾字节数。
21const STDERR_TAIL_LEN: usize = 2048;
22
23/// 启动失败分类(业务枚举;launcher 决定呈现)。
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub enum LaunchFail {
26    /// 子进程退出但没有就绪标记(exit 0 也算 —— 协议违约同样失败)。
27    Exited { code: i32, stderr_tail: String },
28    /// 等满超时仍在跑(子进程已被 kill)。
29    NoReadySignal { timeout: Duration },
30}
31
32impl std::fmt::Display for LaunchFail {
33    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
34        match self {
35            Self::Exited { code, stderr_tail } => write!(
36                f,
37                "child exited (code {code}) without ready signal; stderr tail: {stderr_tail}"
38            ),
39            Self::NoReadySignal { timeout } => {
40                write!(f, "no ready signal within {:?} (child killed)", timeout)
41            }
42        }
43    }
44}
45
46impl std::error::Error for LaunchFail {}
47
48impl From<PickErr> for LaunchFail {
49    fn from(e: PickErr) -> Self {
50        // 语义差异:清单错也会让子进程退 1;launcher 前置校验时直接文本带出。
51        Self::Exited {
52            code: 0,
53            stderr_tail: e.to_string(),
54        }
55    }
56}
57
58/// launcher 工作目录(pick 清单 + 就绪标记的收纳处)。
59#[must_use]
60pub fn launch_workdir() -> std::path::PathBuf {
61    std::env::temp_dir().join("ek-launch")
62}
63
64/// 唯一就绪标记路径(进程 id + 纳秒;同目录并发不撞)。
65#[must_use]
66pub fn unique_marker(dir: &std::path::Path) -> std::path::PathBuf {
67    let nanos = std::time::SystemTime::now()
68        .duration_since(std::time::UNIX_EPOCH)
69        .map(|d| d.as_nanos() as u64)
70        .unwrap_or(0);
71    let _ = std::fs::create_dir_all(dir);
72    dir.join(format!("ek-ready-{}{}-.marker", std::process::id(), nanos))
73}
74
75/// 分离式 spawn:stdin/stdout 静默,stderr 管道(失败时收尾)。
76///
77/// Windows 加 `CREATE_NO_WINDOW`(不占控制台风;GUI 子窗不受影响)。
78/// (0.4.3 审核 P9:这一句原先串在上一行文档注释尾部,编译无碍但 docs.rs 上这段文本是坏的。)
79pub fn spawn_detached(program: &std::path::Path, args: &[String]) -> std::io::Result<Child> {
80    let mut cmd = Command::new(program);
81    cmd.args(args)
82        .stdin(Stdio::null())
83        .stdout(Stdio::null())
84        .stderr(Stdio::piped());
85    #[cfg(windows)]
86    {
87        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
88        use std::os::windows::process::CommandExt;
89        cmd.creation_flags(CREATE_NO_WINDOW);
90    }
91    cmd.spawn()
92}
93
94/// 读 stderr 尾(读满为止再截 TAB;只 happen 在子进程已退出/已 kill 之时)。
95fn stderr_tail(child: &mut Child) -> String {
96    let Some(mut pipe) = child.stderr.take() else {
97        return String::new();
98    };
99    use std::io::Read;
100    let mut buf = Vec::new();
101    let _ = pipe.read_to_end(&mut buf);
102    if buf.len() > STDERR_TAIL_LEN {
103        let tail = &buf[buf.len() - STDERR_TAIL_LEN..];
104        String::from_utf8_lossy(tail).to_string()
105    } else {
106        String::from_utf8_lossy(&buf).to_string()
107    }
108}
109
110/// 轮询:就绪标记 → Ok;子进程退出 → Exited;超时 → NoReadySignal(kill 交付)。
111pub fn wait_ready(
112    child: &mut Child,
113    marker: &std::path::Path,
114    timeout: Duration,
115) -> Result<(), LaunchFail> {
116    let deadline = Instant::now() + timeout;
117    loop {
118        if marker.is_file() {
119            return Ok(());
120        }
121        match child.try_wait() {
122            Ok(Some(status)) => {
123                let tail = stderr_tail(child);
124                return Err(LaunchFail::Exited {
125                    code: status.code().unwrap_or(-1),
126                    stderr_tail: tail,
127                });
128            }
129            Ok(None) => {}
130            Err(_) => {} // 底座朝向:进程直走视权位面,继续走到超时退出兜底
131        }
132        if Instant::now() >= deadline {
133            let _ = child.kill();
134            return Err(LaunchFail::NoReadySignal { timeout });
135        }
136        std::thread::sleep(Duration::from_millis(50));
137    }
138}
139
140/// 一次全步骤:spawn → wait_ready;失败时子进程已收场(超时路径 kill)。
141/// 成功:Handle 已 drop = 分离脱管,子进程独立活着(这正是 launcher 放手的方式)。
142pub fn launch(
143    program: &std::path::Path,
144    args: &[String],
145    marker: &std::path::Path,
146    timeout: Duration,
147) -> Result<(), LaunchFail> {
148    let mut child = match spawn_detached(program, args) {
149        Ok(c) => c,
150        Err(e) => {
151            return Err(LaunchFail::Exited {
152                code: 0,
153                stderr_tail: format!("spawn failed: {e}"),
154            });
155        }
156    };
157    match wait_ready(&mut child, marker, timeout) {
158        Ok(()) => Ok(()), // child 在此 drop:分离脱管
159        Err(e) => Err(e),
160    }
161}