fix(常驻采集): 停本界面采集改为「只认在跑的正面证据」,避免 Windows 上双双停摆

## 改判:`r.ok` 不足以作为"已在跑"的判据

`install` 退出码 0 只承诺"**已注册**",三个平台语义不同(Rust `keepalive.rs` 事实):

| 平台 | 安装命令 | 装完立即在跑? |
|---|---|---|
| macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 |
| Linux | `systemctl --user enable --now` | 是(`--now`)|
| Windows | `reg add HKCU\...\Run` + `schtasks /SC ONLOGON` | **否,只在下次登录时起** |

旧逻辑"拿到 0 就停 GUI 采集"在 Windows 上是**必然**事故(不是概率):
停掉 GUI 采集 + 常驻要等下次登录 → 两个都没在采,而卡片还很平静。
这比双写更糟 —— 双写至少还有数据。

## 新判据

```dart
bool get _hasRunningEvidence =>
    _st.state == ResidentState.running && _st.probeAvailable != false;
```

- 有证据(`running` 且探针没说"我没查")→ 停 GUI 采集(防双写)
- 无证据 → **不停**,并在 `_note` 里说清:
  · 探针不可用(Windows 现状)→「已注册;本平台需重新登录或手动「开始」才会运行。本界面继续采集,不会断数据。」
  · 探针可用但没跑起来 →「安装已完成,但采集进程没有起来(原因见上)。本界面继续采集,不会断数据。」
- `probeAvailable == null`(老 CLI 无此字段)视同**有证据**:那种二进制报的
  `running` 是 `pgrep` 真查出来的,不能因为"没这个元数据"就否认证据

## 为什么这规则不放过双写

双写只可能发生在探针可用的平台(macOS/Linux)——那些平台恰好拿得到正面证据,
于是会停。Windows 拿不到证据,但它**压根不会有两个进程同时在跑**(安装命令里
没有 start 这一步)。**双写风险与"探针不可用"的交集为空**,故既不放过双写,
也不误停采集。上一轮的 A/B 是伪两难:把"所有平台"当成了一回事。

## 顺带:`_run` 的时序随之调整

以前是"先停 → 再刷新"(停不用等状态)。现在**必须先刷新再决定**,因为停的判据
就是刷新回来的状态。代价是:证据存在时,停采集比旧实现晚一次 CLI 往返
(新版 `build/pmon-install` 约 50ms);换来的是"没证据不乱停"。

## 验证

- `flutter test test/resident_card_test.dart` → **17/0**
- `dart analyze lib` → No issues found
- 变异自证(两次,均已恢复):
  · `_hasRunningEvidence` 短路成 `r.ok`(退回旧逻辑)→ 「探针不可用 → 不停」「探针可用但没起来 → 不停」两条 ★ 报红
  · `probeAvailable != false` 改成 `== true`(把"缺字段"当没证据)→ 「拿到在跑证据 → 停采集」报红
This commit is contained in:
PMon Dev committed 2026-10-27 23:54:46 +08:00
1 parent 3af5c592ff
commit 4e3b4739d4
2 files changed
+99 -49

No files matched your search

+51 -27
View File
@@ -112,39 +112,63 @@ class _ResidentCardState extends State<ResidentCard> {
}
});
if (r.ok && startsResident) {
// 常驻起来后必须停掉 GUI 自己的采集(见 onResidentStarted 注释)
widget.onResidentStarted?.call();
// 必须**先**把"装完之后"的真实状态取回来,再决定要不要停本界面采集 ——
// 因为停的判据是"有正面证据证明它在跑",不是"installer 返回了 0"。
await _refresh();
if (!mounted) return;
_applyStartOutcome(r);
} else {
await _refresh();
}
await _refresh();
// 「已经停止本界面采集」这件事必须说出口 —— 见 _explainStopIfUnverified
if (r.ok && startsResident && mounted) _explainStopIfUnverified(r);
}
/// 停掉本界面采集之后,如果**没拿到"它在跑"的证据**,必须把这件事说出来。
/// 是否拿到了"采集端**确实在跑**"的正面证据。
///
/// ── 为什么不能默默停 ────────────────────────────────────────────
/// 停采集的判据是"installer 退出码 0",而 installer 现在把「探针查不了」
/// 如实降级为 ⚠ 而不是失败(这是对的:不能把平台限制说成缺陷)。两者相乘的
/// 结果可能是:**本界面已停采、而常驻进程是否起来没人知道** —— 用户看到的是
/// 一张平静的卡片,实际上什么都没在采。
/// ⚠️ 判据不是 `r.ok`:installer 退出码 0 只承诺"**已注册**",而"已注册"
/// 与"已在跑"在 Windows 上根本不是一回事 —— `reg add HKCU\...\Run` 是
/// 登录时启动、`schtasks /SC ONLOGON` 也是登录时触发,**整个 Windows 分支
/// 没有任何一条"现在启动进程"的命令**(对比 Linux 的 `systemctl --user
/// enable --now`,那个 `--now` 才等于"现在就起")。
///
/// ── 为什么仍然选择"停",而不是"等确认了再停" ──────────────────
/// 两个代价不对等:
/// · 停早了 = 缺数据(用户能看见、能补救:手动点开始采集);
/// · 停晚了 = **双写** —— 两条 seq 流混进同一个 device_id,服务端的会话
/// 去重分不清它们,历史曲线会出现无法解释的毛刺,**而且不报错**。
/// 故"宁可少采也不污染数据",但必须让用户知道这是我们的选择,而不是静默。
void _explainStopIfUnverified(ResidentActionResult r) {
/// `probeAvailable == false` 表示 CLI 明说"我没查" —— 那不是证据,是空白。
/// `null`(老 CLI 没这个字段)不算"查不了":那种二进制报的 `running`
/// 就是从 `pgrep` 真查出来的,照旧采信(见 `ResidentStatus.fromJson` 的三态说明)。
bool get _hasRunningEvidence {
final st = _st;
if (st.state != ResidentState.unknown) return;
final detail = st.detail;
final why = (detail == null || detail.isEmpty) ? '拿不到采集进程状态' : detail;
setState(() {
_note = '${r.message.isEmpty ? '' : '${r.message}\n'}'
'已提交安装;$why。'
'为避免与它重复采集同一台设备,已停止本界面的采集 —— 宁可少采也不污染数据。'
'请用系统工具确认后再决定是否手动开始。';
});
if (st.state != ResidentState.running) return false;
return st.probeAvailable != false;
}
/// 安装动作之后:**有证据才停**本界面采集;没证据就不停,并把原因说清楚。
///
/// ── 为什么"没证据就不停"是对的(而不是"宁可少采")──────────────
/// 双写的风险**只存在于探针可用的平台**(macOS / Linux)—— 那些平台恰好
/// 能拿到正面证据,于是会停。Windows 拿不到证据,但它压根不可能有两个
/// 进程同时在跑(安装命令里没有 start 这一步),所以"不停"也不会双写。
/// **双写风险与"探针不可用"的交集为空**,故这条规则既不放过双写,也不误停。
///
/// 反过来(拿不到证据还照停)的代价在 Windows 上是**必然**发生:
/// 停掉 GUI 采集 + 常驻要等下次登录才起 → 两个都没在采,而卡片还很平静。
void _applyStartOutcome(ResidentActionResult r) {
if (_hasRunningEvidence) {
// 常驻确实在跑 → 停掉 GUI 采集,避免两条 seq 流混进同一个 device_id
// (服务端按会话去重分不清它们,历史曲线会出现无法解释的毛刺且不报错)
widget.onResidentStarted?.call();
return;
}
final prefix = r.message.isEmpty ? '' : '${r.message}\n';
final String why;
if (_st.state == ResidentState.unknown) {
// Windows 现状:装完了,但这个平台没有进程探针
why = '已注册;本平台需重新登录或手动「开始」才会运行。'
'本界面继续采集,不会断数据。';
} else {
// 探针可用但确实没跑起来
why = '安装已完成,但采集进程没有起来(原因见上)。'
'本界面继续采集,不会断数据。';
}
setState(() => _note = '$prefix$why');
}
@override
+48 -22
View File
@@ -224,16 +224,21 @@ void main() {
});
});
/// 「停了采集但没拿到证据」必须说出口。
/// 停本界面采集的判据是「**有正面证据证明它在跑**」,不是「installer 返回 0」。
///
/// installer 现在把"探针查不了"如实降级为 ⚠ 而不是失败(对的),于是
/// install 退出码 0 时**不必然意味着进程起来了**。而卡片一拿到 0 就停掉
/// 本界面采集 —— 于是可能出现"两个都没在采、卡片还很平静"。
/// installer 退出码 0 只承诺"**已注册**",而"已注册 ≠ 已在跑" 在 Windows 上
/// 是硬事实:`reg add HKCU\...\Run` 是登录时启动、`schtasks /SC ONLOGON`
/// 也是登录时触发,整个 Windows 分支**没有一条"现在启动进程"的命令**
/// (对比 Linux 的 `systemctl --user enable --now`,`--now` 才是"现在就起")。
///
/// 我们仍然选择"停"(双写是污染数据,漏采只是缺数据,两个代价不对等),
/// 但**必须让用户知道这是我们的选择**,把不确定性变成一句人话。
group('停采集之后:没有证据就要说出口', () {
testWidgets('★ 装成功但确认不了进程 → 明说"已停止本界面采集"与为什么', (tester) async {
/// 所以"拿到 0 就停"会在 Windows 上**必然**造成:停掉 GUI 采集 + 常驻要等
/// 下次登录 → 两个都没在采,而卡片还很平静。这比双写更糟:双写至少还有数据。
///
/// 而"没证据就不停"并不会放过双写:双写只可能发生在探针可用的平台
/// (macOS/Linux),那些平台恰好拿得到证据。**两个集合的交集为空。**
group('停采集:只认"在跑"的正面证据', () {
testWidgets('★ 探针不可用(Windows 现状)→ **不停**采集,并说清要重新登录',
(tester) async {
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled))
..afterInstall = const ResidentStatus(
state: ResidentState.unknown,
@@ -249,18 +254,39 @@ void main() {
await tester.pump(); // 动作完成
await tester.pump(); // 刷新状态
expect(stopped, 1, reason: '仍然选择停(避免双写),不是"等确认了再停"');
expect(stopped, 0,
reason: '拿不到证据就停,Windows 上必然变成"两个都没在采"');
final text = allText(tester);
expect(text, contains('已停止本界面的采集'),
reason: '停采集这件事必须说出口,否则用户看到一张平静的卡片却在漏采');
expect(text, contains('宁可少采也不污染数据'),
reason: '要说明这是权衡,不是疏漏 —— 双写会让历史曲线出现无法解释的毛刺');
expect(text, contains('无法判定采集端进程'),
reason: '要给出为什么确认不了,而不是笼统的"安装成功"');
expect(text, contains('需重新登录或手动「开始」才会运行'),
reason: '这是 Windows 的真实语义,用户必须知道,否则他以为已经在采了');
expect(text, contains('本界面继续采集'),
reason: '要明确告诉用户数据没断,否则他会去手动折腾');
expect(text, isNot(contains('已停止本界面的采集')));
});
testWidgets('装成功且拿到"在跑"证据 → 不出现这句解释(不制造多余焦虑)',
(tester) async {
testWidgets('★ 探针可用但进程没起来 → **不停**采集', (tester) async {
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled))
..afterInstall = const ResidentStatus(
state: ResidentState.installedNotRunning,
probeAvailable: true,
autostartRegistered: true,
agentExists: true,
logPath: '/tmp/com.pmon.collector.err.log',
);
var stopped = 0;
await mount(tester, ops, onStarted: () => stopped++);
await tester.tap(find.text('安装并启动'));
await tester.pump();
await tester.pump();
expect(stopped, 0, reason: 'agent 确实没跑,GUI 该继续采 —— 停了就是白白漏采');
final text = allText(tester);
expect(text, contains('采集进程没有起来'));
expect(text, contains('本界面继续采集'));
});
testWidgets('装成功且拿到"在跑"证据 → 停采集(防双写),且不多话', (tester) async {
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled));
var stopped = 0;
await mount(tester, ops, onStarted: () => stopped++);
@@ -269,13 +295,13 @@ void main() {
await tester.pump();
await tester.pump();
expect(stopped, 1);
expect(stopped, 1, reason: '有正面证据才停 —— 这是防双写唯一成立的场景');
final text = allText(tester);
expect(text, isNot(contains('已停止本界面的采集')));
expect(text, isNot(contains('宁可少采也不污染数据')));
expect(text, isNot(contains('需重新登录')));
expect(text, isNot(contains('本界面继续采集')));
});
testWidgets('安装失败 → 不停采集,也不出现这句解释', (tester) async {
testWidgets('安装失败 → 不停采集,也不出现解释', (tester) async {
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled))
..installResult = const ResidentActionResult(ok: false, message: '权限不足');
var stopped = 0;
@@ -286,7 +312,7 @@ void main() {
await tester.pump();
expect(stopped, 0);
expect(allText(tester), isNot(contains('已停止本界面的采集')));
expect(allText(tester), isNot(contains('本界面继续采集')));
});
});