From 4e3b4739d4455e6a691ebb867ccc44d24f06b502 Mon Sep 17 00:00:00 2001 From: PMon Dev Date: Tue, 27 Oct 2026 23:54:46 +0800 Subject: [PATCH] =?UTF-8?q?fix(=E5=B8=B8=E9=A9=BB=E9=87=87=E9=9B=86):=20?= =?UTF-8?q?=E5=81=9C=E6=9C=AC=E7=95=8C=E9=9D=A2=E9=87=87=E9=9B=86=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=E3=80=8C=E5=8F=AA=E8=AE=A4=E5=9C=A8=E8=B7=91=E7=9A=84?= =?UTF-8?q?=E6=AD=A3=E9=9D=A2=E8=AF=81=E6=8D=AE=E3=80=8D=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=20Windows=20=E4=B8=8A=E5=8F=8C=E5=8F=8C=E5=81=9C?= =?UTF-8?q?=E6=91=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 改判:`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`(把"缺字段"当没证据)→ 「拿到在跑证据 → 停采集」报红 --- client/lib/ui/resident_card.dart | 78 +++++++++++++++++++---------- client/test/resident_card_test.dart | 70 ++++++++++++++++++-------- 2 files changed, 99 insertions(+), 49 deletions(-) diff --git a/client/lib/ui/resident_card.dart b/client/lib/ui/resident_card.dart index b71155a..6d802bd 100644 --- a/client/lib/ui/resident_card.dart +++ b/client/lib/ui/resident_card.dart @@ -112,39 +112,63 @@ class _ResidentCardState extends State { } }); 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 diff --git a/client/test/resident_card_test.dart b/client/test/resident_card_test.dart index 95b585e..be6b066 100644 --- a/client/test/resident_card_test.dart +++ b/client/test/resident_card_test.dart @@ -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('本界面继续采集'))); }); });