fix(常驻采集): 停了本界面采集就必须说出口,别让用户在漏采时看到一张平静的卡

## 这个洞怎么来的

installer 这轮把「探针查不了」从**失败**改成**只 ⚠**(对的:不能把平台限制说成
缺陷)。而卡片的判据是"installer 退出码 0 → 停掉本界面采集"。两者相乘:

    探针不可用的平台(当前 Windows)
      → install 返回 0
      → 卡片停掉 GUI 采集
      → 而常驻进程是否起来**没人知道**(state=unknown)
      → 净效果:两个都没在采,卡片还很平静

## 为什么仍选择"停",而不是"等确认了再停"

两个代价不对等,且 team-lead 已核实**跨形态的锁是失效的**(GUI 在沙箱容器里查
`Containers/.../agent.lock`,agent 由 launchd 拉起锁真实家目录 —— 两个进程锁的是
两个不同文件,谁也不挡谁)。因此"等确认"并不能防双写:GUI 查容器那把锁永远是
空的,照常启动 → **双写是必然,不是可能**。

   · 停早了 = 缺数据(用户看得见、能补救:手动点开始采集)
   · 停晚了 = **双写**:两条 seq 流混进同一个 device_id,服务端按会话去重分不清
     它们,历史曲线出现无法解释的毛刺,**而且不报错**

故"宁可少采也不污染数据",但必须让用户知道这是我们的选择:

    「已提交安装;<为什么确认不了>。为避免与它重复采集同一台设备,已停止本界面
      的采集 —— 宁可少采也不污染数据。请用系统工具确认后再决定是否手动开始。」

## 实现

`_run` 里**先停、再刷新、再解释**(顺序不能反):停采集的时序一个字没动,
只是刷新之后多一步判断 —— `state == unknown` 才把这段话写进 `_note`。
拿到"在跑"证据时一个字都不加(不制造多余焦虑)。

## 验证

- `flutter test` → **559/0**(+3:装成功但确认不了 → 有解释;装成功且 running → 无解释;安装失败 → 不停也不解释)
- `dart analyze lib bin` → No issues found
- 变异自证(两次均恢复):
  · 删掉 `_explainStopIfUnverified` 的调用 → 「装成功但确认不了进程」报红,另一条仍绿
  · 去掉 `state != unknown` 的提前返回 → 「装成功且 running」报红(说明"不多话"这半边也被守住了)

## 未做(已单独立项,不在本笔)

锁的跨形态修复:GUI 侧改用进程探针(`pgrep -f <installBinDir>/pmon-agent`)判断
agent 在跑 —— agent 的绝对路径 GUI 能由 `app_paths` 推导,`pgrep` 是查进程不是读
文件,不需要跨沙箱访问权限;agent 侧继续用锁,只管"防两个 agent 实例"。
跨形态靠进程探测、同形态靠锁,各管一段。
This commit is contained in:
PMon Dev committed 2026-10-27 23:40:26 +08:00
1 parent d7147e67a7
commit 9db09bb923
2 files changed
+105 -6

No files matched your search

+29
View File
@@ -116,6 +116,35 @@ class _ResidentCardState extends State<ResidentCard> {
widget.onResidentStarted?.call();
}
await _refresh();
// 「已经停止本界面采集」这件事必须说出口 —— 见 _explainStopIfUnverified
if (r.ok && startsResident && mounted) _explainStopIfUnverified(r);
}
/// 停掉本界面采集之后,如果**没拿到"它在跑"的证据**,必须把这件事说出来。
///
/// ── 为什么不能默默停 ────────────────────────────────────────────
/// 停采集的判据是"installer 退出码 0",而 installer 现在把「探针查不了」
/// 如实降级为 ⚠ 而不是失败(这是对的:不能把平台限制说成缺陷)。两者相乘的
/// 结果可能是:**本界面已停采、而常驻进程是否起来没人知道** —— 用户看到的是
/// 一张平静的卡片,实际上什么都没在采。
///
/// ── 为什么仍然选择"停",而不是"等确认了再停" ──────────────────
/// 两个代价不对等:
/// · 停早了 = 缺数据(用户能看见、能补救:手动点开始采集);
/// · 停晚了 = **双写** —— 两条 seq 流混进同一个 device_id,服务端的会话
/// 去重分不清它们,历史曲线会出现无法解释的毛刺,**而且不报错**。
/// 故"宁可少采也不污染数据",但必须让用户知道这是我们的选择,而不是静默。
void _explainStopIfUnverified(ResidentActionResult r) {
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。'
'为避免与它重复采集同一台设备,已停止本界面的采集 —— 宁可少采也不污染数据。'
'请用系统工具确认后再决定是否手动开始。';
});
}
@override
+76 -6
View File
@@ -21,6 +21,9 @@ class _FakeOps implements ResidentOps {
ResidentStatus current;
ResidentActionResult installResult = ResidentActionResult.okNoOutput;
ResidentActionResult uninstallResult = ResidentActionResult.okNoOutput;
/// 安装成功之后的状态(默认"在跑")。用来造"装成功但确认不了进程"的现场。
ResidentStatus? afterInstall;
int installCalls = 0;
int uninstallCalls = 0;
@@ -35,12 +38,13 @@ class _FakeOps implements ResidentOps {
installCalls++;
if (installResult.ok) {
// 安装成功后状态自然变成"在跑"(模拟系统自启把它拉起来)
current = const ResidentStatus(
state: ResidentState.running,
pids: [4321],
autostartRegistered: true,
autostartLoaded: true,
);
current = afterInstall ??
const ResidentStatus(
state: ResidentState.running,
pids: [4321],
autostartRegistered: true,
autostartLoaded: true,
);
}
return installResult;
}
@@ -220,6 +224,72 @@ void main() {
});
});
/// 「停了采集但没拿到证据」必须说出口。
///
/// installer 现在把"探针查不了"如实降级为 ⚠ 而不是失败(对的),于是
/// install 退出码 0 时**不必然意味着进程起来了**。而卡片一拿到 0 就停掉
/// 本界面采集 —— 于是可能出现"两个都没在采、卡片还很平静"。
///
/// 我们仍然选择"停"(双写是污染数据,漏采只是缺数据,两个代价不对等),
/// 但**必须让用户知道这是我们的选择**,把不确定性变成一句人话。
group('停采集之后:没有证据就要说出口', () {
testWidgets('★ 装成功但确认不了进程 → 明说"已停止本界面采集"与为什么', (tester) async {
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled))
..afterInstall = const ResidentStatus(
state: ResidentState.unknown,
probeAvailable: false,
probeError: 'Windows 尚无进程探针(待多端扩面实现)',
detail: '本平台无法判定采集端进程(Windows 尚无进程探针(待多端扩面实现)),'
'这不等于"没在运行"',
);
var stopped = 0;
await mount(tester, ops, onStarted: () => stopped++);
await tester.tap(find.text('安装并启动'));
await tester.pump(); // 动作完成
await tester.pump(); // 刷新状态
expect(stopped, 1, reason: '仍然选择停(避免双写),不是"等确认了再停"');
final text = allText(tester);
expect(text, contains('已停止本界面的采集'),
reason: '停采集这件事必须说出口,否则用户看到一张平静的卡片却在漏采');
expect(text, contains('宁可少采也不污染数据'),
reason: '要说明这是权衡,不是疏漏 —— 双写会让历史曲线出现无法解释的毛刺');
expect(text, contains('无法判定采集端进程'),
reason: '要给出为什么确认不了,而不是笼统的"安装成功"');
});
testWidgets('装成功且拿到"在跑"证据 → 不出现这句解释(不制造多余焦虑)',
(tester) async {
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled));
var stopped = 0;
await mount(tester, ops, onStarted: () => stopped++);
await tester.tap(find.text('安装并启动'));
await tester.pump();
await tester.pump();
expect(stopped, 1);
final text = allText(tester);
expect(text, isNot(contains('已停止本界面的采集')));
expect(text, isNot(contains('宁可少采也不污染数据')));
});
testWidgets('安装失败 → 不停采集,也不出现这句解释', (tester) async {
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled))
..installResult = const ResidentActionResult(ok: false, message: '权限不足');
var stopped = 0;
await mount(tester, ops, onStarted: () => stopped++);
await tester.tap(find.text('安装并启动'));
await tester.pump();
await tester.pump();
expect(stopped, 0);
expect(allText(tester), isNot(contains('已停止本界面的采集')));
});
});
/// 「查不了」不是「出事了」—— 探针不可用时卡片只能说"不知道",
/// 且必须是中性灰那一档(不是橙色告警)。这类错的可恶之处在于:
/// 它看起来**很确定**,用户会照着它去重装一个已经装好的东西。