Merge branch 'feat/m199-javaguard-auto-scan-and-proc-kill-g'
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / unsafe 落点守门(集合必须恰好等于期望) (push) Canceled after 0s
Guards / design 零第三方依赖守门 (push) Canceled after 0s
Guards / presenter 零 UI 守门 (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Dev Guards / 架构与合规守门(同 main 口径) (push) Canceled after 0s
Dev Guards / fmt (rustfmt --check) (push) Canceled after 0s
Dev Guards / clippy (ubuntu) (push) Canceled after 0s

This commit is contained in:
AskaEth committed 2026-11-09 07:20:18 +08:00
commit 1f2d89c23e
9 files changed
+384 -80

No files matched your search

+14 -13
View File
@@ -92,17 +92,15 @@ impl App {
/// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。 /// 扫到了什么」——它不是出图档,所以扫描判据按产品路径算。
pub(in crate::app) fn hydrate_java_settings(&mut self) { pub(in crate::app) fn hydrate_java_settings(&mut self) {
let seeded = self.options.screenshot.is_some(); let seeded = self.options.screenshot.is_some();
let mut live = false;
if seeded { if seeded {
let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string()); let raw = std::env::var("ULL_JAVA_STATE").unwrap_or_else(|_| "two".to_string());
if raw.trim() == "live" { if raw.trim() != "live" {
live = true;
} else {
if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) { if let Some(state) = pages::settings::java::State::screenshot_seed(&raw) {
self.settings.java = state; self.settings.java = state;
} }
return; return;
} }
// `ULL_JAVA_STATE=live`:不灌种子,继续往下走真路径(读本机配置 + 真扫盘)。
} }
let Some(config) = pages::settings::java::store::config_of(&self.bridge) else { let Some(config) = pages::settings::java::store::config_of(&self.bridge) else {
self.settings.java = pages::settings::java::State::default(); self.settings.java = pages::settings::java::State::default();
@@ -124,18 +122,19 @@ impl App {
if changed || prefs_changed || scan_changed { if changed || prefs_changed || scan_changed {
self.view_dirty = true; self.view_dirty = true;
} }
// 自动扫一次(首屏就该有真数据)。`cfg!(test)` 只关掉**这条生产入口的自动发起** // 自动扫一次(首屏就该有真数据)。**没有 `cfg!(test)` 分叉**:单测走的也是这条生产入口,
// (单测不该真去查注册表 / 跑 PATH 上的 java);判据与发起本身由 // 删掉这一行 / 把实参翻成 `true` ⇒ `tests/java.rs` 的
// [`App::auto_scan_java_if_due`] 承载,测试直接调它。 // `the_first_hydrate_starts_a_real_scan_by_itself` 立刻红(r3 复审 P3-3 的缺口)。
if !cfg!(test) { //
self.auto_scan_java_if_due(seeded && !live); // 实参为什么恒为 `false`:截图档的 `empty|two|editing` 在上面就 `return` 了,能走到这里的
} // 只有「不是截图」与「`live` 档」两种,而 `live` 按产品路径算(见本函数文档 D30 的例外)。
self.auto_scan_java_if_due(false);
} }
/// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。 /// 该自动扫就发起一次(判据在 [`pages::settings::java::store::auto_scan_due`])。
/// ///
/// 单独抽出来是为了可测:`hydrate_java_settings` 的自动发起被 `cfg!(test)` 关掉了, /// 抽出来是为了让「取消过就不再自动扫」(r2 复审 P2-2)有个窄的落点;而**首屏真的调了它**
/// 而「取消过就不再自动扫」这条语义必须有一条能红的用例(r2 复审 P2-2)。 /// 这件事由生产入口自己守(`tests/java.rs` 的那条首屏用例),不再靠 `cfg!(test)` 把调用关掉。
pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool { pub(in crate::app) fn auto_scan_java_if_due(&mut self, screenshot: bool) -> bool {
let report = self.settings.java.scan.report(); let report = self.settings.java.scan.report();
if pages::settings::java::store::auto_scan_due(&report, screenshot) { if pages::settings::java::store::auto_scan_due(&report, screenshot) {
@@ -305,7 +304,9 @@ impl App {
/// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**), /// `WriteError::Schema` 里装的是 `core::setting::StoreError` 的 `Display`(**中文**),
/// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志, /// 直出会把 `Not saved: IO 失败 @ …` 塞进英文界面(r2 复审 P3-3)——所以只进 stderr 日志,
/// 界面上给一句页面自己的「写不进去」。 /// 界面上给一句页面自己的「写不进去」。
fn write_notice(error: pages::settings::java::store::WriteError) -> Notice { ///
/// `pub(super)`:`tests/java.rs` 直接钉这一层的映射(M199 起页面侧已经撞不到 schema 那一档)。
pub(super) fn write_notice(error: pages::settings::java::store::WriteError) -> Notice {
use pages::settings::java::store::WriteError; use pages::settings::java::store::WriteError;
match error { match error {
WriteError::Refused(refusal) => Notice::Refused(refusal), WriteError::Refused(refusal) => Notice::Refused(refusal),
@@ -61,6 +61,43 @@ fn setting_the_default_jdk_keeps_unsaved_memory_and_jvm_drafts() {
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
} }
// --------------------------------------------------------------- r3 P3-3:首屏自动扫必须有落脚点
/// **首屏自动扫一次盘**这件事必须在测试里有落脚点(r3 复审 P3-3)。
///
/// 现场:`hydrate_java_settings` 末尾那次 `if !cfg!(test) { auto_scan_java_if_due(...) }` 整行删掉、
/// 或把实参翻成 `true`(截图抑制档),全套测试仍是绿的——于是「设置页从不自动检测本机 Java」
/// 这个**用户实测的头号抱怨**回来时没人拦。
///
/// 为什么这条不是「再把判据测一遍」:判据(`auto_scan_due` 的四条)与取消语义已经有用例;这里要
/// 证明的是**首屏这条路真的调了它**。所以走的是生产入口(`construct.rs` 构造时与心跳都调
/// `hydrate_java_settings`)与生产作业(真扫盘),没有 `cfg!(test)` 分叉、也没有注入假作业。
///
/// 判据为什么同步可判、且与线程快慢无关:`ScanSlot::start_with` 在起线程**之前**就把 `running`
/// 置真(同一把锁里);万一扫描线程已经跑完,`finished` 会留着。所以「真的起过一次扫描」= 这两个
/// 位里至少有一个(空槽两者都是假)——不用等线程,也不看这台机器扫到了什么。
///
/// 变异证明:①删掉 `self.auto_scan_java_if_due(false);` 这一行 ⇒ 两位仍是假 ⇒ 红;
/// ②把它改成 `self.auto_scan_java_if_due(true);` ⇒ 被 `auto_scan_due` 的 `!screenshot` 挡掉 ⇒ 红。
#[test]
fn the_first_hydrate_starts_a_real_scan_by_itself() {
let (mut app, path) = bridged_app("java-autoscan");
// 构造那一刻已经自动扫过一次(同一个入口);这里要重新演一遍「首屏」,所以换一个空槽。
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
app.hydrate_java_settings();
let report = app.settings.java.scan.report();
assert!(
report.running || report.finished,
"首屏 hydrate 没有自动发起扫描(快照是空的 {report:?}):设置页就不会自己检测本机 Java\
(r3 P3-3 的回归)"
);
// 收尾:取消这次真扫盘,别让它在后台继续探测本机 PATH 上的 java。
app.settings.java.scan.cancel();
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
}
// --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来 // --------------------------------------------------------------- r2 P2-2:取消之后不许自己重新起来
static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true); static HOLD: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(true);
@@ -92,6 +129,8 @@ fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
HOLD.store(false, Ordering::SeqCst); HOLD.store(false, Ordering::SeqCst);
let (mut app, path) = bridged_app("java-cancel"); let (mut app, path) = bridged_app("java-cancel");
// 构造时已经自动扫过一次(首屏那条生产入口,见上一条用例);换成空槽,让本用例自己起。
app.settings.java.scan = pages::settings::java::store::ScanSlot::default();
// 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成 // 真起一次扫描(注入卡住的作业),再取消,等线程退出:report 变成
// cancelled=true / finished=false —— 正是复审指认的那个形状。 // cancelled=true / finished=false —— 正是复审指认的那个形状。
@@ -111,14 +150,12 @@ fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
assert!(app.settings.java.cancelled, "状态行要留着「已取消」"); assert!(app.settings.java.cancelled, "状态行要留着「已取消」");
assert!(app.settings.java.scanning.is_none()); assert!(app.settings.java.scanning.is_none());
let started = app.auto_scan_java_if_due(false); // 心跳那条路:走**生产入口**(`clock.rs` 每发现配置文件变了就调 `hydrate_java_settings`),
assert!( // 它末尾那次自动发起必须被「取消过」这一条挡住。
!started, app.hydrate_java_settings();
"取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销)"
);
assert!( assert!(
!slot.report().running, !slot.report().running,
"不许起第二个扫描线程:{:?}", "取消过的扫描不许被自动重新拉起(P2-2:用户取消被无声撤销):{:?}",
slot.report() slot.report()
); );
app.hydrate_java_scan(); app.hydrate_java_scan();
@@ -143,22 +180,57 @@ fn a_cancelled_scan_is_not_started_again_by_a_config_reload() {
/// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是 /// 的 `Display` 是中文(`IO 失败 @ …` / `文件只读,拒绝保存: …`),原样渲染就是
/// `Not saved: IO 失败 @ …`。 /// `Not saved: IO 失败 @ …`。
/// ///
/// 变异证明:`write_notice` 里把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 本条红。 /// M199 起页面侧已经撞不到这一档了(`java.extra_args` 超长先被 `State::validate` 具名拒掉,见下一条
/// 用例),所以这里直接钉 `write_notice` 这一层的映射:`Schema` 只能变成页面自己的 key。
///
/// 变异证明:把 `Schema(detail)` 映射成 `Notice::Unwritable(NotDirty)` ⇒ 红;把它拼进
/// `Notice::Refused` 的 detail ⇒ 红(Debug 里会出现那句中文)。
#[test] #[test]
fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() { fn a_schema_rejection_surfaces_as_a_page_key_not_the_core_sentence() {
let (mut app, path) = bridged_app("java-schema"); use pages::settings::java::store::WriteError;
// `java.extra_args` 的 schema 是 `Text{max_len: 1024}`:本地校验(内存/引号)不管长度, let notice = super::super::java::write_notice(WriteError::Schema(
// 所以这一条会走到 `store.set` 的 schema 拒绝那一档。 "IO 失败 @ /tmp/launcher.json".to_string(),
));
assert_eq!(notice, pages::settings::java::Notice::WriteFailed);
let printed = format!("{notice:?}");
assert!(
!printed.contains("IO 失败"),
"core 的中文句子漏进了界面文案:{printed}"
);
}
/// 粘了 2000 字符的 JVM 参数:保存被**具名理由**拦下,而且文件一个字节都不动。
///
/// 现场(r3 复审 P3-4):`java.extra_args` 的 `max_len=1024` 只在 schema 那一档,页面侧不判长度
/// ⇒ 用户得到的是那句通用的「写不进去」,具体原因只在 stderr。本用例走的是 App 那条真保存路径。
///
/// 变异证明:删掉 `State::validate` 里的 `MAX_JVM_ARGS` 判据 ⇒ notice 变回
/// `Notice::WriteFailed`(schema 那一档),本条红。
#[test]
fn an_overlong_jvm_line_is_refused_with_its_own_reason() {
use pages::settings::java::Notice;
let (mut app, path) = bridged_app("java-jvm-long");
let too_long = "-D".to_string() + &"x".repeat(2000); let too_long = "-D".to_string() + &"x".repeat(2000);
let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long))); let _ = app.update(java(pages::settings::java::Message::EditJvm(too_long)));
let _ = app.update(java(pages::settings::java::Message::Save)); let _ = app.update(java(pages::settings::java::Message::Save));
let notice = app
.settings
.java
.notice
.clone()
.expect("保存必须留下一条回执");
let Notice::Refused(reason) = notice else {
panic!("超长参数要给自己的原因(「太长」),不是那句通用的「写不进去」:{notice:?}");
};
assert_eq!( assert_eq!(
app.settings.java.notice, reason.label(),
Some(pages::settings::java::Notice::WriteFailed), unlucky_i18n::Key::SettingsJavaReasonJvmTooLong,
"schema 拒绝要走页面的「写不进去」key,不能把 core 的中文句子原样上屏" "拒绝原因必须落到「太长」那条文案上(中英各一句,见 i18n 金表)"
); );
assert!(!path.exists(), "被本地校验拒掉的保存不许碰磁盘");
std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok(); std::fs::remove_dir_all(path.parent().unwrap().parent().unwrap()).ok();
} }
+58 -16
View File
@@ -230,8 +230,8 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
/// 正文由两半拼成:`mod.rs` 的 [`section_columns`](super::section_columns) **生产**「每组一件」的列, /// 正文由两半拼成:`mod.rs` 的 [`section_columns`](super::section_columns) **生产**「每组一件」的列,
/// `form` 只把它们堆起来再滚出去。于是: /// `form` 只把它们堆起来再滚出去。于是:
/// ///
/// - [`the_form_stops_at_its_last_group`] 守**收尾**:`form()` 里 `stack = stack.push(` 恰好一处、 /// - [`the_form_stacks_nothing_but_its_sections`] 守**收尾**:正文的唯一来源是 `section_columns`
/// 且以 `frame::scroll(ui, stack)` 收尾 —— 尾部多一件、或整段换成别的拼装(push 数变 0)都红; /// (`push(` 恰好一次、实参必须是循环变量、尾表达式是 `frame::scroll`)—— 多一件就红;
/// - [`the_form_holds_exactly_one_column_per_group`] 守**生产**:真调 `section_columns` 数它交出的 /// - [`the_form_holds_exactly_one_column_per_group`] 守**生产**:真调 `section_columns` 数它交出的
/// 列数(窄窗 == `GROUPS.len()`,单选各组 == 1)—— 多塞一件就红,**不管那件叫什么名字**。 /// 列数(窄窗 == `GROUPS.len()`,单选各组 == 1)—— 多塞一件就红,**不管那件叫什么名字**。
/// ///
@@ -244,9 +244,11 @@ fn setting_row<'a>(ui: &'a Ui, label: &'a str, value: &'a str) -> Element<'a, Me
/// ///
/// ## 抓得住 / 抓不住(据实写) /// ## 抓得住 / 抓不住(据实写)
/// ///
/// 抓得住(三种变异都实测过):①按原样把卡加回循环之后(`stack = stack.push(...)`)⇒ 收尾那条红; /// 抓得住(四种变异都实测过):①把卡折叠回循环
/// ②改成「塞进列清单」(`section_columns` 里多 push 一件)⇒ 列数那条红,换任何名字都躲不掉; /// (`stack = stack.push(section).push(frame::demo_card(…))`)⇒ 收尾那条数到 2 处 `push(`,红
/// ③把拼装整段重写成别的形状(不再用上面那个惯用法)⇒ 收尾那条数的 1 变成 0,同样红。 /// (与 `cargo fmt` 是否拆行无关);②换成别的来源(`stack = stack.push(frame::text_role(…))`)⇒
/// 实参不是 `section`,红;③塞进列清单(`section_columns` 里多 push 一件)⇒ 列数那条红;
/// ④整段改写成 `extend` / `chain` / 尾表达式再包一层 ⇒ 收尾那条红。
/// 抓不住:**某一组自己的列里**换内容(比如某个分组真的需要自己的空态)。那不是本缺陷——本页允许 /// 抓不住:**某一组自己的列里**换内容(比如某个分组真的需要自己的空态)。那不是本缺陷——本页允许
/// 组内有空态,前提是它长在该组的列里、用本页语义的文案。`DevDocs/artifacts/m196/` 那组图是**当时** /// 组内有空态,前提是它长在该组的列里、用本页语义的文案。`DevDocs/artifacts/m196/` 那组图是**当时**
/// 的证据,不是将来的防线(静态图只能证明「现在没有」)。 /// 的证据,不是将来的防线(静态图只能证明「现在没有」)。
@@ -287,6 +289,15 @@ mod no_stray_empty_state {
Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES) Ui::with_platform(SchemeFlavor::Light, Platform::Windows, FAMILIES)
} }
/// `form()` 的函数体,**剥掉每行 `//` 之后的部分**:判据数的是代码,注释里的 `push(` 不算。
fn form_code() -> String {
form_body()
.lines()
.map(|line| line.split("//").next().unwrap_or_default())
.collect::<Vec<_>>()
.join("\n")
}
/// 演示 / harness 路径那份只读状态(与 `pages::page` 同源,不伪造第二份)。 /// 演示 / harness 路径那份只读状态(与 `pages::page` 同源,不伪造第二份)。
fn states() -> States<'static> { fn states() -> States<'static> {
States { States {
@@ -295,24 +306,55 @@ mod no_stray_empty_state {
} }
} }
/// 判据 ①(**收尾**):循环之后没有任何额外的 `push`,且函数以滚出去收尾。 /// 判据 ①(**收尾**):正文的柱子只有一个来源——`section_columns` 交出的那一列。
/// ///
/// 循环体里只有一件(一组一列),所以「`stack = stack.push(` 恰好一次」等于「尾部空着」; /// M195 r2 复审实测的盲区:原来数的是 `stack = stack.push(` 的出现**次数**,于是
/// 拼装被整段重写成别的形状时计数变 0,同样红。 /// `stack = stack.push(section).push(frame::demo_card(ui, &[]));` 一句折叠里塞第二件时计数仍是 1
/// (第二个 `.push(` 前面没有 `stack = `)⇒ 判据 ① 与 ② 同时绿。那是在数**写法**。现在按**角色**
/// 断言:循环头直接迭代 `section_columns`、`push(` 恰好一次且实参是那个循环变量、尾表达式恰好是
/// `frame::scroll(ui, stack)`、没有 `extend`/`chain`/`once`/`concat`/`append`。`push(` 按**次数**
/// 数(拆行不影响),注释里的不算(先剥掉每行 `//`)。变异证明见模块文档的「抓得住」。
#[test] #[test]
fn the_form_stops_at_its_last_group() { fn the_form_stacks_nothing_but_its_sections() {
let body = form_body(); let body = form_code();
let pushes = body.matches("stack = stack.push(").count();
assert!(
body.contains("for section in section_columns("),
"`form()` 的循环头必须直接迭代 `section_columns(...)`:正文节点的唯一来源就是它"
);
let pushes = body.matches("push(").count();
assert_eq!( assert_eq!(
pushes, 1, pushes, 1,
"`form()` 里出现了 {pushes} 处 `stack = stack.push(`:分组循环里只该有一处。\ "`form()` 里出现了 {pushes} 处 `push(`:分组循环里只该有一处。多出来的那一处就是\
尾部又被叠了一件东西的话,设置页每一组下面都会多出同一件(M196 那张\ 「每组下面多一件」(M196 那张卡)——同一句里折叠成 `.push(a).push(b)` 也算两处"
「没有逐实例覆盖」的卡就是这么来的)"
); );
let at = body.find("push(").expect("上面刚数过一次") + "push(".len();
let argument = body[at..].split(')').next().unwrap_or_default().trim();
assert_eq!(
argument, "section",
"被压进正文的必须是 `section_columns` 交出的那一列(循环变量 `section`),实际是 \
`{argument}`:别的来源(演示卡 / 空态 / 任何控件)都不许进正文"
);
for append in ["extend(", "chain(", "once(", "concat(", "append("] {
assert!( assert!(
body.contains(" frame::scroll(ui, stack)\n}"), !body.contains(append),
"`form()` 用了 `{append}`:正文只许从 `section_columns` 逐列 push 进来"
);
}
let tail: Vec<&str> = body
.lines()
.map(str::trim)
.filter(|line| !line.is_empty())
.collect();
assert_eq!(
tail.iter().rev().take(2).copied().collect::<Vec<_>>(),
vec!["}", "frame::scroll(ui, stack)"],
"`form()` 必须以 `frame::scroll(ui, stack)` 收尾(后面紧跟函数右花括号):\ "`form()` 必须以 `frame::scroll(ui, stack)` 收尾(后面紧跟函数右花括号):\
正文到最后一组就结束,别再往后叠东西" 正文到最后一组就结束,别再包一层或往后叠东西"
); );
} }
@@ -125,7 +125,11 @@ pub(super) fn jvm_field<'a>(
.label(catalog.text(Key::SettingsJavaJvm)) .label(catalog.text(Key::SettingsJavaJvm))
.placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder)) .placeholder(catalog.text(Key::SettingsJavaJvmPlaceholder))
.supporting(catalog.text(Key::SettingsJavaJvmHint)); .supporting(catalog.text(Key::SettingsJavaJvmHint));
let field = if matches!(state.validate(), Err(Refusal::JvmQuotes)) { // 两种拒绝都长在这个框上:引号没配对、或者粘了超过 1024 字符的一整段(M199)。
let field = if matches!(
state.validate(),
Err(Refusal::JvmQuotes | Refusal::JvmTooLong)
) {
field.error() field.error()
} else { } else {
field field
@@ -39,6 +39,12 @@ pub const MAX_MEMORY_MB: u32 = 65536;
/// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源, /// `java.default_memory_mb` 的出厂默认值(MB):与 schema 的默认值同源,
/// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。 /// 一致性由 `tests::the_memory_bounds_mirror_the_schema` 守。
pub const DEFAULT_MEMORY_MB: u32 = 2048; pub const DEFAULT_MEMORY_MB: u32 = 2048;
/// `java.extra_args` 的长度上限(字符):**必须**等于 schema 登记的 `Text{max_len: 1024}`。
///
/// 为什么页面要自己再判一次(M199):schema 那一道是在 `store.set` 里拒的,用户得到的是
/// `Notice::WriteFailed`(「写不进去」)+ 一句带具体长度的中文只进 stderr——粘了一大段参数的
/// 人不知道是长度问题。一致性由 `tests::the_jvm_length_mirrors_the_schema` 守。
pub const MAX_JVM_ARGS: usize = 1024;
/// 扫描进度(`None` = 没在扫)。 /// 扫描进度(`None` = 没在扫)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -105,6 +111,8 @@ pub enum Refusal {
MemoryRange, MemoryRange,
/// JVM 参数里的引号没配成对 /// JVM 参数里的引号没配成对
JvmQuotes, JvmQuotes,
/// `java.extra_args` 超过 schema 登记的长度上限(1024)
JvmTooLong,
/// 手动路径为空 /// 手动路径为空
AddEmpty, AddEmpty,
/// 手动路径在盘上找不到 /// 手动路径在盘上找不到
@@ -132,6 +140,7 @@ impl Refusal {
Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber, Self::MemoryNotNumber => Key::SettingsJavaReasonMemoryNotNumber,
Self::MemoryRange => Key::SettingsJavaReasonMemoryRange, Self::MemoryRange => Key::SettingsJavaReasonMemoryRange,
Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes, Self::JvmQuotes => Key::SettingsJavaReasonJvmQuotes,
Self::JvmTooLong => Key::SettingsJavaReasonJvmTooLong,
Self::AddEmpty => Key::SettingsJavaReasonAddEmpty, Self::AddEmpty => Key::SettingsJavaReasonAddEmpty,
Self::AddMissing => Key::SettingsJavaReasonAddMissing, Self::AddMissing => Key::SettingsJavaReasonAddMissing,
Self::AddNotJava => Key::SettingsJavaReasonAddNotJava, Self::AddNotJava => Key::SettingsJavaReasonAddNotJava,
@@ -287,7 +296,8 @@ impl State {
/// 保存前的本地校验(过不了就不写盘)。 /// 保存前的本地校验(过不了就不写盘)。
/// ///
/// # Errors /// # Errors
/// 内存不是整数 / 越界,或 JVM 参数的引号没配成对时返回原因。 /// 内存不是整数 / 越界,JVM 参数引号没配成对,或 JVM 参数超过 schema 登记的长度上限时
/// 返回原因。
pub fn validate(&self) -> Result<u32, Refusal> { pub fn validate(&self) -> Result<u32, Refusal> {
let text = self.memory.trim(); let text = self.memory.trim();
if text.is_empty() { if text.is_empty() {
@@ -302,6 +312,10 @@ impl State {
if unbalanced_quotes(&self.jvm) { if unbalanced_quotes(&self.jvm) {
return Err(Refusal::JvmQuotes); return Err(Refusal::JvmQuotes);
} }
// 与 schema 同一个口径(`spec.rs` 的 `ValueKind::Text` 数 `chars()`,写盘前 `trim()`)。
if self.jvm.trim().chars().count() > MAX_JVM_ARGS {
return Err(Refusal::JvmTooLong);
}
Ok(mb) Ok(mb)
} }
@@ -18,7 +18,7 @@ mod cmdline;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use super::state::{Refusal, RuntimeRow, State, MAX_MEMORY_MB, MIN_MEMORY_MB}; use super::state::{Refusal, RuntimeRow, State, MAX_JVM_ARGS, MAX_MEMORY_MB, MIN_MEMORY_MB};
use super::store; use super::store;
/// 每个用例一个临时数据根(`<dir>/config/launcher.json` 的形状与真实数据根一致)。 /// 每个用例一个临时数据根(`<dir>/config/launcher.json` 的形状与真实数据根一致)。
@@ -238,6 +238,62 @@ fn the_memory_bounds_mirror_the_schema() {
assert_eq!(*min, i64::from(MIN_MEMORY_MB)); assert_eq!(*min, i64::from(MIN_MEMORY_MB));
assert_eq!(*max, i64::from(MAX_MEMORY_MB)); assert_eq!(*max, i64::from(MAX_MEMORY_MB));
} }
// --------------------------------------------------------------- 守卫⑤:JVM 参数超长被拒(M199)
/// 粘了一整段参数(>1024 字符)时界面给的是**具体**原因,而不是撞 schema 之后的「写不进去」;
/// 而且一个字节都不写盘。
///
/// 变异证明:删掉 `State::validate` 里那条 `MAX_JVM_ARGS` 判据 ⇒ 长度打回 schema 那一档
/// (`store::save` 返回 `WriteError::Schema`,用户只看到通用失败文案、具体原因只在 stderr),
/// 本用例红。这也是 r3 复审 P3-4 的现场。
#[test]
fn an_overlong_jvm_argument_line_is_refused_before_writing() {
let dir = root("jvm-long");
let file = config(&dir);
// 恰好等于上限:放行(判据是「超过」,不是「达到」)。
let mut state = State {
storage: true,
memory: super::state::DEFAULT_MEMORY_MB.to_string(),
jvm: "x".repeat(MAX_JVM_ARGS),
..State::default()
};
assert!(state.validate().is_ok(), "恰好 {MAX_JVM_ARGS} 字符应当放行");
state.jvm = "x".repeat(MAX_JVM_ARGS + 1);
assert_eq!(state.validate().err(), Some(Refusal::JvmTooLong));
assert_eq!(
Refusal::JvmTooLong.label(),
unlucky_i18n::Key::SettingsJavaReasonJvmTooLong,
"拒绝原因必须有自己的一句文案(不许退回通用的「写不进去」)"
);
let error = store::save(&file, &state).expect_err("超长参数应被本地校验拒掉");
assert_eq!(error, store::WriteError::Refused(Refusal::JvmTooLong));
assert!(!file.exists(), "被本地校验拒掉的保存不许碰磁盘");
std::fs::remove_dir_all(&dir).ok();
}
/// `MAX_JVM_ARGS` 必须等于 schema 登记的 `java.extra_args` 上限:本地放行的值不许被 schema 拒
/// (否则用户又只看到那句通用失败文案)。
///
/// 变异证明:把 `MAX_JVM_ARGS` 改成 2048 ⇒ 本用例红。
#[test]
fn the_jvm_length_mirrors_the_schema() {
use unlucky_core::setting::keys::launcher_schema;
use unlucky_core::setting::spec::ValueKind;
let schema = launcher_schema();
let kind = schema
.spec("java.extra_args")
.map(|spec| &spec.kind)
.expect("schema 里必须有 java.extra_args");
let ValueKind::Text { max_len } = kind else {
panic!("java.extra_args 应该是 Text:{kind:?}");
};
assert_eq!(*max_len, MAX_JVM_ARGS);
}
#[path = "tests/runtime.rs"] #[path = "tests/runtime.rs"]
mod runtime; mod runtime;
+145 -35
View File
@@ -11,6 +11,16 @@ pub const PROBE_TIMEOUT: Duration = Duration::from_secs(8);
/// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。 /// 轮询间隔(`try_wait` 的粒度):够细,取消能在一帧内生效;够粗,不烧 CPU。
const POLL_INTERVAL: Duration = Duration::from_millis(20); 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 掉。 /// 跑一个命令并等它结束,**带超时与可打断**:`cancelled()` 为真或超时就把子进程 kill 掉。
/// ///
/// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。 /// 返回 `None` = 没起来 / 超时 / 被取消(三者对调用方是同一件事:这次探测没有结论)。
@@ -36,15 +46,13 @@ pub fn run_with_timeout(
Ok(Some(_)) => break, Ok(Some(_)) => break,
Ok(None) => { Ok(None) => {
if cancelled() || started.elapsed() > timeout { if cancelled() || started.elapsed() > timeout {
let _ = child.kill(); terminate(&mut child);
let _ = child.wait();
return None; return None;
} }
std::thread::sleep(POLL_INTERVAL); std::thread::sleep(POLL_INTERVAL);
} }
Err(_) => { Err(_) => {
let _ = child.kill(); terminate(&mut child);
let _ = child.wait();
return None; return None;
} }
} }
@@ -54,6 +62,8 @@ pub fn run_with_timeout(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::time::Duration;
use super::run_with_timeout; use super::run_with_timeout;
#[test] #[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] #[test]
fn a_timed_out_child_is_really_killed() { 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!( let dir = std::env::temp_dir().join(format!(
"ull-proc-kill-{}-{}", "ull-proc-{tag}-{}-{}",
std::process::id(), std::process::id(),
std::time::SystemTime::now() std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@@ -113,45 +219,49 @@ mod tests {
.unwrap_or(0) .unwrap_or(0)
)); ));
std::fs::create_dir_all(&dir).expect("临时目录"); std::fs::create_dir_all(&dir).expect("临时目录");
let marker = dir.join("survived.txt"); dir
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` 秒之后写一个标记文件的命令(两个平台各一条,语义相同)。 /// 「先写启动标记(子进程 pid)、延时 `seconds` 秒之后再写存活标记」的命令(两平台语义相同)。
fn marker_command(marker: &std::path::Path, seconds: u32) -> std::process::Command { ///
let target = marker.display().to_string(); /// 启动标记里的 pid 同时证明两件事:**spawn 真的成功、进程真的跑起来了**(M199:`spawn().ok()?`
if cfg!(windows) { /// 起不来时也是 `None`,本用例因此会空跑),以及「这个 pid 后来被回收了吗」。
// PowerShell 而不是 `cmd /C "…"`:Rust 会把整个参数加一层双引号,cmd 再把里面的 ///
// 引号吃掉,重定向目标就被截断(实测命令立刻失败 ⇒ 本用例假红)。这里路径用 /// 两个标记的路径**走环境变量**:`cmd /C "…"` 会被 Rust 与 cmd 各加一层引号、把重定向目标截断
// PowerShell 的单引号,外层只剩 Rust 加的那一层。 /// (实测命令立刻失败 ⇒ 假红);`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"); let mut command = std::process::Command::new("powershell");
command.args([ command.args([
"-NoProfile", "-NoProfile",
"-Command", "-Command",
&format!( &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 command
} else { } else {
let mut command = std::process::Command::new("sh"); 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 command
}
} }
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。 /// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。
+1
View File
@@ -132,6 +132,7 @@ pub(super) const ROWS: &[(&str, &str, &str)] = &[
("SettingsJavaReasonMemoryNotNumber", "the memory must be a whole number of MB", "内存必须是整数 MB"), ("SettingsJavaReasonMemoryNotNumber", "the memory must be a whole number of MB", "内存必须是整数 MB"),
("SettingsJavaReasonMemoryRange", "the memory is outside the allowed range", "内存超出允许范围"), ("SettingsJavaReasonMemoryRange", "the memory is outside the allowed range", "内存超出允许范围"),
("SettingsJavaReasonJvmQuotes", "the JVM arguments have an unclosed quote", "JVM 参数里有没配对的引号"), ("SettingsJavaReasonJvmQuotes", "the JVM arguments have an unclosed quote", "JVM 参数里有没配对的引号"),
("SettingsJavaReasonJvmTooLong", "the JVM arguments are longer than the settings file allows (1024 characters)", "JVM 参数超过配置文件允许的长度(1024 字符)"),
("SettingsJavaReasonAddEmpty", "the path is empty", "路径是空的"), ("SettingsJavaReasonAddEmpty", "the path is empty", "路径是空的"),
("SettingsJavaReasonAddMissing", "nothing exists at that path", "那个路径上什么都没有"), ("SettingsJavaReasonAddMissing", "nothing exists at that path", "那个路径上什么都没有"),
("SettingsJavaReasonAddNotJava", "there is no java executable at that path", "那个路径里没有 java 可执行文件"), ("SettingsJavaReasonAddNotJava", "there is no java executable at that path", "那个路径里没有 java 可执行文件"),
+4
View File
@@ -125,6 +125,10 @@ macro_rules! settings_java_entries {
"the JVM arguments have an unclosed quote", "the JVM arguments have an unclosed quote",
"JVM 参数里有没配对的引号" "JVM 参数里有没配对的引号"
), ),
SettingsJavaReasonJvmTooLong => (
"the JVM arguments are longer than the settings file allows (1024 characters)",
"JVM 参数超过配置文件允许的长度(1024 字符)"
),
SettingsJavaReasonAddEmpty => ("the path is empty", "路径是空的"), SettingsJavaReasonAddEmpty => ("the path is empty", "路径是空的"),
SettingsJavaReasonAddMissing => ("nothing exists at that path", "那个路径上什么都没有"), SettingsJavaReasonAddMissing => ("nothing exists at that path", "那个路径上什么都没有"),
SettingsJavaReasonAddNotJava => ( SettingsJavaReasonAddNotJava => (