fix(m198): JAVAGUARD——首屏自动扫 / 子进程回收 / 参数长度三条守卫 + 设置页正文结构断言

- 首屏自动扫:删掉 `cfg!(test)` 分叉,生产入口在测试里真跑;新增
  `the_first_hydrate_starts_a_real_scan_by_itself`(删掉那一行、或把实参翻成 `true`
  都红)。顺手删掉恒为假的 `seeded && !live`:截图种子档在上方就 return 了,编译器也
  报 "assigned but never used"。
- proc:`kill()` + `wait()` 收进 `terminate()`(三个分支共用);用例先用同一条命令
  真跑一次证明子进程起得来(`spawn().ok()?` 的空跑假绿),并新增 Linux/Android 的
  僵尸回收用例(只 kill 不 wait ⇒ `/proc/<pid>` 留成僵尸)。
- java.extra_args:`MAX_JVM_ARGS = 1024` 镜像 schema,`Refusal::JvmTooLong` + 中英
  文案 + JVM 输入框变红;App 级用例断言回执是具名的「太长」而不是通用的「写不进去」。
- 设置页正文:判据①从「数 `stack = stack.push(` 的出现次数」升级成按角色断言
  (唯一来源 section_columns、`push(` 恰一次且实参是循环变量、尾表达式、禁
  extend/chain/once/concat/append),单行折叠变异体现在会红。
This commit is contained in:
AskaEth committed 2026-11-09 06:46:29 +08:00
1 parent 7f697f5762
commit aae9a233ff
9 files changed
+385 -81

No files matched your search

+145 -35
View File
@@ -11,6 +11,16 @@ pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。
const POLL_INTERVAL: Duration = Duration::from_millis(20);
/// 杀掉并**回收**子进程:`kill()` 只是投递终止请求,`wait()` 才是回收。
///
/// 为什么两件必须一起做(M199 / r3 复审 P3-5):只 `kill()` 不 `wait()`,Unix 上留下**僵尸**
/// (进程表项与 pid 都不释放),Windows 上句柄活到 `Child` 被 drop 为止、终止也只是异步的。
/// 抽成函数的理由:它是这条不变量的唯一落点,三个分支(超时 / 取消 / 出错)都走这里。
fn terminate(child: &mut std::process::Child) {
let _ = child.kill();
let _ = child.wait();
}
/// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。
///
/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。
@@ -36,15 +46,13 @@ pub fn run_with_timeout(
Ok(Some(_)) => break,
Ok(None) => {
if cancelled() || started.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
terminate(&mut child);
return None;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
terminate(&mut child);
return None;
}
}
@@ -54,6 +62,8 @@ pub fn run_with_timeout(
#[cfg(test)]
mod tests {
use std::time::Duration;
use super::run_with_timeout;
#[test]
@@ -97,15 +107,111 @@ mod tests {
/// 超时之后子进程必须**真的死了**:让它延时写一个标记文件,断言那个文件从未出现。
///
/// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 `kill()+wait()`
/// 两行删掉它们照样绿——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。
/// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 kill 那几行删掉它们照样绿
/// ——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。
///
/// 变异证明:删掉超时分支里的 `child.kill()` + `child.wait()` ⇒ 子进程活到延时结束、把
/// 标记文件写出来 ⇒ 本条红。
/// 为什么还要一条「子进程真的起过」(M199 / r3 复审 P3-5):`command.spawn().ok()?` 在命令
/// 起不来时也返回 `None`,于是机器上没有 powershell 时本用例**空跑也照样绿**。现在先用同一条
/// 命令(不延时)真跑一次,再要求超时那一跑也留下启动标记(里面有子进程自己的 pid)——起不来的
/// 机器从此是红,不是静默通过。
///
/// 时间账(免得慢机器上假红/假绿):超时 3s、子进程尾部延时 4s、事后等待 4s。若 kill 没生效,
/// 子进程在「起进程耗时 + 4s」写出存活标记,而这次检查在 3s + 4s = 7s;只要它起得来(上一步已
/// 证明 3s 内起得来,否则启动标记也写不出来)就一定在检查之前写出来 ⇒ 红。反过来,若 kill 生效,
/// 它在 3s 就被杀掉,4s 的尾巴根本走不到 ⇒ 绿。
///
/// 变异证明:删掉 `terminate()` 里的 `child.kill()` ⇒ 子进程活到 4 秒、把存活标记写出来 ⇒ 红。
#[test]
fn a_timed_out_child_is_really_killed() {
const TIMEOUT: Duration = Duration::from_secs(3);
const TAIL: u32 = 4;
const WAIT: Duration = Duration::from_secs(4);
let dir = temp_dir("kill");
let started = dir.join("started.txt");
let survived = dir.join("survived.txt");
// ① 子进程真的起得来吗(起不来的话下面全是空跑:`spawn().ok()?` 也给 `None`)。
let preflight = marker_command(&started, &survived, 0)
.output()
.expect("子进程必须真的起得来:本机缺 powershell / sh 时本用例不许静默通过");
assert!(
preflight.status.success(),
"标记命令自己失败了(stderr:{})",
String::from_utf8_lossy(&preflight.stderr)
);
assert!(
started.exists() && survived.exists(),
"标记命令没写出标记:它没真的跑过,后面的结论不成立"
);
std::fs::remove_file(&started).ok();
std::fs::remove_file(&survived).ok();
// ② 超时那一跑:被杀掉,且被杀之前它确实已经起来了。
let output = run_with_timeout(marker_command(&started, &survived, TAIL), TIMEOUT, &|| {
false
});
assert!(output.is_none(), "超时没有结论");
assert!(
started.exists(),
"子进程没写出启动标记:这一跑是空跑({TIMEOUT:?} 内没起来),用例不成立"
);
std::thread::sleep(WAIT);
assert!(
!survived.exists(),
"子进程在超时后仍然活着并执行了尾巴(存活标记出现了):kill 没生效"
);
std::fs::remove_dir_all(&dir).ok();
}
/// 被 kill 之后必须**回收**(`wait()`):只 `kill()` 不 `wait()` 会留下僵尸。
///
/// 为什么只能在 Linux / Android 上守(M199):僵尸是 Unix 的概念——进程被杀掉而父进程没
/// `wait()` 时,它在进程表里留着、`/proc/<pid>` 仍然存在(状态 `Z`),pid 也不释放;`wait()`
/// 之后进程表项立刻消失。Windows 没有这个可观测状态(终止 + 句柄释放之后没有残留),所以那条
/// 平台上「少写一行 `wait()`」只能靠代码评审——**这是本用例覆盖不到的一半**,报告里要说清。
///
/// 变异证明:只删 `terminate()` 里的 `child.wait()`(留着 `kill()`)⇒ pid 成僵尸 ⇒ 红;
/// 把 `terminate()` 整个换成空函数(两条都删)⇒ 子进程还活着 ⇒ 红。
#[cfg(any(target_os = "linux", target_os = "android"))]
#[test]
fn a_killed_child_is_reaped_not_left_a_zombie() {
let dir = temp_dir("reap");
let started = dir.join("started.txt");
let survived = dir.join("survived.txt");
let output = run_with_timeout(
marker_command(&started, &survived, 30),
Duration::from_secs(2),
&|| false,
);
assert!(output.is_none(), "超时没有结论");
let pid: u32 = std::fs::read_to_string(&started)
.expect("子进程没写出 pid:这一跑是空跑")
.trim()
.parse()
.expect("启动标记里应当是子进程的 pid");
// `wait()` 是同步回收:它返回时 `/proc/<pid>` 就该没了,不用等。
match process_state(pid) {
None => {}
Some('Z') => panic!("kill 之后没有 wait 回收:pid {pid} 成了僵尸(进程表项还在)"),
Some(state) => panic!("pid {pid} 还在(状态 {state}):它没被杀掉,或没被回收"),
}
std::fs::remove_dir_all(&dir).ok();
}
/// `/proc/<pid>` 里的进程状态字符(`None` = 进程表里已经没有这一项)。
#[cfg(any(target_os = "linux", target_os = "android"))]
fn process_state(pid: u32) -> Option<char> {
let stat = std::fs::read_to_string(format!("/proc/{pid}/stat")).ok()?;
// `pid (comm) S …`:comm 里可能有空格和括号,只认**最后一个** `)` 之后的第一个字符。
let rest = &stat[stat.rfind(')')? + 1..];
rest.chars().find(|c| !c.is_whitespace())
}
/// 每个用例一个临时目录(pid + 纳秒:并行用例不会撞在一起)。
fn temp_dir(tag: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!(
"ull-proc-kill-{}-{}",
"ull-proc-{tag}-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
@@ -113,45 +219,49 @@ mod tests {
.unwrap_or(0)
));
std::fs::create_dir_all(&dir).expect("临时目录");
let marker = dir.join("survived.txt");
let _ = std::fs::remove_file(&marker);
let output = run_with_timeout(
marker_command(&marker, 4),
std::time::Duration::from_millis(300),
&|| false,
);
assert!(output.is_none(), "超时没有结论");
// 给「要是没被杀」足够的时间把标记写出来(4 秒的延时 + 余量)。
std::thread::sleep(std::time::Duration::from_secs(6));
assert!(
!marker.exists(),
"子进程在超时后仍然活着并执行了尾巴(标记文件出现了):kill 没生效"
);
std::fs::remove_dir_all(&dir).ok();
dir
}
/// 延时 `seconds` 秒之后写一个标记文件的命令(两个平台各一条,语义相同)。
fn marker_command(marker: &std::path::Path, seconds: u32) -> std::process::Command {
let target = marker.display().to_string();
if cfg!(windows) {
// PowerShell 而不是 `cmd /C "…"`:Rust 会把整个参数加一层双引号,cmd 再把里面的
// 引号吃掉,重定向目标就被截断(实测命令立刻失败 ⇒ 本用例假红)。这里路径用
// PowerShell 的单引号,外层只剩 Rust 加的那一层。
/// 「先写启动标记(子进程 pid)、延时 `seconds` 秒之后再写存活标记」的命令(两平台语义相同)。
///
/// 启动标记里的 pid 同时证明两件事:**spawn 真的成功、进程真的跑起来了**(M199:`spawn().ok()?`
/// 起不来时也是 `None`,本用例因此会空跑),以及「这个 pid 后来被回收了吗」。
///
/// 两个标记的路径**走环境变量**:`cmd /C "…"` 会被 Rust 与 cmd 各加一层引号、把重定向目标截断
/// (实测命令立刻失败 ⇒ 假红);`sh` 用 `"$VAR"`、PowerShell 用 `$env:` 取,命令行上一个引号
/// 都不出现,路径里有空格也不会被拆开。
fn marker_command(
started: &std::path::Path,
survived: &std::path::Path,
seconds: u32,
) -> std::process::Command {
let mut command = if cfg!(windows) {
let mut command = std::process::Command::new("powershell");
command.args([
"-NoProfile",
"-Command",
&format!(
"Start-Sleep -Seconds {seconds}; Set-Content -Path '{target}' -Value alive"
"Set-Content -Path $env:ULL_PROC_STARTED -Value $PID; \
Start-Sleep -Seconds {seconds}; \
Set-Content -Path $env:ULL_PROC_SURVIVED -Value alive"
),
]);
command
} else {
let mut command = std::process::Command::new("sh");
command.args(["-c", &format!("sleep {seconds}; echo alive > '{target}'")]);
command.args([
"-c",
&format!(
"echo $$ > \"$ULL_PROC_STARTED\"; sleep {seconds}; \
echo alive > \"$ULL_PROC_SURVIVED\""
),
]);
command
}
};
command
.env("ULL_PROC_STARTED", started)
.env("ULL_PROC_SURVIVED", survived);
command
}
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。