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:
PMon Dev committed 2026-10-26 00:10:54 +08:00
1 parent ca5c8014f3
commit cffdc50df2
5 files changed
+552 -57

No files matched your search

+82 -24
View File
@@ -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']);
+128
View File
@@ -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);
}
+179
View File
@@ -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
View File
@@ -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]
+4 -2
View File
@@ -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()),