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
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:
commit
1f2d89c23e
9 files changed
+385
-81
No files matched your search
@@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)` 也算两处"
|
||||||
「没有逐实例覆盖」的卡就是这么来的)"
|
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
body.contains(" frame::scroll(ui, stack)\n}"),
|
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!(
|
||||||
|
!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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
};
|
||||||
|
command
|
||||||
|
.env("ULL_PROC_STARTED", started)
|
||||||
|
.env("ULL_PROC_SURVIVED", survived);
|
||||||
|
command
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。
|
/// 一个必然超时的命令(两个平台都有,且在「等很久」这件事上行为一致)。
|
||||||
|
|||||||
@@ -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 可执行文件"),
|
||||||
|
|||||||
@@ -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 => (
|
||||||
|
|||||||
Reference in new issue
Block a user