feat(m175): Java 设置页接真运行时(扫描 / 手动路径 / 默认 JDK / 内存 / JVM 参数)

用户实测四条抱怨逐条对应:
- 「没有自动检测本机 Java」:首屏自动扫一次盘(托管根 + JAVA_HOME + PATH + 标准安装位置 +
  注册表),之后由「立即扫描」手动触发;扫描在后台线程里跑,带 done/total 进度与取消
- 「显示 Temurin 21.0.4 不知道是不是硬编码」:演示行与绊线测试一起删掉,列表来自真读盘
- 「没一个能改」:默认内存(MB,512~65536,范围与 schema 同源)与 JVM 参数可真改、真落盘、读回
- 「不能手动指定 Java 路径 / 不能改默认版本」:手动添加走严格校验(真跑 java -version),
  设为默认写 java.selected_path 并给出「为什么选它」(当前实例的版本门槛 + 每行满足与否)

core:java::manual(严格探测 + 厂商识别 + 名单持久化)、java::staged(候选收集 + 带进度与
取消的扫描)、java::proc(带超时且可打断的子进程执行:卡住的 java -version 会被 kill)。
i18n:catalog/settings/java.rs 55 条中英 + 金表按装配顺序插入。

复审 r1(p2-5items)全修:
- P2-1 「设为默认」不再清脏:只写 java.selected_path,内存/JVM 草稿一个字节都不动
- P2-2 错误文案全部走 i18n(三档「写不进去」/「路径太长」/「名单写不进去」各自一个 key),
  不再把 Rust 枚举名当人话
- P2-3 手动名单落盘有正面断言(加一条 → 从盘上新读一份 → 在)
- P2-4 版本号显示到小版本(17.0.13 / 17.0.9 可区分),解析不出来时给原始版本串
- P2-5 每候选 8 秒超时并 kill、取消打断正在跑的探测、扫描槽挂在 State 上、线程活着期间
  不许起第二个(界面显示「正在取消…」)

复审 r2(p2-2items)全修:
- P2-1 空态不再指向不存在的「Java 页」,改成真实存在的 `ull java install 21` 命令行入口
- P2-2 `auto_scan_due` 增加 `!report.cancelled`:取消过一次之后本进程不再自动扫,
  配置文件再变(存内存 / 改主题)也不会把取消无声撤销;手动「立即扫描」不受影响
- 顺带:proc 的 kill+wait 补「子进程真死了」的回归守卫(标记文件从未出现)、
  core 的 Schema 错误改成页面自己的 key(不再把中文 Display 直出给英文界面)、
  默认态 chip 用新的 `Chip::badge`(不再挂一条永远收不到的消息)
This commit is contained in:
AskaEth committed 2026-11-08 19:52:04 +08:00
1 parent 30b61e2684
commit df331f6a60
26 files changed
+4030 -85

No files matched your search

+169
View File
@@ -0,0 +1,169 @@
//! 子进程的**带超时 / 可打断**执行(M175 r1 复审 P2-5)。
//!
//! `Command::output()` 是「等到底」:一份放在挂死网络盘上的 `java` 会让调用它的扫描线程永远不
//! 返回,界面上「取消」也就永远兑现不了。这里只做一件事——**要么拿到输出,要么把它杀掉**。
use std::time::Duration;
/// 单个候选的探测预算(`java -version` 正常几十毫秒;8 秒还没答话就是卡住了)。
pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。
const POLL_INTERVAL: Duration = Duration::from_millis(20);
/// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。
///
/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。
///
/// 为什么不用 `Command::output()`:它是「等到底」,一个卡住的子进程会把整个扫描线程钉死,
/// 而取消位只能在两次探测之间被看到——取消就永远兑现不了。
#[must_use]
pub fn run_with_timeout(
mut command: std::process::Command,
timeout: Duration,
cancelled: &dyn Fn() -> bool,
) -> Option<std::process::Output> {
use std::process::Stdio;
command
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped());
let mut child = command.spawn().ok()?;
let started = std::time::Instant::now();
loop {
match child.try_wait() {
Ok(Some(_)) => break,
Ok(None) => {
if cancelled() || started.elapsed() > timeout {
let _ = child.kill();
let _ = child.wait();
return None;
}
std::thread::sleep(POLL_INTERVAL);
}
Err(_) => {
let _ = child.kill();
let _ = child.wait();
return None;
}
}
}
child.wait_with_output().ok()
}
#[cfg(test)]
mod tests {
use super::run_with_timeout;
#[test]
fn a_hung_probe_is_killed_after_the_timeout() {
// 一个「永远不返回」的子进程:不杀掉它,扫描线程就永远不结束(复审 P2-5)。
let started = std::time::Instant::now();
let output = run_with_timeout(
sleeping_command(30),
std::time::Duration::from_millis(400),
&|| false,
);
assert!(output.is_none(), "超时必须判成「没有结论」");
let elapsed = started.elapsed();
assert!(
elapsed < std::time::Duration::from_secs(5),
"超时之后必须 kill 掉子进程并返回,实际等了 {elapsed:?}"
);
}
#[test]
fn a_cancel_interrupts_the_probe_that_is_already_running() {
// 取消不能只在「两次探测之间」被看到:卡住的那一个也要能打断。
let flag = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let tripper = flag.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(150));
tripper.store(true, std::sync::atomic::Ordering::SeqCst);
});
let started = std::time::Instant::now();
let output = run_with_timeout(
sleeping_command(30),
std::time::Duration::from_secs(30),
&|| flag.load(std::sync::atomic::Ordering::SeqCst),
);
assert!(output.is_none(), "被取消的探测没有结论");
assert!(
started.elapsed() < std::time::Duration::from_secs(10),
"取消必须打断正在跑的那个探测,而不是等它自己结束"
);
}
/// 超时之后子进程必须**真的死了**:让它延时写一个标记文件,断言那个文件从未出现。
///
/// 为什么要有这一条(r2 复审 P3-1):上面两条只断言「返回得快」,把 `kill()+wait()`
/// 两行删掉它们照样绿——而没杀掉的子进程会继续跑(写它自己的文件、占句柄)。
///
/// 变异证明:删掉超时分支里的 `child.kill()` + `child.wait()` ⇒ 子进程活到延时结束、把
/// 标记文件写出来 ⇒ 本条红。
#[test]
fn a_timed_out_child_is_really_killed() {
let dir = std::env::temp_dir().join(format!(
"ull-proc-kill-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.subsec_nanos())
.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();
}
/// 延时 `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 加的那一层。
let mut command = std::process::Command::new("powershell");
command.args([
"-NoProfile",
"-Command",
&format!(
"Start-Sleep -Seconds {seconds}; Set-Content -Path '{target}' -Value alive"
),
]);
command
} else {
let mut command = std::process::Command::new("sh");
command.args(["-c", &format!("sleep {seconds}; echo alive > '{target}'")]);
command
}
}
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。
fn sleeping_command(seconds: u32) -> std::process::Command {
if cfg!(windows) {
let mut command = std::process::Command::new("cmd");
command.args(["/C", &format!("ping -n {seconds} 127.0.0.1 > NUL")]);
command
} else {
let mut command = std::process::Command::new("sleep");
command.arg(seconds.to_string());
command
}
}
}