fix(常驻采集): 探针不可用只报「不知道」,并把采集端实际读到的配置摆出来

## 一、探针不可用 ≠ 没在跑(把上一轮糊回去的洞补上)

installer 在 ccb6c3f 里给 `process` 加了 `probe_available/probe_error`,把
「查不了」与「没在跑」分开了(人话输出已经用上);但 JSON 消费方没跟上:
`ResidentStatus.fromJson` 只读 `running`/`pids`,于是 Windows(无 pgrep)上
`running=false + registered=true` → 卡片一本正经显示 **"已安装但没起来"(橙色告警)**,
而真相是"一次都没查过"。这比"没有卡片"糟得多:它看起来**很确定**,用户会
照着它去重装一个已经装好的东西。

- `fromJson` 读 `probe_available`,**只有明确 false 且没观测到进程**才降级为
  `ResidentState.unknown`(中性灰)+ detail 写明原因与"这不等于没在运行"
- `probe_available` 做成**三态**(`bool?`):缺字段(老二进制/老快照)→ 不看该判据。
  若把"缺字段"当 false,所有老机器都会被判成"未知"——那是用另一种方式骗人
- 正面观测优先:`running=true`/有 pid 时仍判 running(探针元数据不压过证据)

## 二、采集端实际读到的配置:诚实标注,不做同步

macOS GUI 是真沙箱(容器内 `ConfigStore`),常驻采集端由 launchd 拉起、读真实
家目录 —— 磁盘上**两份 config 同时活着**:界面里改网关地址只改容器那份,采集端
看到的还是旧的,两边都不报错。三条同步路本轮都不通(沙箱无权写容器外 / 沙箱子
进程继承沙箱 / 让 agent 读容器会给主路径加上"必须装过 GUI"的依赖),故只做标注:

- 卡片显示「采集端当前读到:<server_url> · <device_id>」(来自 `status --json`)
- 与界面输入不一致时明说不一致,并给出下一步(点一次「重新安装并启动」)
- 配置面板加一句:上面两项只对「界面内采集」生效,常驻采集读的是安装器那份
- 未安装/问不到时不显示这一行(没有"采集端读到的配置"这回事,不能凭空造)

## 三、`log.path` 的漂移种子写进注释

`/tmp/<label>.err.log` 在 Rust 的 `macos_launchagent_plist` 与 Dart 的
`statusSnapshot()` 各写一份,今天"逐字相同"只是碰巧。已在 `logPath` 的文档里
写明正解:让 `AutoStartPlan` 返回 `stderr_log_path`,Dart 只读不算。

## 四、顺带补上 label 守卫(等 ccb6c3f 落地后才敢提)

`⑥` 组:字面量只许出现在 `lib/host/autostart_label.dart`,两个消费者必须引用
`kDefaultAutostartLabel`。

## 验证

- `flutter test` → **556/0**(新增 7 条:接缝 6 + 卡片 5,另含 label 守卫 2)
- `dart analyze lib bin` → No issues found
- 变异自证:
  · 把 `fromJson` 里 `probeAvailable == false` 分支短路 → `⑦` 两条 ★ 报红(其余仍绿),恢复即绿
  · 把卡片的 `if (mismatch)` 短路 → 「与界面输入不一致」报红,恢复即绿
This commit is contained in:
PMon Dev committed 2026-10-27 23:37:21 +08:00
1 parent 24e79b06a6
commit d7147e67a7
5 files changed
+374 -3

No files matched your search

+58
View File
@@ -60,6 +60,10 @@ class ResidentStatus {
this.logExists = false,
this.agentPath = '',
this.agentExists = false,
this.probeAvailable,
this.probeError = '',
this.serverUrl = '',
this.deviceId = '',
this.detail,
});
@@ -78,6 +82,17 @@ class ResidentStatus {
final bool configExists;
final String configPath;
/// 采集端错误日志路径(launchd 的 `StandardErrorPath`)。
///
/// ⚠️ **这条事实目前在 Rust 与 Dart 各写了一份**:`rust-core/src/keepalive.rs`
/// 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成 `/tmp/<label>.err.log`,
/// `bin/pmon_install.dart` 的 `statusSnapshot()` 又自己拼一遍。今天两边**逐字
/// 相同**,但"两边碰巧一样"正是漂移的前兆 —— 改一处,status 就指向一个不存在
/// 的日志文件,而两边都不会报错。
///
/// 正解(未做,要动 Rust 结构 + FFI 出口):让 `AutoStartPlan` 多返回
/// `stderr_log_path`,Dart 侧**只读不算** —— 即改为读
/// `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`**。
final String logPath;
final bool logExists;
@@ -85,6 +100,27 @@ class ResidentStatus {
final String agentPath;
final bool agentExists;
/// 采集端进程探针**是否可用**(`process.probe_available`)。
///
/// 三态而非布尔:`null` 表示这个 CLI 版本压根没报这个字段(老二进制/老快照)。
/// 只有明确 `false` 才允许把结论降级为 [ResidentState.unknown] ——
/// 老 CLI 没这个字段时若按"不可用"处理,会把所有老机器的正常状态打成"未知",
/// 那就是用另一种方式骗人。
final bool? probeAvailable;
/// 探针不可用的原因(`process.probe_error`),只在 [probeAvailable] 为 false 时有意义
final String probeError;
/// 采集端**实际读到的**网关地址(`config.server_url`,即安装器那份配置)
///
/// 为什么不让界面拿自己那份配置显示:macOS GUI 是**真沙箱**,它读写的
/// `ConfigStore` 在应用容器里;而常驻采集端由 launchd 拉起、读真实家目录。
/// 两份都活着、各说各话 —— 显示"采集端实际读到什么"才是真的。
final String serverUrl;
/// 采集端**实际读到的**设备标识(`config.device_id`)
final String deviceId;
/// 诊断说明(未知态时说明为什么问不出来)
final String? detail;
@@ -111,14 +147,31 @@ class ResidentStatus {
.whereType<int>()
.toList();
// `probe_available` 是三态:缺字段(老 CLI)→ null,不看这个判据。
final rawProbe = process['probe_available'];
final probeAvailable = rawProbe is bool ? rawProbe : null;
final probeError = (process['probe_error'] as String?) ?? '';
final running = process['running'] == true || pids.isNotEmpty;
final registered = autostart['registered'] == true;
final loaded = autostart['loaded'] == true;
final agentExists = installed['agent_exists'] == true;
final ResidentState state;
// 探针不可用 ≠ 没在跑
String? resolutionDetail;
if (running) {
// 正面观察到的"在跑"永远优先:探针能给出 pid,就说明它至少是能用的
state = ResidentState.running;
} else if (probeAvailable == false) {
// ★「查不了」不是「没在跑」:本平台没有探针(Windows 暂无)时,
// `running` 只能是 false —— 若照旧往下判,会得出"已安装但没起来"这个
// **看起来很确定、实际没查过**的结论,卡片还会染成告警色。
// 本项目的规矩是"不知道"优于"错的答案",故降级为 unknown(中性灰)。
state = ResidentState.unknown;
resolutionDetail = '本平台无法判定采集端进程'
'(${probeError.isEmpty ? '缺少可用的进程探针' : probeError}),'
'这不等于"没在运行"';
} else if (registered || agentExists) {
state = ResidentState.installedNotRunning;
} else {
@@ -137,6 +190,11 @@ class ResidentStatus {
logExists: log['exists'] == true,
agentPath: (installed['agent_path'] as String?) ?? '',
agentExists: agentExists,
probeAvailable: probeAvailable,
probeError: probeError,
serverUrl: (config['server_url'] as String?) ?? '',
deviceId: (config['device_id'] as String?) ?? '',
detail: resolutionDetail,
);
}
+16
View File
@@ -15,6 +15,7 @@ import 'package:flutter/scheduler.dart';
import 'host/agent_lock.dart';
import 'host/app_config.dart';
import 'host/app_paths.dart';
import 'host/boot_report.dart';
import 'host/device_id.dart';
import 'host/link.dart';
@@ -818,6 +819,21 @@ class _ControlPanelPageState extends State<ControlPanelPage>
),
),
const SizedBox(height: 18),
// ── 沙箱导致的"两份配置"必须在这里就说清(诚实标注,非修好)──
// macOS GUI 是真沙箱:它自己读写的配置在应用容器里;而「常驻采集」
// 是 launchd 拉起的非沙箱进程,读真实家目录那份。用户在下面两项里
// 改地址,只有界面内采集会跟着变,常驻采集不会 —— 不说清的话,
// 症状是"改了网关地址、后台那台还在往旧地址发",两边都不报错。
if (currentPlatform != PmonPlatform.android) ...[
const Text(
'上面两项只对「界面内采集」生效。常驻采集是另一个进程,'
'读的是安装器那份配置 —— 装好后在「常驻采集」卡里能看到它'
'实际读到的网关地址与设备标识。',
style: TextStyle(
fontSize: 11.5, color: PmonColors.textSecondary),
),
const SizedBox(height: 18),
],
SwitchListTile(
value: _autoStart,
onChanged: (v) {
+66
View File
@@ -126,6 +126,7 @@ class _ResidentCardState extends State<ResidentCard> {
ResidentState.notInstalled => (PmonColors.offline, '未安装'),
ResidentState.unknown => (PmonColors.offline, '状态未知'),
};
final effectiveConfig = _effectiveConfigNote();
return Card(
child: Padding(
@@ -166,6 +167,10 @@ class _ResidentCardState extends State<ResidentCard> {
),
const SizedBox(height: PmonSpace.md),
_statusLine(),
if (effectiveConfig != null) ...[
const SizedBox(height: PmonSpace.sm),
effectiveConfig,
],
if (_error != null) ...[
const SizedBox(height: PmonSpace.sm),
_errorBox(_error!),
@@ -213,6 +218,67 @@ class _ResidentCardState extends State<ResidentCard> {
);
}
/// 「采集端**实际读到**的配置」—— 诚实标注,不是"修好"
///
/// ── 为什么必须显示它(而不是显示界面自己那份)────────────────────
/// macOS GUI 是**真沙箱**(`com.apple.security.app-sandbox`,`HOME` 指向
/// `~/Library/Containers/com.pmon.pmonClient/Data`),它读写的 `ConfigStore`
/// 在应用容器里;而常驻采集端由 launchd 拉起(**非沙箱**),读真实家目录。
/// 于是磁盘上**两份 config 同时活着**:用户在界面里改网关地址,只改了容器那份,
/// 采集端看到的还是旧的 —— 而两边都不会报错。这比"读不到配置"更隐蔽。
///
/// ── 为什么本轮不做同步 ──────────────────────────────────────────
/// 三条路都不通:①安装器双写 —— 沙箱 GUI 无权写容器外;②GUI 调 CLI 去写 ——
/// 沙箱的**子进程继承沙箱**,一样写不了;③让 agent 改读容器 —— 没装过 GUI 的
/// 机器根本没有容器,给主路径加了一条"必须装过 GUI"的依赖。
/// 故本版只做**诚实标注**:把采集端真正读到的东西摆出来,不一致就明说。
/// 真解要等 `files.user-selected.read-write` 或非沙箱 helper。
Widget? _effectiveConfigNote() {
final st = _st;
// 未安装 / 问不到:没有"采集端读到的配置"这回事,别凭空造一行出来
if (st.state == ResidentState.notInstalled ||
st.state == ResidentState.unknown) {
return null;
}
final effUrl = st.serverUrl;
final effId = st.deviceId;
if (effUrl.isEmpty && effId.isEmpty) {
return Text(
'采集端还没读到配置(${st.configPath.isEmpty ? '路径未知' : st.configPath})。',
style: PmonText.caption,
);
}
final wantUrl = widget.serverUrl;
final wantId = widget.deviceId;
// 只比"两边都有值且不同":空值表示"沿用现有配置",那不算冲突
final mismatch =
(wantUrl.isNotEmpty && effUrl.isNotEmpty && wantUrl != effUrl) ||
(wantId.isNotEmpty && effId.isNotEmpty && wantId != effId);
final effective = [effUrl, effId].where((s) => s.isNotEmpty).join(' · ');
final wanted = [wantUrl, wantId].where((s) => s.isNotEmpty).join(' · ');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('采集端当前读到:$effective', style: PmonText.caption),
const SizedBox(height: PmonSpace.xs),
if (mismatch)
Text(
'与上方输入框($wanted)不一致:常驻采集读的是安装器那份配置。'
'点一次「重新安装并启动」即可让它改成上面填的值。',
style: PmonText.caption.copyWith(color: PmonColors.suspect),
)
else
const Text(
'(常驻采集读的是安装器那份配置,与本界面容器内的配置是两份;'
'本版尚未打通,故如实标注)',
style: PmonText.caption,
),
],
);
}
Widget _statusLine() {
final st = _st;
switch (st.state) {
+89 -1
View File
@@ -55,7 +55,13 @@ class _FakeOps implements ResidentOps {
}
}
Future<void> mount(WidgetTester tester, _FakeOps ops, {VoidCallback? onStarted}) async {
Future<void> mount(
WidgetTester tester,
_FakeOps ops, {
VoidCallback? onStarted,
String serverUrl = '',
String deviceId = '',
}) async {
tester.view.physicalSize = const Size(1000, 2200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.reset);
@@ -65,6 +71,9 @@ Future<void> mount(WidgetTester tester, _FakeOps ops, {VoidCallback? onStarted})
child: ResidentCard(
ops: ops,
onResidentStarted: onStarted,
// 界面内采集用的网关地址/设备标识(与采集端实际读到的可能不同)
serverUrl: serverUrl,
deviceId: deviceId,
// 轮询间隔拉长到一天:测试只关心"这一帧显示什么",
// 周期性定时器会让 pumpAndSettle 永远不收敛
refreshInterval: const Duration(days: 1),
@@ -210,4 +219,83 @@ void main() {
expect(allText(tester), contains('未安装'));
});
});
/// 「查不了」不是「出事了」—— 探针不可用时卡片只能说"不知道",
/// 且必须是中性灰那一档(不是橙色告警)。这类错的可恶之处在于:
/// 它看起来**很确定**,用户会照着它去重装一个已经装好的东西。
group('探针不可用(未知态)', () {
testWidgets('★ 显示"状态未知"与原因,绝不显示"已安装但没起来"', (tester) async {
final ops = _FakeOps(const ResidentStatus(
state: ResidentState.unknown,
autostartRegistered: true,
agentExists: true,
probeAvailable: false,
probeError: 'Windows 尚无进程探针(待多端扩面实现)',
detail: '本平台无法判定采集端进程(Windows 尚无进程探针(待多端扩面实现)),'
'这不等于"没在运行"',
));
await mount(tester, ops);
final text = allText(tester);
expect(text, contains('状态未知'));
expect(text, contains('无法判定采集端进程'));
expect(text, contains('这不等于'), reason: '要顺手否掉"没在跑"这个误读');
expect(text, isNot(contains('已安装但没起来')),
reason: '探针不可用时给告警态,等于把"不知道"渲染成"出事了"');
});
});
/// 「采集端当前读到的配置」——本轮只做诚实标注(不做同步,沙箱下也同步不了)
group('采集端实际读到的配置(诚实标注)', () {
testWidgets('★ 在跑时显示采集端读到的网关地址与设备标识', (tester) async {
final ops = _FakeOps(const ResidentStatus(
state: ResidentState.running,
pids: [777],
autostartRegistered: true,
serverUrl: 'http://127.0.0.1:3000',
deviceId: 'mac-01',
));
await mount(tester, ops,
serverUrl: 'http://127.0.0.1:3000', deviceId: 'mac-01');
final text = allText(tester);
expect(text, contains('采集端当前读到'));
expect(text, contains('http://127.0.0.1:3000 · mac-01'));
expect(text, isNot(contains('不一致')));
});
testWidgets('★ 与界面输入不一致 → 明说不一致,并给出可执行的动作', (tester) async {
// 这正是沙箱两份配置的现场:界面里改了地址,采集端还在读旧的
final ops = _FakeOps(const ResidentStatus(
state: ResidentState.running,
pids: [777],
autostartRegistered: true,
serverUrl: 'http://127.0.0.1:3000',
deviceId: 'mac-01',
));
await mount(tester, ops,
serverUrl: 'http://192.168.1.9:3000', deviceId: 'mac-01');
final text = allText(tester);
expect(text, contains('不一致'));
expect(text, contains('http://192.168.1.9:3000'),
reason: '要同时显示"界面填的"和"采集端读的",否则用户不知道差在哪');
expect(text, contains('重新安装并启动'),
reason: '只说"不一致"是抱怨;给出下一步才是诚实且有用');
});
testWidgets('未安装时不显示这一行(没有"采集端读到的配置"这回事)',
(tester) async {
final ops = _FakeOps(const ResidentStatus(state: ResidentState.notInstalled));
await mount(tester, ops);
expect(allText(tester), isNot(contains('采集端当前读到')));
});
testWidgets('问不到状态时也不显示(不能凭空造一份配置出来)', (tester) async {
final ops = _FakeOps(const ResidentStatus(
state: ResidentState.unknown,
detail: '拿不到状态',
serverUrl: 'http://127.0.0.1:3000',
));
await mount(tester, ops);
expect(allText(tester), isNot(contains('采集端当前读到')));
});
});
}
+145 -2
View File
@@ -19,10 +19,14 @@ import 'dart:io';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:pmon_client/host/autostart_label.dart';
import 'package:pmon_client/host/install_paths.dart';
import 'package:pmon_client/host/resident_service.dart';
/// 造一个 `pmon-install status --json` 的输出
///
/// [probeAvailable] 传 `null` 表示**省略该字段**(老 CLI 的真实形态)——
/// 这正是三态要覆盖的部分:缺字段 ≠ false。
String statusJson({
bool running = false,
List<int> pids = const [],
@@ -32,6 +36,10 @@ String statusJson({
bool configExists = true,
bool logExists = true,
bool agentExists = true,
bool? probeAvailable,
String probeError = '',
String serverUrl = 'http://127.0.0.1:3000',
String deviceId = 'dev-x',
}) =>
jsonEncode({
'platform': Platform.operatingSystem,
@@ -42,9 +50,18 @@ String statusJson({
'loaded': loaded,
'detail_lines': ['state = running'],
},
'process': {'running': running, 'pids': pids},
'process': {
'running': running,
'pids': pids,
// 只有显式要求时才写这个字段(模拟老二进制)——三态必须靠"字段在不在"测
if (probeAvailable != null) 'probe_available': probeAvailable,
if (probeAvailable != null) 'probe_tool': 'pgrep',
if (probeAvailable != null) 'probe_error': probeError,
},
'config': {'path': '/Users/me/Library/Application Support/pmon_client/config.json',
'exists': configExists},
'exists': configExists,
'server_url': serverUrl,
'device_id': deviceId},
'log': {'path': '/tmp/com.pmon.collector.err.log', 'exists': logExists},
'installed': {'agent_path': '/Users/me/Library/Application Support/pmon_client/bin/pmon-agent',
'agent_exists': agentExists},
@@ -323,4 +340,130 @@ void main() {
}
});
});
/// 「查不了」≠「没在跑」。
///
/// `process.probe_available` 是 CLI 侧新增的判据(Windows 上还没有 pgrep →
/// false)。消费方若不看它,`running` 只能是 false,于是得出"已安装但没起来"
/// 这个**看起来很确定、实际一次都没查过**的结论,卡片还会染成告警色。
/// 故除单点外还加一条矩阵守卫:这个洞不该靠"偶然被发现"来堵。
group('⑦ 探针不可用 → 只能报"不知道"', () {
test('★ probe_available=false 且没观测到进程 → unknown(不是 installedNotRunning)',
() {
final st = ResidentStatus.fromJson(jsonDecode(statusJson(
probeAvailable: false,
probeError: 'Windows 尚无进程探针(待多端扩面实现)',
registered: true,
agentExists: true,
)) as Map<String, dynamic>);
expect(st.state, ResidentState.unknown,
reason: '没查过就不能给结论,哪怕自启项和落位文件都在');
expect(st.detail, contains('无法判定'));
expect(st.detail, contains('Windows 尚无进程探针'));
expect(st.probeAvailable, isFalse);
});
test('★ 矩阵守卫:任何 probe_available=false 的快照都不得产出"确定的没在跑"', () {
// 三种结论态里,只有"确实观测到进程"才允许说 running;其余一律 unknown。
for (final registered in [true, false]) {
for (final agentExists in [true, false]) {
for (final running in [true, false]) {
final st = ResidentStatus.fromJson(jsonDecode(statusJson(
probeAvailable: false,
probeError: 'no pgrep',
registered: registered,
agentExists: agentExists,
running: running,
)) as Map<String, dynamic>);
final label = 'registered=$registered '
'agentExists=$agentExists running=$running';
if (running) {
expect(st.state, ResidentState.running, reason: label);
} else {
expect(st.state, ResidentState.unknown,
reason: '$label —— 探针不可用时"没在跑"是猜的,不是结论');
expect(st.state, isNot(ResidentState.installedNotRunning),
reason: '$label —— 这一态是橙色告警,等于把"不知道"渲染成"出事了"');
expect(st.state, isNot(ResidentState.notInstalled),
reason: '$label —— 更糟:会让用户去重装一个已经装好的东西');
}
}
}
}
});
test('老 CLI 没有 probe_available 字段 → 判定不变(缺字段 ≠ 不可用)', () {
// 把"缺字段"当成 false,所有老二进制/老快照都会被打成"未知" ——
// 那是用另一种方式骗人(明明查到了,却说不知道)。
final st = ResidentStatus.fromJson(
jsonDecode(statusJson(registered: true, agentExists: true))
as Map<String, dynamic>);
expect(st.probeAvailable, isNull);
expect(st.state, ResidentState.installedNotRunning);
expect(st.detail, isNull, reason: '正常判定不该附带诊断文字');
});
test('观测到进程时 running 优先(探针元数据不能压过正面证据)', () {
final st = ResidentStatus.fromJson(jsonDecode(statusJson(
probeAvailable: false,
running: true,
pids: [99],
)) as Map<String, dynamic>);
expect(st.state, ResidentState.running);
expect(st.pids, [99]);
});
test('★ config.server_url / device_id 必须解析出来(卡片据此显示"采集端读到什么")',
() {
final st = ResidentStatus.fromJson(jsonDecode(statusJson(
running: true,
pids: [1],
serverUrl: 'http://192.168.1.9:3000',
deviceId: 'mac-01',
)) as Map<String, dynamic>);
expect(st.serverUrl, 'http://192.168.1.9:3000');
expect(st.deviceId, 'mac-01');
});
test('config 缺 server_url(老快照)→ 空串,不崩', () {
final st = ResidentStatus.fromJson({
'autostart': {'registered': true},
'process': {'running': true},
'config': {'path': '/x/config.json', 'exists': true},
});
expect(st.state, ResidentState.running);
expect(st.serverUrl, isEmpty);
expect(st.deviceId, isEmpty);
});
});
/// 自启 label 的两处消费者(安装器 / GUI)必须是同一份常量。
///
/// 漂移的症状极隐蔽:GUI 查 `A.plist`、安装器注册 `B.plist` —— 两边都"正常",
/// 只是问的不是同一个自启项,表现是"**明明装了、卡片却说未注册**"。
/// 这与 install_paths / 指标字段 / 配对码是同一族缺陷,故用同款源码守卫。
group('⑥ label 只有一处真相源', () {
test('★ 字面量只许出现在 host/autostart_label.dart', () {
const source = 'lib/host/autostart_label.dart';
const consumers = [
'lib/host/resident_service.dart',
'bin/pmon_install.dart',
];
final src = File(source).readAsStringSync();
expect(src, contains("'com.pmon.collector'"),
reason: '真相源里必须有这个字面量(否则守卫自己在扫空气)');
for (final path in consumers) {
final body = File(path).readAsStringSync();
expect(body, isNot(contains("'com.pmon.collector'")),
reason: '$path 又写死了一份 label —— 请改用 kDefaultAutostartLabel');
expect(body, contains('kDefaultAutostartLabel'),
reason: '$path 应当引用真相源常量');
}
});
test('GUI 传给 CLI 的 label 就是那份常量', () {
final svc = ResidentService();
expect(svc.label, kDefaultAutostartLabel);
});
});
}