fix(installer): 安装器不再"撒谎"——按 Rust 给出的成败语义返回非零
P0-6。此前 pmon_install.dart 遍历 install_commands 时,非零只打印一行就继续;
循环结束后**无条件**打印「安装完成」并 return 0。于是 launchctl bootstrap /
systemctl --user enable --now 全部失败,安装器照样宣布成功。
根因在 :137 那行注释:"bootstrap 已加载时 bootout 会返回非零,属预期,不算失败"。
作者为了容忍 bootout 的预期非零,把 bootstrap 的非零也一并容忍了。
危害不是文字不对,而是**进程退出码失去意义**:远程部署脚本、CI 真机构建、
用户自己写的 supervisor 包装,全都只能拿到 0。叠加 P0-1 的 GUI 一键安装,
就是"点一下永远显示成功"——用户信了绿勾就不再自查。假成功比失败更糟。
修法(关键约束):
- Rust 的 AutoStartPlan 里,install_commands / uninstall_commands 从裸
Vec<String> 改为 Vec<AutostartCommand>{cmd, allow_failure}。
语义随命令一起产出,**不在 Dart 侧按命令文本猜**——contains("bootout")
这类白名单等于把同一份语义写在第二处,本项目已因此翻车五次。
- 语义分配:幂等前置动作与增强项 allow_failure=true
(bootout 未加载时必然非零、loginctl enable-linger 受限环境可能无权限);
对外承诺 allow_failure=false(bootstrap、systemctl enable --now、
reg add、schtasks /Create)。卸载侧一律容忍"删本来不存在的项"。
- Dart 侧:install / uninstall / restart 三处只要有必成失败即 return 非零;
--no-start 未指定时轮询最多 3 秒等进程起来,没起来也算失败(原先只打印 ⚠)。
- restart 里 `id -u` 从平台判断之前挪到之后——Windows 上没有 id 命令,
原顺序会先抛 ProcessException 而不是给出明确提示。
为什么把执行逻辑抽到 lib/host/autostart_runner.dart:
bin/ 下的私有函数无法被 flutter test 覆盖,而"安装器会不会撒谎"恰恰最该被测住。
抽出后判定逻辑可注入假 runner,新增 autocstart_runner_test.dart 12 项,
含一条回归守卫:全部命令失败时 ok 必须为 false(即 2026-10-25 的缺陷形态)。
新增 Rust 不变量测试:每个桌面平台的 install_commands 至少一条必成,
否则安装失败无从判定(防后来把语义整体宽化改回去)。
验证:cargo test --release 60/0(+1);flutter test 463/0(+12);
dart analyze lib bin 无问题;run-all.sh 28 套件 27 通过/1 失败
(唯一红仍为隐私扫描的存量公网 IP)。
⚠️ 如实说明:CLI 层的退出码接线只经单测与代码审查覆盖,未做真实 install 验证
—— 那会写用户的 ~/Library/LaunchAgents 与真实配置,不宜在本机跑。
真机验证应在测试机或装完立即 uninstall。
This commit is contained in:
1 parent
ca5c8014f3
commit
cffdc50df2
5 files changed
+552
-57
No files matched your search
@@ -24,6 +24,7 @@ import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:pmon_client/host/app_config.dart';
|
||||
import 'package:pmon_client/host/autostart_runner.dart';
|
||||
import 'package:pmon_client/host/install_paths.dart';
|
||||
import 'package:pmon_client/host/pmon_core.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
@@ -129,28 +130,36 @@ Future<int> _install(Map<String, Object> opts, String label) async {
|
||||
}
|
||||
|
||||
// 4) 执行安装命令(先 bootout 再 bootstrap,避免重复注册报 already loaded)
|
||||
for (final c in (plan['install_commands'] as List?) ?? []) {
|
||||
final r = await Process.run('/bin/zsh', ['-c', '$c']);
|
||||
final msg = (r.stdout as String).trim();
|
||||
if (r.exitCode != 0) {
|
||||
final err = (r.stderr as String).trim();
|
||||
// bootstrap 已加载时 bootout 会返回非零,属预期,不算失败
|
||||
stdout.writeln(' · $c → exit ${r.exitCode} ${err.isEmpty ? msg : err}');
|
||||
} else {
|
||||
stdout.writeln(' ✓ $c${msg.isEmpty ? '' : ' → $msg'}');
|
||||
}
|
||||
}
|
||||
//
|
||||
// 成败语义由 Rust 的 AutoStartPlan 随命令给出(allow_failure),这里只照做。
|
||||
// 历史缺陷(2026-10-25 修):此处原先"非零只打印一行就继续",循环结束后
|
||||
// **无条件**打印「安装完成」并 return 0 —— 于是 bootstrap / systemctl enable
|
||||
// 全失败,安装器照样宣布成功。任何自动化(部署脚本、CI、GUI 的一键安装按钮)
|
||||
// 都无法据此判断成败。假成功比失败更糟:用户信了绿勾就不再自查。
|
||||
final outcome = await runAutostartCommands(
|
||||
(plan['install_commands'] as List?) ?? const [],
|
||||
);
|
||||
_printAutostartOutcome(outcome);
|
||||
var hardFailures = outcome.hardFailures;
|
||||
|
||||
if (opts['no-start'] != true) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 800));
|
||||
final pids = await _agentPids();
|
||||
final pids = await _waitForAgent(const Duration(seconds: 3));
|
||||
if (pids.isEmpty) {
|
||||
stdout.writeln(' ⚠ 未检测到采集端进程,请查看 status 与日志');
|
||||
hardFailures++;
|
||||
stderr.writeln(' ✗ 未检测到采集端进程(已等 3 秒)');
|
||||
} else {
|
||||
stdout.writeln(' ✓ 采集端已运行(pid ${pids.join(",")})');
|
||||
}
|
||||
}
|
||||
|
||||
if (hardFailures > 0) {
|
||||
stderr.writeln('\n安装失败:$hardFailures 项必成步骤未成功。');
|
||||
stderr.writeln('排查:pmon-install status');
|
||||
stderr.writeln('(macOS 上若为 launchd 域不可访问,属受限环境,'
|
||||
'需在自己终端重新执行 install)');
|
||||
return 1;
|
||||
}
|
||||
|
||||
stdout.writeln('\n安装完成。排查用:pmon-install status');
|
||||
stdout.writeln('提示:本项为「登录即起 + 崩溃自恢复」,系统重启后同样生效。');
|
||||
return 0;
|
||||
@@ -166,12 +175,20 @@ Future<int> _uninstall(String label) async {
|
||||
stderr.writeln('核心返回: ${plan['error']['reason']}');
|
||||
return 1;
|
||||
}
|
||||
for (final c in (plan['uninstall_commands'] as List?) ?? []) {
|
||||
final r = await Process.run('/bin/zsh', ['-c', '$c']);
|
||||
stdout.writeln(' ✓ $c → exit ${r.exitCode}');
|
||||
final outcome = await runAutostartCommands(
|
||||
(plan['uninstall_commands'] as List?) ?? const [],
|
||||
);
|
||||
_printAutostartOutcome(outcome);
|
||||
final hardFailures = outcome.hardFailures;
|
||||
final stopped = await _confirmStopped();
|
||||
if (hardFailures > 0 || !stopped) {
|
||||
stderr.writeln('\n卸载未完成。');
|
||||
if (hardFailures > 0) stderr.writeln(' · $hardFailures 项必成命令失败');
|
||||
if (!stopped) stderr.writeln(' · 采集端进程仍在运行(已等 5 秒)');
|
||||
stderr.writeln('排查:pmon-install status');
|
||||
return 1;
|
||||
}
|
||||
final ok = await _confirmStopped();
|
||||
stdout.writeln(ok ? ' ✓ 已停止并移除自启' : ' ⚠ 仍有进程存活,请手动检查');
|
||||
stdout.writeln(' ✓ 已停止并移除自启');
|
||||
stdout.writeln('(采集端文件与配置保留在 $installRoot,如不需要可自行删除)');
|
||||
return 0;
|
||||
}
|
||||
@@ -223,16 +240,26 @@ Future<int> _status(String label) async {
|
||||
/* ────────────────────────── restart ────────────────────────── */
|
||||
|
||||
Future<int> _restart(String label) async {
|
||||
final uid = (await Process.run('id', ['-u'])).stdout.toString().trim();
|
||||
// 注意顺序:先判平台再执行 `id` —— Windows 上没有 id 命令,
|
||||
// 原先把它放在平台判断之前,会先抛 ProcessException 而不是给出明确提示。
|
||||
if (!Platform.isMacOS) {
|
||||
stderr.writeln('restart 目前仅实现 macOS(launchctl kickstart)');
|
||||
return 1;
|
||||
}
|
||||
final uid = (await Process.run('id', ['-u'])).stdout.toString().trim();
|
||||
final r = await Process.run('/bin/zsh', ['-c', 'launchctl kickstart -k gui/$uid/$label 2>&1']);
|
||||
stdout.writeln(' kickstart → exit ${r.exitCode} ${(r.stdout as String).trim()}');
|
||||
await Future<void>.delayed(const Duration(seconds: 2));
|
||||
final pids = await _agentPids();
|
||||
stdout.writeln(' 采集端: ${pids.isEmpty ? "未运行" : "pid ${pids.join(",")}"}');
|
||||
if (r.exitCode != 0) {
|
||||
stderr.writeln(' ✗ kickstart 失败 → exit ${r.exitCode} '
|
||||
'${(r.stdout as String).trim()}');
|
||||
return 1;
|
||||
}
|
||||
stdout.writeln(' ✓ kickstart 已提交');
|
||||
final pids = await _waitForAgent(const Duration(seconds: 3));
|
||||
if (pids.isEmpty) {
|
||||
stderr.writeln(' ✗ 3 秒内未见到采集端进程');
|
||||
return 1;
|
||||
}
|
||||
stdout.writeln(' 采集端: pid ${pids.join(",")}');
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -354,6 +381,37 @@ Future<bool> _archMatches(String libPath) async {
|
||||
|
||||
/* ────────────────────────── 工具 ────────────────────────── */
|
||||
|
||||
/// 打印一组自启命令的执行情况。
|
||||
///
|
||||
/// 判定逻辑在 `host/autostart_runner.dart`(可被单测覆盖),这里只负责呈现。
|
||||
void _printAutostartOutcome(AutostartRunOutcome outcome) {
|
||||
for (final c in outcome.commands) {
|
||||
final text = c.spec.text;
|
||||
if (c.succeeded) {
|
||||
stdout.writeln(' ✓ $text${c.stdoutText.isEmpty ? '' : ' → ${c.stdoutText}'}');
|
||||
} else if (c.isToleratedFailure) {
|
||||
stdout.writeln(' · $text → exit ${c.exitCode}(可失败,不计错)'
|
||||
'${c.stderrText.isEmpty ? '' : ' ${c.stderrText}'}');
|
||||
} else {
|
||||
stdout.writeln(' ✗ $text → exit ${c.exitCode}'
|
||||
'${c.stderrText.isEmpty ? '' : ' ${c.stderrText}'}');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 轮询等待采集端进程出现,最多等 [timeout]。
|
||||
///
|
||||
/// 用 `Stopwatch` 而非 `DateTime.now()`:这里虽是短任务,但"时钟语义"
|
||||
/// 在本仓已经因墙钟踩过坑(NTP 回拨让重连挂死),不要再引入第二处口径。
|
||||
Future<List<String>> _waitForAgent(Duration timeout) async {
|
||||
final sw = Stopwatch()..start();
|
||||
while (true) {
|
||||
final pids = await _agentPids();
|
||||
if (pids.isNotEmpty || sw.elapsed >= timeout) return pids;
|
||||
await Future<void>.delayed(const Duration(milliseconds: 400));
|
||||
}
|
||||
}
|
||||
|
||||
/// 采集端进程 pid(排除本进程与 shell)
|
||||
Future<List<String>> _agentPids() async {
|
||||
final r = await Process.run('/bin/zsh', ['-c', 'pgrep -f "pmon-agent --collector" || true']);
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
/// 自启命令执行器 —— 把「成败语义」与「真的去跑命令」分开,便于单测。
|
||||
///
|
||||
/// 为什么单独成模块:`bin/pmon_install.dart` 会被 `dart compile exe` 编成
|
||||
/// 独立可执行文件,里面的私有函数无法被 `flutter test` 覆盖。而"安装器会不会
|
||||
/// 撒谎"恰恰是最该被测试守住的那件事 —— 2026-10-25 的缺陷正是它把所有命令的
|
||||
/// 非零返回都当预期、最后无条件宣布安装成功并 `return 0`。
|
||||
///
|
||||
/// 分工:
|
||||
/// · 本模块 = 纯执行 + 判定,不打印(打印归 CLI,便于测试断言)
|
||||
/// · `bin/pmon_install.dart` = 打印 + 决定进程退出码
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
/// 执行一条 shell 命令。默认走 `/bin/zsh -c`;测试注入假实现。
|
||||
typedef ShellRunner = Future<ProcessResult> Function(String command);
|
||||
|
||||
/// 默认执行器。
|
||||
///
|
||||
/// 注意:跑 shell 意味着这里假设了 POSIX 环境。`/bin/zsh` 在多数 Linux 发行版
|
||||
/// 上并不存在(默认是 bash),Windows 上更不存在 —— 这是多端支持的真实阻塞点,
|
||||
/// 修法是让 Rust 产出结构化 argv 而不经过 shell,届时只需替换本函数。
|
||||
Future<ProcessResult> runShell(String command) =>
|
||||
Process.run('/bin/zsh', ['-c', command]);
|
||||
|
||||
/// 一条自启命令,连同它的成败语义。
|
||||
///
|
||||
/// 语义**只有一个合法产地**:Rust 的 `AutoStartPlan`(见
|
||||
/// `rust-core/src/keepalive.rs` 的 `AutostartCommand`)。
|
||||
/// 调用方不要按命令文本猜"这条失败算不算错" —— `contains('bootout')`
|
||||
/// 之类的判断等于把同一份语义写在第二处,而"同一份清单写两处"在本项目
|
||||
/// 已经翻车过五次(指标字段、配对码契约、显示目录、单位换算、安装路径)。
|
||||
class AutostartCommandSpec {
|
||||
const AutostartCommandSpec(this.text, {required this.allowFailure});
|
||||
|
||||
/// 要执行的命令行
|
||||
final String text;
|
||||
|
||||
/// 返回非零时是否**不算**失败
|
||||
final bool allowFailure;
|
||||
|
||||
/// 解析 Rust `AutoStartPlan` 里的条目,兼容两种载荷形态:
|
||||
///
|
||||
/// · 新:`{"cmd": "...", "allow_failure": bool}`
|
||||
/// · 旧:裸字符串 —— 视为**必成**
|
||||
///
|
||||
/// 旧形态按必成处理是有意为之:宁可误报一次失败,也不能回到"命令全失败
|
||||
/// 却返回 0"的老路。旧产物是真实存在的情形 —— `cargo test` 只编译测试
|
||||
/// 二进制,**不更新 cdylib**,忘了跑 `cargo build --release` 就会拿到
|
||||
/// 旧的 libpmon_core.dylib。
|
||||
static AutostartCommandSpec parse(Object? item) {
|
||||
if (item is String) {
|
||||
return AutostartCommandSpec(item, allowFailure: false);
|
||||
}
|
||||
if (item is Map) {
|
||||
final cmd = item['cmd'];
|
||||
final allow = item['allow_failure'];
|
||||
return AutostartCommandSpec(
|
||||
cmd is String ? cmd : '',
|
||||
allowFailure: allow is bool ? allow : false,
|
||||
);
|
||||
}
|
||||
return const AutostartCommandSpec('', allowFailure: false);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条命令的执行结果
|
||||
class AutostartCommandOutcome {
|
||||
const AutostartCommandOutcome({
|
||||
required this.spec,
|
||||
required this.exitCode,
|
||||
required this.stdoutText,
|
||||
required this.stderrText,
|
||||
});
|
||||
|
||||
final AutostartCommandSpec spec;
|
||||
final int exitCode;
|
||||
final String stdoutText;
|
||||
final String stderrText;
|
||||
|
||||
bool get succeeded => exitCode == 0;
|
||||
|
||||
/// 是否应计入「安装失败」:非零 **且** 该命令不允许失败
|
||||
bool get isHardFailure => !succeeded && !spec.allowFailure;
|
||||
|
||||
/// 是否属于"预期内的失败"(幂等前置动作 / 增强项)
|
||||
bool get isToleratedFailure => !succeeded && spec.allowFailure;
|
||||
}
|
||||
|
||||
/// 一组命令的整体执行结果
|
||||
class AutostartRunOutcome {
|
||||
const AutostartRunOutcome(this.commands);
|
||||
|
||||
final List<AutostartCommandOutcome> commands;
|
||||
|
||||
/// 必成命令的失败条数 —— **为 0 才代表"该做到的都做到了"**
|
||||
///
|
||||
/// 调用方必须据此决定进程退出码,不能因为"打印得挺好看"就返回 0。
|
||||
int get hardFailures => commands.where((c) => c.isHardFailure).length;
|
||||
|
||||
bool get ok => hardFailures == 0;
|
||||
|
||||
List<AutostartCommandOutcome> get hardFailed =>
|
||||
commands.where((c) => c.isHardFailure).toList();
|
||||
}
|
||||
|
||||
/// 按计划逐条执行,返回结构化结果(**不做任何打印** —— 打印归 CLI)
|
||||
Future<AutostartRunOutcome> runAutostartCommands(
|
||||
List<dynamic> raw, {
|
||||
ShellRunner runner = runShell,
|
||||
}) async {
|
||||
final results = <AutostartCommandOutcome>[];
|
||||
for (final item in raw) {
|
||||
final spec = AutostartCommandSpec.parse(item);
|
||||
if (spec.text.isEmpty) continue;
|
||||
|
||||
final r = await runner(spec.text);
|
||||
results.add(
|
||||
AutostartCommandOutcome(
|
||||
spec: spec,
|
||||
exitCode: r.exitCode,
|
||||
stdoutText: r.stdout.toString().trim(),
|
||||
stderrText: r.stderr.toString().trim(),
|
||||
),
|
||||
);
|
||||
}
|
||||
return AutostartRunOutcome(results);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/// 自启命令执行器 —— 守住"安装器不会再撒谎"
|
||||
///
|
||||
/// ── 这个测试要防的是什么 ──────────────────────────────────────────
|
||||
/// 2026-10-25 体检发现 `bin/pmon_install.dart` 的成功判据是假的:
|
||||
/// 遍历 `install_commands` 时非零**只打印一行就继续**,循环结束后
|
||||
/// **无条件**打印「安装完成」并 `return 0`。于是
|
||||
/// `launchctl bootstrap` / `systemctl --user enable` 全部失败,
|
||||
/// 安装器照样宣布成功。
|
||||
///
|
||||
/// 危害不是"屏幕上的字不对",而是**任何自动化都无法判断安装成败**:
|
||||
/// 远程部署脚本、将来的 CI 真机构建、用户自己写的 supervisor 包装,
|
||||
/// 全部只能拿到退出码 0。再叠加 P0-1 的 GUI 一键安装按钮,就成了
|
||||
/// "点一下就永远显示成功"—— 用户信了绿勾就不再自查,问题被永久掩盖。
|
||||
/// 假成功比失败更糟。
|
||||
///
|
||||
/// 本测试锁的是判定逻辑本身:**有必成命令失败,就必须 hardFailures > 0**。
|
||||
library;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pmon_client/host/autostart_runner.dart';
|
||||
|
||||
/// 造一个假的 ProcessResult,避免真的去跑 shell
|
||||
ProcessResult _res(int code, {String out = '', String err = ''}) =>
|
||||
ProcessResult(0, code, out, err);
|
||||
|
||||
/// 按命令文本决定退出码的假执行器
|
||||
ShellRunner _runner(Map<String, int> codes) {
|
||||
return (command) async => _res(codes[command] ?? 0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
group('① 成败语义来自 Rust,不在这里猜', () {
|
||||
test('新形态:cmd + allow_failure 被逐字解析', () {
|
||||
final spec = AutostartCommandSpec.parse(
|
||||
{'cmd': 'launchctl bootstrap gui/501/x', 'allow_failure': false},
|
||||
);
|
||||
expect(spec.text, 'launchctl bootstrap gui/501/x');
|
||||
expect(spec.allowFailure, isFalse);
|
||||
});
|
||||
|
||||
test('allow_failure=true 被正确识别', () {
|
||||
final spec = AutostartCommandSpec.parse(
|
||||
{'cmd': 'launchctl bootout gui/501/x', 'allow_failure': true},
|
||||
);
|
||||
expect(spec.allowFailure, isTrue);
|
||||
});
|
||||
|
||||
test('旧形态(裸字符串)视为必成 —— 宁可误报失败,不可假装成功', () {
|
||||
final spec = AutostartCommandSpec.parse('launchctl bootstrap gui/501/x');
|
||||
expect(spec.text, 'launchctl bootstrap gui/501/x');
|
||||
expect(
|
||||
spec.allowFailure,
|
||||
isFalse,
|
||||
reason: '旧 libpmon_core.dylib(cargo test 不更新 cdylib)会返回裸字符串。'
|
||||
'此时按必成处理,最多多报一次失败;按可失败处理就会回到"全失败却成功"的老路。',
|
||||
);
|
||||
});
|
||||
|
||||
test('allow_failure 缺失时默认必成', () {
|
||||
final spec = AutostartCommandSpec.parse({'cmd': 'x'});
|
||||
expect(spec.allowFailure, isFalse);
|
||||
});
|
||||
|
||||
test('空文本条目被跳过(不让脏数据穿透成一次"成功的空操作")', () async {
|
||||
final outcome = await runAutostartCommands(
|
||||
[
|
||||
{'cmd': '', 'allow_failure': false},
|
||||
{'cmd': 'real', 'allow_failure': false},
|
||||
],
|
||||
runner: _runner({}),
|
||||
);
|
||||
expect(outcome.commands.length, 1);
|
||||
expect(outcome.commands.single.spec.text, 'real');
|
||||
});
|
||||
});
|
||||
|
||||
group('② 必成失败必须计入(这是缺陷的正面形态)', () {
|
||||
test('单条必成命令失败 → hardFailures=1 且 ok=false', () async {
|
||||
final outcome = await runAutostartCommands(
|
||||
[
|
||||
{'cmd': 'launchctl bootstrap gui/501/x', 'allow_failure': false},
|
||||
],
|
||||
runner: _runner({'launchctl bootstrap gui/501/x': 5}),
|
||||
);
|
||||
expect(outcome.hardFailures, 1);
|
||||
expect(outcome.ok, isFalse);
|
||||
expect(outcome.hardFailed.single.exitCode, 5);
|
||||
});
|
||||
|
||||
test('可失败命令非零 → 不计入失败(bootout 在未加载时必然非零)', () async {
|
||||
final outcome = await runAutostartCommands(
|
||||
[
|
||||
{'cmd': 'launchctl bootout gui/501/x', 'allow_failure': true},
|
||||
{'cmd': 'launchctl bootstrap gui/501/x', 'allow_failure': false},
|
||||
],
|
||||
runner: _runner({
|
||||
'launchctl bootout gui/501/x': 3,
|
||||
'launchctl bootstrap gui/501/x': 0,
|
||||
}),
|
||||
);
|
||||
expect(outcome.hardFailures, 0);
|
||||
expect(outcome.ok, isTrue);
|
||||
expect(outcome.commands.first.isToleratedFailure, isTrue);
|
||||
});
|
||||
|
||||
test('混合场景只算必成的那些', () async {
|
||||
final outcome = await runAutostartCommands(
|
||||
[
|
||||
{'cmd': 'a', 'allow_failure': true}, // 失败,容忍
|
||||
{'cmd': 'b', 'allow_failure': false}, // 失败,计入
|
||||
{'cmd': 'c', 'allow_failure': true}, // 失败,容忍
|
||||
{'cmd': 'd', 'allow_failure': false}, // 成功
|
||||
],
|
||||
runner: _runner({'a': 1, 'b': 1, 'c': 1, 'd': 0}),
|
||||
);
|
||||
expect(outcome.hardFailures, 1);
|
||||
expect(outcome.hardFailed.single.spec.text, 'b');
|
||||
});
|
||||
|
||||
test('旧形态字符串失败同样计入(不能因为格式旧就放行)', () async {
|
||||
final outcome = await runAutostartCommands(
|
||||
['launchctl bootstrap gui/501/x'],
|
||||
runner: _runner({'launchctl bootstrap gui/501/x': 1}),
|
||||
);
|
||||
expect(outcome.hardFailures, 1);
|
||||
});
|
||||
});
|
||||
|
||||
group('③ 回归守卫:安装器不得再"撒谎"', () {
|
||||
test('全部命令都失败时 ok 必须为 false(2026-10-25 缺陷的完整形态)',
|
||||
() async {
|
||||
// 复刻当时真实发生的情形:macOS 上 bootout 与 bootstrap 双双非零
|
||||
final outcome = await runAutostartCommands(
|
||||
[
|
||||
{'cmd': 'launchctl bootout gui/501/x', 'allow_failure': true},
|
||||
{'cmd': 'launchctl bootstrap gui/501/x', 'allow_failure': false},
|
||||
],
|
||||
runner: _runner({
|
||||
'launchctl bootout gui/501/x': 3,
|
||||
'launchctl bootstrap gui/501/x': 5,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
outcome.ok,
|
||||
isFalse,
|
||||
reason: '修复前这里会返回 0 —— 安装器宣布"安装完成",而自启根本没注册上',
|
||||
);
|
||||
});
|
||||
|
||||
test('Linux 上 systemctl enable 失败必须被识别', () async {
|
||||
final outcome = await runAutostartCommands(
|
||||
[
|
||||
{'cmd': 'systemctl --user daemon-reload', 'allow_failure': false},
|
||||
{'cmd': 'systemctl --user enable --now x.service', 'allow_failure': false},
|
||||
{'cmd': 'loginctl enable-linger u', 'allow_failure': true},
|
||||
],
|
||||
runner: _runner({
|
||||
'systemctl --user daemon-reload': 0,
|
||||
'systemctl --user enable --now x.service': 1,
|
||||
'loginctl enable-linger u': 1,
|
||||
}),
|
||||
);
|
||||
expect(outcome.hardFailures, 1);
|
||||
expect(outcome.hardFailed.single.spec.text, contains('enable --now'));
|
||||
});
|
||||
|
||||
test('命令执行输出被保留,便于失败时给出原因', () async {
|
||||
final outcome = await runAutostartCommands(
|
||||
[
|
||||
{'cmd': 'systemctl --user enable --now x.service', 'allow_failure': false},
|
||||
],
|
||||
runner: (cmd) async => _res(1, err: 'Failed to connect to bus'),
|
||||
);
|
||||
expect(outcome.hardFailed.single.stderrText, contains('Failed to connect'));
|
||||
});
|
||||
});
|
||||
}
|
||||
+159
-31
@@ -148,6 +148,48 @@ pub struct PlannedFile {
|
||||
pub executable: bool,
|
||||
}
|
||||
|
||||
/// 一条自启注册/卸载命令,**连同它的成败语义**。
|
||||
///
|
||||
/// 为什么 `allow_failure` 必须由这里给出、而不是让执行方去猜:
|
||||
/// 宿主(Dart)若按命令文本判断"这条失败算不算错",就得维护一份
|
||||
/// `contains("bootout")` 之类的白名单 —— 那是同一份语义写在第二处,
|
||||
/// 本项目已因此在指标字段、配对码契约、显示目录、单位换算、安装路径上
|
||||
/// 反复翻车。语义只有一个合法产地:产出计划的地方。
|
||||
///
|
||||
/// 历史缺陷(2026-10-25 修):两个命令数组原来都是裸 `Vec<String>`,
|
||||
/// 执行方无法区分"bootout 本来就会失败"与"bootstrap 失败意味着没注册上",
|
||||
/// 于是把所有非零都当预期、最后无条件宣布安装成功并返回 0。
|
||||
#[derive(Debug, Clone, PartialEq, Serialize)]
|
||||
pub struct AutostartCommand {
|
||||
/// 要交给 shell 执行的命令行
|
||||
pub cmd: String,
|
||||
/// 该命令返回非零时,是否**不算**安装失败
|
||||
///
|
||||
/// - `true`:幂等性前置动作(如 `bootout` 在"本来没加载"时必然非零),
|
||||
/// 或增强项(如 `enable-linger` 在受限环境可能无权限)。失败只记日志。
|
||||
/// - `false`:承诺的一部分。失败即表示"没做到",
|
||||
/// 调用方必须据此返回非零,不得对外宣称成功。
|
||||
pub allow_failure: bool,
|
||||
}
|
||||
|
||||
impl AutostartCommand {
|
||||
/// 必成命令:失败即安装失败
|
||||
pub fn must(cmd: impl Into<String>) -> Self {
|
||||
Self {
|
||||
cmd: cmd.into(),
|
||||
allow_failure: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 容错命令:失败可接受(幂等前置动作 / 增强项)
|
||||
pub fn may_fail(cmd: impl Into<String>) -> Self {
|
||||
Self {
|
||||
cmd: cmd.into(),
|
||||
allow_failure: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 保活契约错误
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum KeepAliveError {
|
||||
@@ -174,9 +216,9 @@ pub struct AutoStartPlan {
|
||||
/// 需写入的文件
|
||||
pub files: Vec<PlannedFile>,
|
||||
/// 安装命令(写入文件后执行)
|
||||
pub install_commands: Vec<String>,
|
||||
pub install_commands: Vec<AutostartCommand>,
|
||||
/// 卸载命令
|
||||
pub uninstall_commands: Vec<String>,
|
||||
pub uninstall_commands: Vec<AutostartCommand>,
|
||||
/// 构建期声明(Android/iOS 需写进工程清单,运行期无法补)
|
||||
pub build_manifest: Option<String>,
|
||||
/// 运行期需引导用户操作的说明
|
||||
@@ -210,13 +252,27 @@ pub fn plan_autostart(platform: Platform, exe_path: &str, label: &str) -> Result
|
||||
executable: false,
|
||||
}],
|
||||
install_commands: vec![
|
||||
// 先 bootout 再 bootstrap:重复注册时旧 job 未被卸载会导致 bootstrap 报 already loaded
|
||||
format!("launchctl bootout gui/$(id -u)/{} 2>/dev/null || true", label),
|
||||
format!("launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/{}.plist", label),
|
||||
// 先 bootout 再 bootstrap:重复注册时旧 job 未被卸载会导致 bootstrap 报 already loaded。
|
||||
// bootout 在「本来就没加载」时必然失败,属幂等前置动作,不算安装失败。
|
||||
AutostartCommand::may_fail(format!(
|
||||
"launchctl bootout gui/$(id -u)/{} 2>/dev/null || true",
|
||||
label
|
||||
)),
|
||||
// bootstrap 才是真正把自启注册上的那一步 —— 它失败就是没注册,必须报出来。
|
||||
AutostartCommand::must(format!(
|
||||
"launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/{}.plist",
|
||||
label
|
||||
)),
|
||||
],
|
||||
uninstall_commands: vec![
|
||||
format!("launchctl bootout gui/$(id -u)/{} 2>/dev/null || true", label),
|
||||
format!("rm -f ~/Library/LaunchAgents/{}.plist", label),
|
||||
AutostartCommand::may_fail(format!(
|
||||
"launchctl bootout gui/$(id -u)/{} 2>/dev/null || true",
|
||||
label
|
||||
)),
|
||||
AutostartCommand::must(format!(
|
||||
"rm -f ~/Library/LaunchAgents/{}.plist",
|
||||
label
|
||||
)),
|
||||
],
|
||||
build_manifest: None,
|
||||
runtime_guidance: vec![
|
||||
@@ -239,16 +295,23 @@ pub fn plan_autostart(platform: Platform, exe_path: &str, label: &str) -> Result
|
||||
executable: false,
|
||||
}],
|
||||
install_commands: vec![
|
||||
"systemctl --user daemon-reload".into(),
|
||||
format!("systemctl --user enable --now {}.service", label),
|
||||
// 无图形会话时用户级 systemd 默认在登出后停止服务,
|
||||
// 开启 linger 才能做到"未登录也在采集"
|
||||
format!("loginctl enable-linger $USER # 使 {}.service 在未登录时也常驻", label),
|
||||
AutostartCommand::must("systemctl --user daemon-reload"),
|
||||
// 这一步才是真正 enable 上 —— 失败就是没自启。
|
||||
AutostartCommand::must(format!("systemctl --user enable --now {}.service", label)),
|
||||
// linger 属「未登录也常驻」的增强项,受限环境可能没有权限,失败可接受。
|
||||
AutostartCommand::may_fail(format!(
|
||||
"loginctl enable-linger $USER # 使 {}.service 在未登录时也常驻",
|
||||
label
|
||||
)),
|
||||
],
|
||||
uninstall_commands: vec![
|
||||
format!("systemctl --user disable --now {}.service", label),
|
||||
format!("rm -f ~/.config/systemd/user/{}.service", label),
|
||||
"systemctl --user daemon-reload".into(),
|
||||
// 服务本来就不存在时 disable 会返回非零 —— 目的已达成,不算失败。
|
||||
AutostartCommand::may_fail(format!(
|
||||
"systemctl --user disable --now {}.service",
|
||||
label
|
||||
)),
|
||||
AutostartCommand::must(format!("rm -f ~/.config/systemd/user/{}.service", label)),
|
||||
AutostartCommand::may_fail("systemctl --user daemon-reload"),
|
||||
],
|
||||
build_manifest: None,
|
||||
runtime_guidance: vec![
|
||||
@@ -267,20 +330,27 @@ pub fn plan_autostart(platform: Platform, exe_path: &str, label: &str) -> Result
|
||||
// 注册表方式无需落盘文件,此处留空
|
||||
files: vec![],
|
||||
install_commands: vec![
|
||||
format!(
|
||||
AutostartCommand::must(format!(
|
||||
"reg add \"{}\" /v {} /t REG_SZ /d \"{}\" /f",
|
||||
run_key, label, quoted.replace('"', "\\\"")
|
||||
),
|
||||
run_key,
|
||||
label,
|
||||
quoted.replace('"', "\\\"")
|
||||
)),
|
||||
// 注册表 Run 项不提供崩溃重启能力,故另建一个计划任务由系统兜底:
|
||||
// 注册表项负责"登录即起",计划任务负责"异常退出后重起"。
|
||||
format!(
|
||||
// 两者都是对用户承诺的一部分,任一失败都算没装好。
|
||||
AutostartCommand::must(format!(
|
||||
"schtasks /Create /TN \"{}\\Guardian\" /TR \"'{}' --guardian\" /SC ONLOGON /RL LIMITED /F",
|
||||
label, exe_path
|
||||
),
|
||||
)),
|
||||
],
|
||||
uninstall_commands: vec![
|
||||
format!("reg delete \"{}\" /v {} /f", run_key, label),
|
||||
format!("schtasks /Delete /TN \"{}\\Guardian\" /F", label),
|
||||
// 删一个本来就不存在的项会返回非零 —— 目的已达成,不算失败。
|
||||
AutostartCommand::may_fail(format!("reg delete \"{}\" /v {} /f", run_key, label)),
|
||||
AutostartCommand::may_fail(format!(
|
||||
"schtasks /Delete /TN \"{}\\Guardian\" /F",
|
||||
label
|
||||
)),
|
||||
],
|
||||
build_manifest: None,
|
||||
runtime_guidance: vec![
|
||||
@@ -710,9 +780,19 @@ mod tests {
|
||||
"若验收期限放宽,可重新评估 ThrottleInterval"
|
||||
);
|
||||
// 安装命令必须先 bootout 再 bootstrap,否则重复注册会 already loaded
|
||||
assert!(plan.install_commands[0].starts_with("launchctl bootout"));
|
||||
assert!(plan.install_commands[1].starts_with("launchctl bootstrap"));
|
||||
assert!(plan.uninstall_commands.iter().any(|c| c.contains("rm -f")));
|
||||
assert!(plan.install_commands[0].cmd.starts_with("launchctl bootout"));
|
||||
assert!(plan.install_commands[1].cmd.starts_with("launchctl bootstrap"));
|
||||
assert!(plan.uninstall_commands.iter().any(|c| c.cmd.contains("rm -f")));
|
||||
// 成败语义:bootout 是幂等前置(未加载时必然非零),bootstrap 是承诺(必成)。
|
||||
// 一旦把 bootstrap 标成可失败,安装器就又能"命令全失败却宣布成功"了。
|
||||
assert!(
|
||||
plan.install_commands[0].allow_failure,
|
||||
"bootout 在本来未加载时必然返回非零,属预期"
|
||||
);
|
||||
assert!(
|
||||
!plan.install_commands[1].allow_failure,
|
||||
"bootstrap 失败就等于自启没注册上,必须让调用方报错"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -728,7 +808,24 @@ mod tests {
|
||||
assert!(c.contains("MemoryMax=5M"));
|
||||
assert!(plan.files[0].path.contains(".config/systemd/user/pmon-collector.service"));
|
||||
// 用户级服务默认登出即停,必须有 linger 提示
|
||||
assert!(plan.install_commands.iter().any(|c| c.contains("enable-linger")));
|
||||
assert!(plan.install_commands.iter().any(|c| c.cmd.contains("enable-linger")));
|
||||
// linger 属增强项(受限环境可能没权限),可失败;
|
||||
// 但 daemon-reload 与 enable --now 是承诺,失败即没自启。
|
||||
let linger = plan
|
||||
.install_commands
|
||||
.iter()
|
||||
.find(|c| c.cmd.contains("enable-linger"))
|
||||
.expect("必须有 linger 命令");
|
||||
assert!(linger.allow_failure, "linger 失败不该让整个安装算失败");
|
||||
let enable = plan
|
||||
.install_commands
|
||||
.iter()
|
||||
.find(|c| c.cmd.contains("enable --now"))
|
||||
.expect("必须有 enable --now");
|
||||
assert!(
|
||||
!enable.allow_failure,
|
||||
"systemctl enable --now 失败就等于没自启,必须报错"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -736,12 +833,43 @@ mod tests {
|
||||
let plan = plan_autostart(Platform::Windows, "C:\\Program Files\\墨观\\pmon.exe", "").unwrap();
|
||||
// 注册表方式无需落盘文件
|
||||
assert!(plan.files.is_empty());
|
||||
assert!(plan.install_commands[0].contains("reg add"));
|
||||
assert!(plan.install_commands[0].contains("CurrentVersion\\Run"));
|
||||
assert!(plan.install_commands[0].cmd.contains("reg add"));
|
||||
assert!(plan.install_commands[0].cmd.contains("CurrentVersion\\Run"));
|
||||
// 注册表项不带崩溃重启能力,需计划任务兜底
|
||||
assert!(plan.install_commands.iter().any(|c| c.contains("schtasks /Create")));
|
||||
assert!(plan.uninstall_commands.iter().any(|c| c.contains("reg delete")));
|
||||
assert!(plan.uninstall_commands.iter().any(|c| c.contains("schtasks /Delete")));
|
||||
assert!(plan.install_commands.iter().any(|c| c.cmd.contains("schtasks /Create")));
|
||||
assert!(plan.uninstall_commands.iter().any(|c| c.cmd.contains("reg delete")));
|
||||
assert!(plan.uninstall_commands.iter().any(|c| c.cmd.contains("schtasks /Delete")));
|
||||
// 安装两条都是对外承诺;卸载两条都允许失败(删不存在的项会非零,目的已达成)。
|
||||
assert!(
|
||||
plan.install_commands.iter().all(|c| !c.allow_failure),
|
||||
"注册表项与计划任务任一失败都算没装好"
|
||||
);
|
||||
assert!(
|
||||
plan.uninstall_commands.iter().all(|c| c.allow_failure),
|
||||
"卸载幂等:删本来就不存在的项不该算失败"
|
||||
);
|
||||
}
|
||||
|
||||
/// 不变量:每个桌面平台的安装命令里,至少有一条是"必成"的。
|
||||
///
|
||||
/// 若某个平台把安装命令全标成 `allow_failure`,安装器就永远无法判定失败 ——
|
||||
/// 那正是 2026-10-25 修掉的那个缺陷的形状(命令全失败仍宣布安装成功、
|
||||
/// 退出码 0)。这条测试守的是"修复不会被后来的宽化改回去"。
|
||||
#[test]
|
||||
fn every_platform_has_at_least_one_must_succeed_install_command() {
|
||||
for p in [Platform::MacOS, Platform::Linux, Platform::Windows] {
|
||||
let plan = plan_autostart(p, "/x/pmon", "pmon-collector").unwrap();
|
||||
assert!(
|
||||
!plan.install_commands.is_empty(),
|
||||
"{:?} 应有安装命令",
|
||||
p
|
||||
);
|
||||
assert!(
|
||||
plan.install_commands.iter().any(|c| !c.allow_failure),
|
||||
"{:?} 的安装命令必须至少有一条必成,否则安装失败无从判定",
|
||||
p
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -187,11 +187,13 @@ fn section_keepalive() {
|
||||
}
|
||||
println!(" 安装命令:");
|
||||
for c in &plan.install_commands {
|
||||
println!(" $ {}", c);
|
||||
let tag = if c.allow_failure { "(可失败)" } else { "(必成)" };
|
||||
println!(" $ {}{}", c.cmd, tag);
|
||||
}
|
||||
println!(" 卸载命令:");
|
||||
for c in &plan.uninstall_commands {
|
||||
println!(" $ {}", c);
|
||||
let tag = if c.allow_failure { "(可失败)" } else { "(必成)" };
|
||||
println!(" $ {}{}", c.cmd, tag);
|
||||
}
|
||||
}
|
||||
Err(e) => println!(" 本平台无自启方案: {}", e.reason()),
|
||||
|
||||
Reference in new issue
Block a user