Files
Unlucky-Launcher/crates/core/tests/launch/process_smoke.rs
T
AskaEth 6ce825db8d fix(launch): M48 —— POSIX 组杀假成功导致 stop() 空等超时
CI test (ubuntu-latest) 自 M43 起红的根因不是「组杀打到自身进程组」,
而是 procps-ng 的 kill 把裸 `-<pgid>` 当成又一个信号选项吞掉:退出码 0、
什么都没发。旧实现据此提前 return,单 pid 兜底永远不执行,于是
process_smoke::stop_kills_process_tree 与
pipeline_cases::pipeline_stop_records_killed_exit 只能干等
graceful+5s 预算后报「强制结束后进程仍在运行」。

- process.rs:Unix 分支用 CommandExt::process_group(0)(std 1.64+ 安全
  API,无需 pre_exec/unsafe)让子进程自成一个进程组,与 Windows 的
  CREATE_NEW_PROCESS_GROUP 对称;这也让「树」回收真正覆盖后代。
- tree_kill.rs:组杀改用 `kill -TERM -- -<pgid>`(`--` 不可省),并且
  先过 group_kill_target 三重验证——目标必须是自己的进程组组长、必须
  读得到我们自己的组、目标组不能就是我们所在的组;单 pid 兜底改为
  「永远执行」,kill 缺失(NotFound)返回明确错误而不是静默失败。
  pid==0 守卫从 kill_tree 提到 terminate/force_kill 共用入口。
- 测试钉子(Linux 门住):spawn 后子进程 pgid == 自己的 pid 且 !=
  测试进程 pgid;组杀只接受验证过的独立组;组杀覆盖孙进程且不碰我们
  自己的组;同组子进程靠单 pid 兜底仍能被干掉;pid 0 一律拒绝。

验证:rust:1-bookworm 容器(ull-linux-target 热缓存)cargo fmt --check、
cargo clippy --workspace --all-targets -D warnings、cargo test -p
unlucky-core --test launch(82 passed,两条原红灯转绿)、cargo test
--workspace --all-targets --no-fail-fast 不再被 SIGTERM 打断;Windows
本机 fmt/clippy/cargo test -p unlucky-core 全绿。
2026-11-01 10:32:18 +08:00

168 lines
5.1 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 进程冒烟测试:真实 spawn 一个 shell,验证泵、退出码、进程树 kill。
//!
//! 这些用例跑的是真进程(不是 mock),是“事件流 + 退出分析 + 树 kill”端到端的最小证据。
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use crate::prelude::*;
use unlucky_core::launch::process::{self, ProcessConfig, RunningProcess};
use unlucky_core::launch::pump::{LineSink, NullSink};
fn shell_command(script: &str) -> AssembledCommand {
#[cfg(windows)]
let (program, args) = (
"cmd".to_string(),
vec!["/C".to_string(), script.to_string()],
);
#[cfg(not(windows))]
let (program, args) = (
"/bin/sh".to_string(),
vec!["-c".to_string(), script.to_string()],
);
AssembledCommand {
program: PathBuf::from(program),
args,
classpath: Vec::new(),
encoding: "UTF-8".to_string(),
memory: MemoryOptions::default(),
env: Vec::new(),
warnings: Vec::new(),
main_class: String::new(),
}
}
fn config() -> ProcessConfig {
ProcessConfig {
graceful_stop: Duration::from_millis(200),
..ProcessConfig::default()
}
}
#[test]
fn spawn_collects_both_streams_and_exit_code() {
let root = temp_sessions_root("smoke");
let meta = SessionMeta::new("inst-1", "smoke", "1.20.1");
let writer =
SessionWriter::create_with_id(&root, "smoke-1", meta, Redactor::new()).expect("创建会话");
let path = writer.path().to_path_buf();
let sink = Arc::new(SessionSink::new(writer));
let mut child = RunningProcess::spawn(
&shell_command("echo hello-from-stdout & echo hello-from-stderr 1>&2"),
None,
sink.clone(),
config(),
)
.expect("spawn 应成功");
let status = child.wait().expect("wait 应成功");
assert_eq!(process::exit_code(&status), 0);
assert!(child.pid() > 0);
let verdict = child.supervise();
assert!(verdict.is_complete(), "两条流都应在预算内结束");
let texts: Vec<String> = child.lines().into_iter().map(|line| line.text).collect();
assert!(
texts.iter().any(|text| text.contains("hello-from-stdout")),
"stdout 未收集到:{texts:?}"
);
assert!(
texts.iter().any(|text| text.contains("hello-from-stderr")),
"stderr 未收集到:{texts:?}"
);
// 事件流里应有 stdout/stderr 行事件。
let events = read_events(&path);
let stdout_lines = events
.iter()
.filter(|event| event["kind"] == "stdout")
.count();
let stderr_lines = events
.iter()
.filter(|event| event["kind"] == "stderr")
.count();
assert!(stdout_lines >= 1, "应有 stdout 事件");
assert!(stderr_lines >= 1, "应有 stderr 事件");
}
#[test]
fn stop_kills_process_tree() {
#[cfg(windows)]
let script = "ping -n 30 127.0.0.1 >nul";
#[cfg(not(windows))]
let script = "sleep 30";
let mut child =
RunningProcess::spawn(&shell_command(script), None, Arc::new(NullSink), config())
.expect("spawn 应成功");
assert!(child.is_running());
child.stop().expect("停止应成功");
let status = child.wait().expect("wait 应成功");
assert_ne!(
process::exit_code(&status),
0,
"被结束的进程不应返回退出码 0"
);
assert!(!child.is_running());
}
#[cfg(target_os = "linux")]
#[test]
fn spawn_puts_child_in_its_own_process_group() {
/// `/proc/<pid>/stat` 里的 `pgrp` 字段。
fn pgid(pid: u32) -> u32 {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).expect("读 /proc stat");
let tail = &stat[stat.rfind(')').expect("stat 应有 comm 括号") + 1..];
tail.split_whitespace()
.nth(2)
.expect("stat 应有 pgrp 字段")
.parse()
.expect("pgrp 应是数字")
}
let mut child = RunningProcess::spawn(
&shell_command("sleep 30"),
None,
Arc::new(NullSink),
config(),
)
.expect("spawn 应成功");
let pid = child.pid();
let own_pgid = pgid(std::process::id());
assert_eq!(
pgid(pid),
pid,
"子进程应是自己的进程组组长(process_group(0))"
);
assert_ne!(pgid(pid), own_pgid, "子进程不能挂在测试进程的进程组上");
assert_eq!(
unlucky_core::launch::tree_kill::group_kill_target(pid),
Some(pid),
"独立组的组长才是合法的组杀目标"
);
child.stop().expect("停止应成功");
let _ = child.wait();
}
#[test]
fn spawn_reports_missing_program() {
let mut command = shell_command("echo hi");
command.program = PathBuf::from("definitely-not-a-real-binary-ull");
let error = RunningProcess::spawn(&command, None, Arc::new(NullSink), config())
.expect_err("程序不存在必须报错");
assert!(matches!(error, LaunchError::Spawn { .. }));
}
#[test]
fn line_sink_trait_object_is_usable() {
let sink: Arc<dyn LineSink> = Arc::new(NullSink);
sink.on_line(&LogLine::new(Stream::Stdout, "ignored"));
}