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 管道(失败时收尾)。/// Windows 加 `CREATE_NO_WINDOW`(不占控制台风;GUI 子窗不受影响)。
76pub fn spawn_detached(program: &std::path::Path, args: &[String]) -> std::io::Result<Child> {
77    let mut cmd = Command::new(program);
78    cmd.args(args)
79        .stdin(Stdio::null())
80        .stdout(Stdio::null())
81        .stderr(Stdio::piped());
82    #[cfg(windows)]
83    {
84        const CREATE_NO_WINDOW: u32 = 0x0800_0000;
85        use std::os::windows::process::CommandExt;
86        cmd.creation_flags(CREATE_NO_WINDOW);
87    }
88    cmd.spawn()
89}
90
91/// 读 stderr 尾(读满为止再截 TAB;只 happen 在子进程已退出/已 kill 之时)。
92fn stderr_tail(child: &mut Child) -> String {
93    let Some(mut pipe) = child.stderr.take() else {
94        return String::new();
95    };
96    use std::io::Read;
97    let mut buf = Vec::new();
98    let _ = pipe.read_to_end(&mut buf);
99    if buf.len() > STDERR_TAIL_LEN {
100        let tail = &buf[buf.len() - STDERR_TAIL_LEN..];
101        String::from_utf8_lossy(tail).to_string()
102    } else {
103        String::from_utf8_lossy(&buf).to_string()
104    }
105}
106
107/// 轮询:就绪标记 → Ok;子进程退出 → Exited;超时 → NoReadySignal(kill 交付)。
108pub fn wait_ready(
109    child: &mut Child,
110    marker: &std::path::Path,
111    timeout: Duration,
112) -> Result<(), LaunchFail> {
113    let deadline = Instant::now() + timeout;
114    loop {
115        if marker.is_file() {
116            return Ok(());
117        }
118        match child.try_wait() {
119            Ok(Some(status)) => {
120                let tail = stderr_tail(child);
121                return Err(LaunchFail::Exited {
122                    code: status.code().unwrap_or(-1),
123                    stderr_tail: tail,
124                });
125            }
126            Ok(None) => {}
127            Err(_) => {} // 底座朝向:进程直走视权位面,继续走到超时退出兜底
128        }
129        if Instant::now() >= deadline {
130            let _ = child.kill();
131            return Err(LaunchFail::NoReadySignal { timeout });
132        }
133        std::thread::sleep(Duration::from_millis(50));
134    }
135}
136
137/// 一次全步骤:spawn → wait_ready;失败时子进程已收场(超时路径 kill)。
138/// 成功:Handle 已 drop = 分离脱管,子进程独立活着(这正是 launcher 放手的方式)。
139pub fn launch(
140    program: &std::path::Path,
141    args: &[String],
142    marker: &std::path::Path,
143    timeout: Duration,
144) -> Result<(), LaunchFail> {
145    let mut child = match spawn_detached(program, args) {
146        Ok(c) => c,
147        Err(e) => {
148            return Err(LaunchFail::Exited {
149                code: 0,
150                stderr_tail: format!("spawn failed: {e}"),
151            });
152        }
153    };
154    match wait_ready(&mut child, marker, timeout) {
155        Ok(()) => Ok(()), // child 在此 drop:分离脱管
156        Err(e) => Err(e),
157    }
158}