v2/init
main
从主干(1.0.9)长出,**只新增、不改动主干**。本提交带过来的是 已交付的水墨屏看板 1.1.0 那批改动(下一步会在此基础上做横屏与统一字号): · ui/eink_board.dart 单台设备大字看板(6 大格、一屏放下) · ui/eink_mode.dart 全局零动画开关 + 静止等待指示 · ui/theme.dart PmonEink 三档明度令牌 + statusGlyph 形状化状态 · host/app_config.dart einkMode / einkRefreshSec(默认关,不影响彩色版) · 平台通道 setKeepScreenOn(副屏常亮) · 刷新节流 数据每秒到,只在间隔到点重建 之后本分支会继续做:横屏为默认、竖屏可切、隐藏次要信息、统一字体与样式。 主干(main)不含以上任何代码。
<user> 2026-09-16 的四条要求,逐条落地并各有可执行断言: ① 横屏为默认、可切竖屏 · 新增配置 einkLandscape(默认 true);看板里的「转屏」按钮切换并落盘 · 屏幕方向由宿主锁(SystemChrome),看板只发指令、保持"纯展示"以利测试 · 列数按**可用空间宽高比**决定(宽→3 列 / 高→2 列),不写死方向 —— Flutter 只拿得到一个矩形,分屏/折叠/多窗口下方向与矩形并不一致 ② 聚焦核心数据、隐藏次要信息与冗余控件 · 在线时顶部只有「实心圆 + 设备名」:形状已表达在线, "在线"二字与"数据新鲜"都是冗余;一旦离线/疑似/未知才把状态与原因铺出来 · 底部两行说明合并为一行("最后刷新 xx" + "每 N 秒自动刷新" → "xx · 每 N 秒") ③ 对比度与刷新 · 延续零动画(静止等待图标、NoSplash、无无限动画),有源码结构断言守着 · 支撑"永不溢出":横屏/竖屏/极窄/极矮/极宽多套尺寸全部覆盖 ④ 统一字体与样式(避免字号大小不一) · PmonEinkText 收敛为 5 档(label 与 action 同号,靠字重而非字号区分); 界面里**不得写就地字号**,有源码断言(`fontSize: 数字` 即红) · 数值改为「大数值 + 小单位」:此前 `1.42 Mb/s` 整串按 40px 渲染宽 360px, 而 3 列时每格仅 207px → 统一比例被拖到 0.575,**一屏数值全被它压小** · 全屏数值共用**同一个**缩放比例(按最长那条算); 并改用**实测字形二次收敛**替代纯线性估算(渲染期文本宽度非线性, 实测在极窄格 + 极长值时溢出 3.7~9.3px);最外层留一层 FittedBox 兜底, 保证"绝不裁掉内容"(正常尺寸下它不生效,同号不受影响) · 数值加**无损紧凑化**(`310.00` → `310`):只删数学上无信息的零尾,绝不四舍五入 实测(F50 代跑): · 横屏 3 列 × 2 行、大数值 + 小单位、数值明显大于标签 —— 见 dist/水墨屏走查/03 · 竖屏**未能在 F50 干净验证**(它的 wm size/density 覆盖 + 强制旋转使 Flutter 视口 变成近正方形,于是按设计走了 3 列)。竖屏布局由 widget 测试覆盖 (536×724 → 2 列),待真机(Neo2)上机复核。 测试:客户端 321/321;其中 eink_board_test 32 项。 变异测试:把"统一缩放"退回"逐格 FittedBox"会被"渲染高度一致"那条抓住。 主干 main 不受影响 —— 本分支相对 main 只新增、未修改任何主干代码。
配合项目定名「墨观 InkWatch」: - client/README.md 的发布步骤改为输出 inkwatch-arm64-*.apk, 并标注「包名与 FFI 符号仍是 pmon」(两件事,见 docs/rename-inkwatch.md) - pubspec 版本 1.1.1+11 → 1.1.2+12 真机核验(F50): - 界面标题「墨观 采集端」;APK 内 AndroidManifest 含「墨观」、不含 PMon - 因未改 applicationId,覆盖安装成功 —— 本地配置与会话令牌原样保留。 若改了包名,这一步会变成「装成另一个 App」,要重装并重新认领 生产已同步:~/pmon → ~/inkwatch。容器名与卷名(pmon_pg_data)均未变, 4 个容器 Up 18 hours healthy;从新路径 compose 照常认得 —— 这正是「只改显示名、不动 compose project name」的价值所在。
脱敏改坏了一处断言:device-identity-test 的「大小写归一」用例 输入 deviceTag('android-ABC123')(大写,未被替换) 期望 'abc123'(小写,已被替换) → 输入与期望指向了不同的标识,必然失败。改为输入 ABC123 / 期望 abc123, 两者同指一个标识,语义(大写入、小写出)不变。 ⚠️ 教训:**字符串级脱敏必然改坏成对出现的断言**(输入与期望、正例与反例)。 脱敏后必须跑全套测试,不能只看 diff 顺眼就推。 同时把上一轮只匹配小写而漏掉的**机型编号大写变体**(25042PN24C / 24069RA21C) 一并脱敏为 MODEL-A1 / MODEL-B2 —— 型号本身是公开信息,但与设备 ID 后缀 组合起来足以指认具体设备,故一并替换。
上一轮 git add -A 时把 filter-branch 的临时目录一并提交了 —— 它里面存着**改写前的提交信息与临时工作树**,等于把脱敏前的原文又带回了仓库。 本次:删除该目录、写入 .gitignore(防止再犯),随后用它重写全部历史。
首次推送前的人工审计一次扫出六类个人数据,而且**反复漏**: · 第一轮漏了机型编号的**大写变体**(规则只写了小写) · 第二轮漏了**裸用户名**(规则只写了长形式 xxxdeMacBook-Pro-2.local) · 第三轮漏了真实账号邮箱 —— 因为臣用 head -10 截断了扫描输出, 前面十条全是测试假邮箱,真邮箱被挤到看不见 三次都是"扫全之后回看"才发现的。人工审计靠不住,固化成脚本。 判据分两档(关键设计): · FAIL:高置信(公网 IP / .cn 域名 / /Users/<名字> / SSH 私钥路径 / 私钥块 / 手机号 / 疑似真实密钥赋值) · WARN:需人判断(邮箱 —— 测试夹具与真实邮箱长得一样)。 只列不判红,避免假阳性变成噪音、进而被整体忽略。 白名单刻意**窄**:Flutter 官方中国镜像域名、明显合成的口令值、单字母域名的假邮箱。 扫描范围:工作树(被跟踪文件)+ **全部提交** + 全部提交信息 —— 只扫工作树是不够的,`git log -p` 照样能把脱敏前的原文翻出来。 本脚本**不内置任何真实值**(内置就等于把要防的东西写进仓库); 项目特定敏感串写到本机的 ~/.config/inkwatch/privacy-denylist.txt(不入库)。 用法:node tests/privacy-scan.js(全量) / --worktree-only(快,只扫工作树)
改的是什么:client/android/app/build.gradle.kts 此前是 Flutter 模板原样 —— release 一直用 signingConfigs.getByName("debug"),即公开的 AOSP 测试密钥。 三条后果且都不报错: ① 任何人都能签一个同签名的包冒充升级 ② 无法上架(Play 拒绝 debug 签名) ③ 换正式密钥后,已装设备无法覆盖升级(不可逆) 怎么改: - 新增 signingConfigs.release,凭据从 client/android/key.properties 读 (该文件已在 .gitignore 中,确认 check-ignore 命中) - buildTypes.release 指向它 - task-graph 守卫:release 构建缺 key.properties 时**明确失败**并打印修复指引 刻意不学模板「回落 debug 签名」—— 那是一条「看着构建成功、其实签的是公开密钥」 的静默失败路径。本仓库多次栽在静默失败上(迁移从不执行、首次部署路径无人测), 故这里宁可让它红。 验证: - 变异:移走 key.properties → release 构建 2 秒内失败并给出指引 - 放回 → 构建成功;apksigner 确认 DN=CN=InkWatch,指纹与 keystore 一致 SHA-256 0d58f8b5749b6a60c8042f6f4333a0c847192bf37d44e56cdd4756f3970ad679 密钥:~/keystores/inkwatch-release.jks(仓库外,PKCS12/RSA2048,有效期至 2054), 凭据记录在同目录一个 600 权限的文件里。 ⚠️ 这是本仓库唯一不可再生的东西 —— 丢了就永远无法更新已发布的应用。 新增 docs/release-and-signing.md:密钥信息、key.properties 契约、出包命令、 两个包名的分工、换签名的后果与恢复步骤。
为什么必须独立:两条分支的 versionCode 各自递增(通用版 1.0.9+9 / 水墨屏版 +13)。 共用一个 applicationId 会互相覆盖,且 versionCode 小的那个会被系统当成**降级** 而拒绝安装 —— 两个想并行的版本根本装不到同一台手机上。 改为独立包名后:两版可共存、各自升级,且 data 目录独立(配置互不干扰)。 这是本分支与主干**唯一有意的差异** —— 已在 gradle 里写明「合并主干时不要合回去」。 namespace(Kotlin 包名)保持 com.pmon.pmon_client 不动:它只影响 R 类与源码包声明, 与「装成哪个应用」无关,动它要大改 Kotlin 源码且无收益。 版本 1.1.2+12 → 1.1.3+13。
为什么单独写:看板版面(列数、字号、格高)全部取决于设备的**逻辑尺寸 dp**, 而 6″/300PPI 且系统 density 由厂商设定 —— 1072×1448 在 density 2.0 下是 536×724dp, 若厂商设成 240 就是 715×965dp,版面完全不是一回事。不查就不知道。 脚本做三件事: 1. 按机型名(Neo/iReader)**精确挑设备**,避免误操作到 F50 上 2. 打印机型/Android 版本/内存/ABI + 物理与覆写尺寸、density, 并换算出逻辑 dp 与"该走几列"的预期 3. 找到设备却无法连接时,直接打印开启 USB 调试的排查步骤 用法:bash client/scripts/eink-device-probe.sh [APK路径] 首跑实测:Neo2 在 adb 不可见(见提交信息附的 USB 描述符证据), 脚本按设计输出了排查步骤。
背景:Neo2 的 USB 调试开不了(已用 locationID 关联确认它只暴露掌阅自己的 255/255/0 传输接口,全系统唯一的 ADB 接口属于 F50)。而看板版面 (列数、字号、格高)**完全取决于逻辑尺寸 dp** —— 拿不到 wm size/density 就是瞎调。 于是把自检做进应用:把关键数据**直接画在屏幕上**,一张截图即可取数。 自检页内容: · 展示层自有(MediaQuery):逻辑尺寸 dp、devicePixelRatio、物理像素、方向、 由尺寸推出的看板列数 · 宿主提供(viewer_page):刷新间隔、已收帧数、本会话断点、最后刷新时刻、常驻内存 设计要点: · **静态文本表**,无动画无滚动 —— 与看板同一套墨水屏约束 · 打开时**不显示数值网格**(一屏只放一件事) · 底部按钮在「自检 / 返回」间切换,仅在宿主提供回调时出现 · 用 Expanded 等分行高,多尺寸不溢出 测试:eink_board_test 32 → 39 项(新增 7 项),客户端 321 → 328/328。
本文件此前有四处范围缺陷,每一处都是"范围悄悄变小、而输出仍然是绿的": ① 受 cwd 约束。脚本内 `git ls-files` 与 `git grep <rev> -- .` 都相对当前目录, 而 run-all.sh:27 正是 `cd "$(dirname "$0")"` → 实际只覆盖 tests/** (实测 26 个文件 vs 从仓库根跑的 193 个,少 86%)。 client/ server/ shared/ docs/ deploy/ 全在盲区,而真实环境指纹恰好都在那里。 → 开跑先 `git rev-parse --show-toplevel`,所有 git 调用带 cwd=ROOT。 ② 非 ASCII 路径。`git ls-files` 默认把路径加引号转义输出 ("docs/xxx-\346\236\266.html"),照原样 readFileSync 必然打不开 → 被 catch 跳过。 本仓库两个中文名文件长期不在扫描范围内。 → 所有 git 调用带 `-c core.quotepath=false`。 ③ 未跟踪文件。只扫 `ls-files`(纯受跟踪)会漏掉"刚写好、还没 add"的新文件, 而那正是下一次提交的原料。实测踩到:一个新写的 docs/*.md 带着设备代号与 ROM 构建指纹躲过了脱敏。 → 加 `--others --exclude-standard`(忽略项仍排除,不引入噪音)。 ④ 浅克隆。`clone --depth=N` 隐含 `--single-branch`,只带默认分支。 → 扫描前确认分支齐全。 同时**写明判据边界**(这一条比上面四条更重要,因为它防的是"用错力"): 判据是"这条信息能不能把内容跟我本人 / 我的机器关联起来",不是"网上能不能查到"。 | 类别 | 处置 | |---|---| | 产品参数(机型名、型号代号、分辨率、芯片名) | **不必脱** | | 设备实例标识(device_id 的 ANDROID_ID 后缀、设备串号) | 建议脱 | | 云 / 容器实例(lhins-…、1Panel-…) | 该脱 | | 指向本人的(服务器 IP、域名、SSH 私钥路径、Linux 用户名、macOS 主机名、账号邮箱) | **必须脱** | 两条硬约束,来自本轮一次返工: · 不得把产品参数当敏感数据脱 —— 脱了以后 README 里「设备归并」案例 (归并前 5 台 / 归并后 4 台)就没法跟真机对照了,净负收益; · 不得让判据恒红 —— 通用开源项目名(searxng / meilisearch)、通用词 (reader_default)、已经是脱敏占位符的值(/home/devuser、~/pmon) 都不许进判据或 denylist。恒红会让整条护栏被无视,比漏报更糟。 按此边界调整规则集: · 新增「Linux 家目录(含用户名)」,devuser 作为既有占位符放行 · 删除「Apple 机器标识符」「证书 DN 中的地名」「真机型号/芯片标识」—— 这三条属于产品参数,留着只会恒红 · 邮箱保持 WARN:夹具与真实邮箱形状完全一样,规则分不开; 真正要拦的那个邮箱属于"形状上无特征"一类,请写进 denylist(命中即 FAIL) 另修一处工具自身的坑:`--format=%(refname:short)` 里的左括号在 `sh -c` 下是 子 shell 起点 → 语法错误 → 被 catch 吞成空串(范围提示会显示"分支 0 个")。 带 () % 的 git 参数改用 execFileSync,不拼进 shell 字符串。 验证:从仓库根与从 tests/ 运行结果完全一致;范围提示如实报出文件数与分支数。
上一轮脱敏**做过头了**:把产品参数当敏感数据脱,把 README 里「设备归并」案例 (归并前 5 台 / 归并后 4 台)跟真机的对照关系毁掉了 —— 净负收益。 按最终判据("能不能把内容跟我本人 / 我的机器关联起来")重做,替换表从 31 条 收敛到 4 条,命中从 43 文件 / 400 处降到 25 文件 / 46 处: 仍要脱(指向账号资源 / 指向本人 / 设备实例标识): · 云实例 ID、容器编排实例名 · 设备 ID 的稳定后缀真值(在 main 上补齐) · 个人 handle 撤回(此前误脱,现恢复原样): · 产品参数:机型名、型号代号、屏幕分辨率、芯片名、Android 版本 · 证书 DN 里的地名 —— 它随 APK 公开分发,且文档要用它核对 apksigner 输出 · 私有 LAN IP、公共 npm 镜像源 · 通用开源项目名(searxng / meilisearch / market-sentiment / reader_default) · 已经是脱敏占位符的值(/home/devuser、~/pmon) · keystore 目录路径 —— `~/` 下不含用户名,且命令要能照抄执行 「护栏拦不住」的三类(形状上无可匹配特征)改由本机 denylist 装原始值: Linux 用户名、macOS 主机名、账号邮箱。denylist 装原始值、不装结果值 —— 装结果值会把已经改对的内容也判红。 ⚠️ 只改工作树。历史里的原文仍在,由 git filter-repo 连历史一起重写。 验证:tests/run-all.sh 全部套件(见下一条执行记录)。
上一轮把自称替换成了 `<user>`,有两个副作用: ① **尖括号触发 14 个 doc-comment lint**(`unintended_html_in_doc_comment`)—— Dart 分析器把 `///` 注释里的 `<...>` 当 HTML。脱敏前 analyze 是 "No issues found",脱敏后变 14 条 info,属于**回归**。 涉及:app_config / boot_report / config_migration / main / ui/eink_board / ui/skeleton + 6 个测试文件。 ② **语义被降级**。原句是**决策记录**,改完丢了主体: - `(2026-09-16 产品负责人定的默认)` → `(<user>定的默认)` —— 从"负责人拍板"降级成"某个用户",信息量没了 - `产品负责人那台,2026-09-15 实测` → `<user>那台` —— 指代不清 - `产品负责人截图里的现象是` → `<user>截图里的现象是` —— 同上 改法:`<user>` → **「产品负责人」**(全仓 41 处 / 23 文件) · 无尖括号 → 14 个 lint 归零 · 语义比 `<user>` 清楚:明确是「拍板的人」,而不是「某个用户」 · 公开仓库里用中性角色名比昵称得体 验证:客户端 328/328、`dart analyze lib test` = No issues found。 diff 已核为**纯粹**(仅这一类替换,未混入其它改动)。 注:昵称本身不指向真实身份(网名 `某个网名` 才是可关联的,那条保留在 denylist 里), 故这里的取舍是「去掉个人色彩」而非「切断身份关联」。
设备报告(2026-09-16「电纸书助手」导出,实测非资料推测)显示此前的描述有两处不准, 且缺了一条**会影响设计判断**的关键事实: 1. **系统版本写错**:注释与文档此前写 `Android 11`,实为 **Android 10 (API 29)**。 顺手核过兼容性:Kotlin 里所有 `SDK_INT` 判定(>= O / >= TIRAMISU / >= UPSIDE_DOWN_CAKE)都有守卫,29 上走安全分支; `foregroundServiceType="dataSync"` 正是 API 29 引入的,在 29 上有效; `POST_NOTIFICATIONS`(33) 被忽略;只用应用私有 filesDir,不涉分区存储。 2. **补上 CPU 与传感器**:MT8175B 四核 2GHz(aarch64,与 APK 的 arm64-v8a 匹配); ★ **传感器 0 个** —— 这条最要紧,见下。 ★ 由「零传感器」推出的设计结论(已写进 docs/eink-device-neo2.md): 没有加速度计/陀螺仪 → 系统拿不到姿态 → **自动旋转在这台机器上等于不存在**。 所以横竖屏**只能由软件锁定**,看板里的「转屏」按钮**不是便利功能, 而是唯一的方向入口**,**不能省** —— 省掉它,用户在竖屏机器上永远看不到横屏版面。 现有实现(宿主用 SystemChrome 锁方向、看板只发指令)正好不依赖传感器, 经此确认是对的,不是过度设计。 另记一个仍待实测的数:逻辑尺寸 dp。报告里的「300」若指 densityDpi → 约 572×772dp; 若只是由 1072×1448@6″ 算出的 PPI(density 取常见 2.0)→ 约 536×724dp。 **两种假设下版面决策一致**(列数由宽高比决定:竖屏 2 列 / 横屏 3 列), 故不阻塞调版;确切值用看板「自检」页一屏截图即可定案。
产品负责人指出"参数早就发过了,不该再等截图"。臣复核后确认:**参数确实够用**, 而且不必上机 —— 把两种 density 假设的逻辑尺寸喂给看板自己的代码 (widget 测试挂载),就能量出真机版面: | 假设 | 方向 | 数值字号 | 列数 | |---|---|---|---| | A 572×772(若 300=densityDpi) | 竖 / 横 | 40.0 / 37.2 dp | 2 / 3 | | B 536×724(若 300=PPI、density 2.0) | 竖 / 横 | 38.5 / 33.2 dp | 2 / 3 | 两种假设下**列数一致**、**数值字号 33~40dp**(标签的两倍以上)→ 版面合格。 ⚠️ 关键澄清(避免把测算读成真机值):**widget 测试的字形是正方形的** (每字宽 ≈ 字号),远宽于真机字体,故上表是**下限** —— 真机只会更大(长值基本能开到满档 40dp)。 结论:`densityDpi` 的精确值**不影响任何设计决策**,只用于事后核对。 设备卡里「如何确认」的措辞已从「待确认」改为「事后核对」, 并说明自检页的真正用途是内存占用与实际刷新计数。 (臣上一轮把"验证"说成了"前提",是要纠正的表述。)
真机反馈:在采集端登录后,切到查看端仍显示未登录,于是又登一次。 排查结论:**不是登录两次,而是登录态没同步**。令牌早就共享 (两端都写同一个 `AppConfig.accountToken`,且两端都订阅了 `ConfigStore.updates`), 整条链路本来是通的: 采集端登录 → onAuthChanged → ConfigStore.save → _publish 广播 → ViewerPage._onExternalConfig → sessionAffectingChanges(含 accountToken)为真 → _restartWith → _startFromConfig → `_token = cfg.accountToken` ✅ 断在**最后一环**:`AccountCard` 在 `initState` 里 `_token = widget.initialToken` 读了**一次**就不管了,且没有 `didUpdateWidget`。 而主壳是 IndexedStack,两页的卡片**都长期存活** —— 父级更新了 `initialToken` 也不会重跑 `initState`(同位置、无 key → State 被复用), 卡片内部仍是旧的空 token,照样渲染登录表单。用户看到的自然就是 "那边登了、这边还得再登一次"。 修法:给 `AccountCard` 补 `didUpdateWidget`,只在 `initialToken` 变化时同步; 登出(token 变空)时一并清掉 `_info`,否则会留着上个账号的档位与设备数。 边界(刻意保守): · 邮箱**只补空、不覆盖** —— 只在输入框为空或仍等于旧值时才同步, 免得把用户正在敲的内容冲掉; · token 未变时直接返回,不做无谓重建(避免自写盘自激)。 对照:`PairingCard` 全程直接用 `widget.accountToken`,所以没有这个问题 —— 同一应用里两种写法,错的那个恰恰是"看起来更规范"的那种(把入参缓存进 State)。 验证: · 新增 4 条测试(`test/account_test.dart` ⑥ 组),客户端 328 → **332/332** · **变异测试**:移除 `didUpdateWidget` → 「别处登录/登出后卡片要跟着变」两条**报红**; 恢复即绿 —— 证明测的就是这条修复本身 · `dart analyze lib test` 干净
真机反馈「转屏功能无法使用」。排查:**不是代码没接上,是系统不理** —— `SystemChrome.setPreferredOrientations` 发出去后窗口尺寸纹丝不动, 界面上当然"没反应"。这与设备事实卡里的「零传感器」自洽: 没有加速度计,SmartOS 层面就不做方向变化。 (接线本身是对的:按钮 → setState + 请求 + 落盘,manifest 也没有写死 `screenOrientation`、`configChanges` 也含 orientation|screenSize。) ⚠️ 由此得一条通用认识:**发了请求不等于生效**。能验证的只有窗口尺寸本身。 改法: 1. `_verifyOrientation()` —— 请求后回读窗口尺寸(900ms 宽裕), 宽高比没变成想要的那样就切到应用内旋转。 **不猜设备、不看厂商,只看事实。** 2. `_einkBoardShell()` —— 必要时套 `RotatedBox(quarterTurns: 1)`。 它的关键性质是**交换子节点的约束**(窗口 1072×1448 → 子节点拿到 1448×1072), 于是横屏布局正好按 1448×1072 排布、再旋转 90° 铺满物理屏 —— **不缩放、字号无损**;命中测试也跟着旋转,按钮照常可点。 代价:系统 UI 不参与旋转,用户需把设备物理横置。 同时清掉系统 padding(旋转后它描述的是未旋转的窗口,语义已不对)。 3. 自检页新增「窗口(物理)」「布局(看板用)」「转屏方式」三行 —— 前两者**不相等**即证明在应用内旋转,一眼可验证。 对**支持系统转屏的设备(如 F50)零影响**:请求后尺寸确实变了 → `_softRotate` 保持 false,行为与之前完全一致。 验证: · eink_board_test 39 → 40 项,客户端 332 → **333/333**,analyze 干净 · 新用例「竖屏窗口里也排出 3 列」:断言物理窗口是 536×724(竖) 而看板列数是 3(横)—— **一竖一横并存**即证明约束真被交换 · ⚠️ 写这条测试时踩坑:不能沿用按屏幕 y 坐标分行的 `columnsOf()` —— RotatedBox 之后 `getTopLeft` 返回的是**旋转后**的屏幕坐标,同一行会变成同一列。 **度量方法本身失效,别把"量不出来"当成"功能没实现"。** 设备事实卡(docs/eink-device-neo2.md)补第六节,记录该结论与验证方法。
真机反馈「开看板后底下的导航键没隐藏」。查下来是**两个成因,都要治**: ① **系统导航栏**:代码里**从未**调过 `setEnabledSystemUIMode` —— 一直显示。 修法:进看板 → `SystemUiMode.immersiveSticky`;退出 → `manual` + 全部 overlays。 · 用 `immersiveSticky` 而非 `immersive`:前者在用户误触屏幕边缘后**会自动重新隐藏**, 副屏是常驻的,不该被一次滑动永久唤出系统栏。 · 恢复用 `manual` + `SystemUiOverlay.values` 而非 `edgeToEdge`:后者要求 API 29+, 前者全版本可用,语义就是"把系统栏都还回来"。 ② **应用自己的底部导航**(采集/查看两页签):主壳是 `IndexedStack` + `bottomNavigationBar`,而后者是 Scaffold 的**独立区域** —— 看板渲染在 body 里, 它一无所知。修法:新增 `PmonEinkBoardMode`(`ValueNotifier<bool>`), 主壳用 `ValueListenableBuilder` 订阅,看板前台时不渲染导航。 · 用 ValueNotifier 而非像 `PmonEinkMode` 那样的静态 bool:这里必须**触发主壳重建**。 **判定只有一处**:`_syncBoardChrome()` 用同一个 `_eink && !_einkPaused` 同时驱动 系统栏显隐与主壳导航 —— 本项目已因"同一判据写两处"反复踩坑(指标清单、单位换算、 身份判据…),这里不再重演。 顺带修掉一个隐患:转屏校验原先用 `Future.delayed`(**不可取消**),页面销毁后 回调仍会醒来。已改成可取消的 `Timer` 并在 `dispose` 里取消。 (这是写测试时被 "Pending timers" 揪出来的 —— 测试的价值不只在断言。 `dispose` 里同时还原 `PmonEinkBoardMode` 与系统栏,免得离开这一页留下副作用。) 验证: · 新增 2 条用例(`viewer_page_test` ⑨ 组)→ 客户端 344 → **346/346** · **变异测试**:注释掉"通知主壳"那行 → 「进看板 → active 置真」**报红**;恢复即绿 · `dart analyze lib test` 干净 ⚠️ 本提交是 **feature/eink 专属**,不同步主干:`ui/eink_mode.dart` 主干上不存在, `main.dart` 的订阅逻辑也只在有水墨屏模式时才有意义。 (上一轮臣就是整文件复制到主干,把分支专属内容带了过去 → 主干编译不过。 这次明确不跨分支。) 设备事实卡补第七节,记录两个成因、取舍与遗留。
2026-09-17 为了连上 Neo2 绕了四轮弯路,根因**不在设备侧**: Neo2 被 Parallels 直通进了虚拟机(`prl_vm_app` 持有它的 user client), 之后释放了又被 Microsoft Edge(Chromium WebUSB)认领。 而脚本当时只会说"未发现设备,请检查 USB 调试" —— 于是排查一直被引向 设备侧(开关、拔插、描述符),迟迟找不到主机侧的真凶。 改法:把"设备在总线上"与"根本没插上"**分成两条路**: · 在总线上 → 打印它暴露的接口类别 + **谁持有它**(user client 占用者) · 不在总线上 → 才是线/开关的问题 并明确写出 ADB 接口的判据(`255 / 66 / 1`)。 命令用的是**按设备递归取子树**(`ioreg -r -n "<名>"`), 不是全量 grep 拼字段 —— 后者会让不同节点的字段串在一起, 臣据此一度误判"Neo2 有 ADB 接口"。 ⚠️ 同时记下一个**已确认的硬事实**:iReader Neo 2(SmartOS / Android 10) 在主机侧**无任何占用者**、设备侧 USB 调试开关反复确认已开的情况下, 仍只暴露一个名为 `MTP` 的 `255/255/0` 私有接口 —— **该机型不产出 ADB 通道**,调试通道不可用。以后不要再在这上面花时间, 改用设备内「自检」页取数。 (脚本细节:`$NAME」` —— bash 会把紧跟其后的多字节字符字节并进变量名, 故一律写成 `${NAME}`。)
# Conflicts: # tests/privacy-scan.js
护栏的邮箱一条是 WARN(只列不判红),但规则太紧:本地部分限 1-3 字符, 于是测试夹具 `typed@x.com` / `other@y.com` 每轮都被列出来, 18 条噪声把真正的信号淹了。 判据其实是**域名**:`x.com` 这种单字母域名不可能是真实的企业或个人邮箱, 本地部分多长都不改变这一点。故放宽本地部分到 1-12 字符,域名仍限单字母。 (这与"护栏总报红就没人看"是同一类问题:噪声会让人对信号麻木。)
方案落地第一步。它是两件事的**共同底座**(故先做它,不分成两遍): · 指标走内网(LAN 优先) · 大文件端到端传输(不经服务器 ⇒ 不受服务器磁盘与流量限制) ⚠️ **这一步最关键的不是功能,是安全**: 绝不能 `HttpServer.bind(InternetAddress.anyIPv4)` —— 那会同时绑到公网网卡上, 等于在公网开了一个端口。故: · 只绑 `pickLanAddress` **选中的那一个**内网地址; · 只暴露**只读、无数据**的 `/lan/ping`,未知路径一律 404; · 将来接数据/文件时用一次性令牌 —— **不拿"同网段"当授权** (那是隐蔽监控的形态,见方案 2.1 的合规红线)。 地址选择收敛在 `pickLanAddress` 一条纯函数里,因为**选错网卡的后果最难定位** (表现是"服务看着开了、对面却连不上")。规则:排除回环 / 169.254 链路本地 / IPv6 / 虚拟网卡(utun、docker、br-、veth、vmnet、tap…),**私有网段优先**。 没有可用内网地址时**返回 null 且不启动** —— 不退而求其次绑公网。 可测性上做了一处刻意取舍:入参收 `(网卡名, 地址列表)` 简单形状, 而不是直接吃 `NetworkInterface` —— **后者没有公开构造函数**, 吃了它就没法在测试里构造"VPN + Wi-Fi 同时存在"这类最容易选错的场景。 验证: · 新增 12 条测试(9 条穷举地址筛选 + 3 条服务本身),客户端 346 → **358/358** · 其中一条**真起服务 + 真发 HTTP 请求**(不是"调了 API 就算过"), 因为"绑了但对面连不上"正是本模块最容易出的问题;并断言未知路径 404 · **Mac 真机验证**:网卡 en0 192.168.0.85 / en7 192.168.0.12 → 选中前者; 服务起在 192.168.0.85:49504;/lan/ping → 200 {"ok":true,"v":1};未知路径 → 404 · `dart analyze lib test` 干净 接线:主壳 initState 启动、dispose 停止;启动失败**不影响主流程** (局域网只是"更快",不是"必需"),只在看板自检页如实显示状态 —— **"没开"和"开了但对面连不上"必须能分辨**。 下一步:查看端拿到地址后先试内网、失败回落云端;再做文件传输。
# Conflicts: # client/lib/host/lan_service.dart # client/lib/main.dart # client/test/lan_service_test.dart
把第二步的探测结果露出来 —— 否则"走内网还是走公网"只是代码里的一个变量, 用户看不见,也就没法判断到底有没有生效(本项目一贯要求可验证)。 **三种状态必须分得开**: · 对端未上报(可能不在同一局域网) · 已上报但探不通(会列出试过哪些端点) · 可达(显示具体是哪个端点) 混成一句"不可用",用户就没法判断是"我这边的服务没起来"还是"对端根本没报"。 ⚠️ 诚实边界:探测结果**不是实时推送的**,在下一次节流刷新时反映到界面(最多 10 秒)。 这是刻意的取舍 —— 为一个诊断行加一条推送通道要多一处要维护的接口, 而看板本来就有节流刷新,够用。 客户端 361/361、analyze 干净。
按项目纪律「**凡多层持同一份清单,必须有真实形态数据端到端穿透**」补上 —— 这条链路横跨四处:客户端上报 → 网关校验 → 网关存会话 → 随快照下发 → 客户端接收。 单测只能覆盖"网关校验"那一处,而本项目最贵的一类 bug 恰恰是**跨层静默丢数据** (历史上白名单过严把 capabilities 全剥掉,谁都没发现)。 跑法(本脚本自带环境,不碰生产): 起临时 redis + 本地网关(无 PG 走 Redis 授权回退)→ 手动写一条 auth 授权 → 跑 ★ 实测结果(**证明安全规则在链路里真的生效**,不只是"函数会拦"): 上报 5 条(2 条合法 + 公网地址 / 主机名 / 云元数据地址各 1 条) → 快照里只有 ["192.168.1.5:49504","10.0.0.7:1234"] → 三类非法值**在真实链路里被拦掉** 再次全部非法上报 → 旧端点**被清掉**(快照不再带该字段), 避免查看端拿着上一轮的过期地址去连 过程中顺带确认了两件既有机制(都不是本次改动引入的): · 订阅有**授权校验**,匿名订阅会被 NOT_PAIRED 拒 —— 头一次跑就撞上了, 这反而说明授权守得住;本地无 PG 时走 `auth:<deviceId>` 的 Redis 回退; · 无 PG 时网关是 stateless,不落库。 另:生产网关已同步部署并核对 —— `sha256sum` 与本地**一致**, 即生产跑的就是这份被 E2E 验证过的代码。
方案文档里的 ③,也是产品负责人的**原始诉求**:文件不落服务端、 端到端传输、支持超出服务器磁盘容量的大文件。 实现落在第一步做出的那个"只绑内网的小服务"上 —— 这正是把底座先做出来的回报。 ## 端点 | 端点 | 作用 | |---|---| | `offerFile(path)` | 登记一个待传出的文件,返回带**一次性令牌**的句柄 | | `GET /file/<token>` | **流式**返回,支持 `Range` 断点续传 | | `revoke(token)` | 传完即撤销 | 文件从发送端磁盘**直接流到**接收端磁盘,服务端一分钟都不参与 —— 所以既不受服务器 45G 可用磁盘的限制,也不烧流量包。 ## 安全(这一块比功能重要) - **令牌即授权,不拿"同网段"当授权** —— 那等于把物理接近当成授权依据, 正是隐蔽监控的形态(方案 2.1 的合规红线)。令牌由上层显式发放,与配对码同一思路。 - **令牌不存在/过期一律 404,刻意不用 403**:403 会泄漏"这个令牌曾经存在", 等于给爆破一个反馈信号。 - **路径不来自请求**(由登记时的本地路径决定)→ 结构上就没有路径穿越。 - **`stop()` 清空登记**:停止服务 = 不再共享任何文件。否则下次 start 会带着 上一轮的令牌复活 —— 令牌不该比它所属的那次服务活得更久。 (这条是被测试逼出来的:用例间单例状态残留,一查才发现语义本身就不对。) ## 校验:把"传完了"和"内容对"分开 `LanDownloadResult.verified` 是**返回值的一部分**,不是一行日志。 大文件最常出的问题是"字节数对得上、内容不对"(断点拼接错位、对端文件被换过)。 对端没给哈希时**不算验过** —— "没坏"与"验过了"是两件事,不能拿后者冒充前者。 哈希走**流式**(`sha256.bind(file.openRead())`),大文件不整个读进内存 —— 那正是本模块要解决的问题本身。 ## 验证 - 新增 7 条用例(端到端 sha256 一致、令牌错 404、撤销后 404、 Range 206 + 分片内容正确、越界 416、中文文件名 URI 编码、登记不存在的文件报错) → 客户端 361 → **368/368** - **真机实测**(本机真起服务、真传文件、真校验): 源文件 64.0 MB 登记(含流式 sha256)502 ms 传输 64.0 MB / 601 ms ≈ **106 MB/s** 端到端校验 ✓ sha256 一致,字节数一致 → 1GB 也就十来秒,**"超出服务器磁盘的大文件"这条诉求成立** ## 依赖 新增 `crypto`(Dart 官方、纯 Dart、极小):大文件传输没有端到端校验是不合格的, 这个依赖值得。未引入任何原生插件,安装包体积基本不受影响。
与主干同一份实现。同步前已 diff 确认两文件在编辑前一致,故可安全取; 并顺手把 pubspec 里 crypto 的注释与主干统一 —— 两次插入的文本有引号差异, 虽无害,但同一份清单两处定义正是本项目反复踩的坑,注释也不该例外。 (pubspec 现在只剩 version 一行差异,那是两支刻意不同的版本号。) 验证:客户端 368 → 374/374,analyze 干净。
把上一步的领取码接上界面。放在**采集端页与查看端页各一张** —— "我的设备间传文件"是双向的:任一台都可能既发又收。 ## ★ 一条贯穿全卡片的纪律:「先确认再收」 接收是**两段式**的:粘贴码 → **看清"将收到什么(来自哪台机、什么名字、多大)"** → 再点「收下」。**不给"粘了码就自动开下"的路径** —— 文件会落到接收方磁盘上, 用户有权在写盘之前知道那是什么。 (与本项目对"破坏性操作要先列清单再确认"的态度一致。) 配套的一条细节:**输入内容一变,上一次的确认立刻作废** —— 否则会出现"看着 A 的信息、收的却是 B"这种最不该有的错。 ## 保存到哪 优先**下载目录**(Android 上是 app-specific 外部目录,**系统文件管理器看得到**), 拿不到才退回应用私有目录。存在用户翻不到的地方等于"传了个寂寞"。 文件名来自对端,**只取 basename**(把分隔符换掉)—— 不让对端用路径做别的事。 ## ⚠️ 引入依赖时踩的坑(值得记) 加 `file_picker` 后 **Android 构建直接失败**: ``` Dependency ':flutter_plugin_android_lifecycle' requires ... compile against version 36 or later :file_picker is currently compiled against android-34. ``` 即 **`file_picker` 8.x 自身用 android-34 编译**,而其传递依赖要求 36+。 → 升到 **13.x** 解决(顺带 API 变了:`FilePicker.pickFiles()` 是**静态**方法、 返回 `List<PlatformFile>`、用户取消返回**空列表**而非 null —— "取消"不是错误,不该被渲染成红色提示)。 ⚠️ **教训:加原生依赖必须真跑一次 Android 构建**, `pub get` 成功、`analyze` 干净都**不代表能构建**。 ## 验证 - 新增 5 条界面纪律用例(没确认就没有「收下」、垃圾码给人话错误且不放行、 有效码先显示"将收什么"、**改输入即作废旧确认**、发出区初始无码) → 客户端 374 → **379/379**,`analyze` 干净 - **Android release 构建通过**:`com.inkwatch.eink` v17 / 19.7MB (比上版大 0.6MB,来自 file_picker + path_provider,合理) - 出包 `dist/inkwatch-eink-1.1.7.apk`(正式签名 CN=InkWatch)
先写 file_picker ^8 再改成 ^13 时留下的说明,内容已被上方那段(解释为何不能低于 13) 完全覆盖。留着会让两分支的 pubspec 多一处无谓差异 —— 而同一份清单两处定义正是本项目反复踩的坑,注释同理。 现在 pubspec 与主干**只剩 version 一行差异**(两支刻意不同的版本号)。
产品负责人要"设备间传文件"免去手工粘贴领取码。这需要一条信令通道。 本提交只做**服务端侧**(协议事件 + 网关转发 + 端到端验证), 客户端接线留作下一步 —— 一次改太多、又验不透,是本项目最贵的一类错误的温床。 ## 协议:新增 3 个上行 + 2 个下行事件 | 事件 | 方向 | 作用 | |---|---|---| | `agent:offer` | 上行 | 采集端发布"有个文件待领" → 广播给该设备的订阅者 | | `viewer:offer` | 上行 | 查看端发布 → 定向送到目标设备的采集端 | | `viewer:unsubscribe` | 上行 | **退订** —— 局域网直连时改走内网,必须能退掉云端订阅 | | `server:offer` | 下行 | "有人要给你一个文件,凭这些信息去取" | | `server:unsubscribed` | 下行 | 退订回执 | ## ★ 三条设计取舍 **① 服务端刻意不解析"领取码"的格式。** 载荷里把 `lan_endpoints`(数组)与 `token` **分开传**:端点走 `normalizeEndpoints` 校验(只收私有网段 —— 与 hello 里**同一条防线**,不因 "这次是 offer"而放宽),而那个码是客户端之间的约定,服务端多懂一层 就多一处要跟着改的地方。 **② `viewer:offer` 必须先过授权。** `isAuthorized(target, accountId)` 复用同一套判据。少了这一步, "发文件"就成了**绕过授权往别人设备推东西**的口子。 **③ 退订只摘 Redis 订阅,不动 socket 房间。** 房间是**账号级**(`viewer:<accountId>`,一个账号可能多端在线), 而订阅是**设备级**。把房间退掉会连带断掉该账号看别的设备的连接 —— 那是另一回事。转发路径本就按 `getSubscribers(deviceId)` 查,摘订阅即生效。 ## 验证:端到端穿透(`tests/tools/lan-signal-e2e.js`) 真起 socket 走完整链路,**8/8 通过**: · `agent:offer` → 订阅者收到,端点/文件名/大小原样到达 · **公网端点在 offer 这条路上同样被拦**(没绕过同一条防线) · `viewer:offer` → 目标设备的采集端收到 · `viewer:unsubscribe` → 收到回执,且**此后不再收到该设备的帧** (这一条是"省流量"能不能成立的前提 —— 退不干净就是空话) 另:跑 `lan-signal-e2e` 时发现订阅本身有授权校验(匿名订阅被 `NOT_PAIRED` 拒), 这在设计之内,脚本里如实写明。
服务端侧上一步已就绪。这一步把两端接上: 选完文件**自动通知对端**,对端那边直接出现待领提示,不用再转发那条码。 ## 两条路并存,而不是二选一 免粘贴通了就走它;**不通就自动退回"把码发给对方"**。 刻意不是二选一 —— 那会让"不在同一局域网"直接变成功能不可用。 界面把这两种结果**分开说**:能通知对端时不给用户增加任何动作; 没通知成时明确要求"请把这条码发给对方"。 ## 共用一份解析(单一真相源) `parseIncomingOffer` 放在 `lan_service.dart`,**两端共用** —— 采集端与查看端收到的是同一个事件形状,各写一份解析迟早会漂移 (本项目为此吃过多次亏)。 ## ★ 一处必须补的坑:`didUpdateWidget` 宿主(采集端页/查看端页)的会话对象是**异步创建**的:卡片先建、`_agent`/ `_session` 后到。若只在 `initState` 订阅,这个通道会被**永久漏掉** —— 症状是"免粘贴时灵时不灵",而且极难定位。 **这与本项目刚修过的 `AccountCard` 是同一类问题**(入参缓存必须配 `didUpdateWidget`), 所以这次是**主动**加上并写测试守住的,而不是等它变成故障。 ## 避免重复实现 手工粘贴与免粘贴**共用同一个下载实现**(`_download`)—— 两条入口、一套逻辑。 分开写迟早出现"一条路径修了、另一条没修"。选端点也复用 `pickReachableLanEndpoint`(对端可能报多个,先挑真的可达的那个)。 ## 验证 - 卡片测试 5 → **9 条**,新增:收到待领文件显示"谁给的/什么/多大"、 「忽略」后不再纠缠、**流入参后到也必须订得上**(守 didUpdateWidget)、 没有免粘贴通道时粘贴那条路仍可用(降级而非不可用) - 客户端 379 → **383/383**,`analyze` 干净 - Android release 构建通过:`com.inkwatch.eink` v18 / 19.8MB - 出包 `dist/inkwatch-eink-1.1.8.apk` ⚠️ 服务端**无需再部署**(信令在上一步已上线)。
与主干同一份实现(产品级)。同步前逐个核对基线:分支已有上几轮的改动, 本轮差异是纯新增;viewer_page 的看板自检页因两分支差异大,用锚点精确插入。 要点见主干上那条提交,关键三条: · **两套数据源必须互斥** —— 两条都在跑意味着云端流量照烧, 而那正是本功能要省的东西; · **三道闸防重复**:_lanMode 时忽略云端帧 / 轮询按 seq 去重 / 两条路共用同一个 _ingestMetricsPayload; · **不等退订回执就切** —— 在途帧由第一道闸丢掉,等回执只慢一拍。 验证:客户端 383 → **387/387**,analyze 干净,Android release 构建通过。 ⚠️ 切换时序仍无自动化覆盖,真机形态验证待两台设备同局域网实测。
产品负责人拍板了"三层优先级:局域网 → 公网直连 → 中继(要有限制)"。 本提交做**中继核心**,它是唯一能保证"不在同一局域网也传得成"的一层。 ## 为什么"不落盘"而不是"传完就删" 原始要求是"文件不保存在服务端"。落盘再删**不满足**它:中间那段时间磁盘上 真有这份数据,而服务器只有 45G 可用、一次大文件就可能填满。 所以这里是**两条 HTTP 连接对接后直接 pipe** —— 字节从发送端进来、 立刻从接收端出去,进程内存里只有管道缓冲。 (测试里真的起了两个 HTTP 连接跑一遍,并断言**服务端目录里没有多出文件**。) ⚠️ 代价如实说:**两端必须同时在线**。这与"不落盘"是同一枚硬币的两面。 ## 四道限制("中继传输要有限制") | 闸 | 值 | 越界行为 | |---|---|---| | 单次大小 | 2 GiB | 拒绝并说明上限 | | 每账号并发 | 1 | 拒绝,提示等前一条 | | 限速 | 8 MiB/s | 节流 | | 每账号每日 | 20 GiB | 拒绝并给出已用/上限 | 另外:**假 size 绕不过**单次上限(传输中累计,超了直接断), 配额**按实际字节记**(不是声明的 size)。 任何一道越界都**明确告知用户**,不是静默失败。 ## 安全 - **上传/下载各自一条一次性令牌**,拿到一个不能当另一个用(有用例守着); - 令牌比较用 `timingSafeEqual`,不按字符提前返回; - 数据面失败一律 **404,不区分"不存在"与"令牌错"** —— 避免给爆破反馈。 ## ⚠️ 测试抓到的真 bug(值得记) `waitDownloader` 最初只接 `(id, token)`、**没接住下载端的 `res`**, 于是配对时拿不到 pipe 的目标。**只测"开/拒"永远不会暴露它** —— 是"真起 HTTP 跑一遍"这条用例抓出来的。 另一条教训:臣第一版断言"系统临时目录前后条目数相等", **那是假失败** —— tmp 是全局共享的、别的进程随时在写。 **量不出来时先怀疑量法**(本项目已多次踩)。 ## 验证 - 服务端 `relay-test` **15/15**:四道闸、令牌双向隔离、过期、 **真传一遍且字节一致**、**服务端不落盘**、按实际字节记配额、假 size 被截断 ## ⚠️ 未完成(如实说明) 1. **接入层(nginx)未落定**:中继走 HTTP,外网访问必须过 nginx, 且**必须关掉 `proxy_request_buffering`**(默认会把请求体缓冲到临时文件, 那样"不落盘"就是假的)。臣改了 `/opt/1panel/www/conf.d/pmon-ip.conf`, **但排查发现它似乎不在 openresty 的 include 链路里** —— 生效配置的路径未确认,故**不敢说这一环做对了**。 2. **客户端三层降级未做**(局域网 → 公网端点 → 中继的候选排序与界面明示)。
生产接入层已改并 reload 验证(外网 /relay/open → 200)。 仓库这份同步补上,保证**能重建出同样的接入层** —— 只改服务器不改仓库, 下次重新部署就丢了,而这类只在线上存在的配置最难排查。 ★ 关键两行:proxy_request_buffering off / proxy_buffering off —— nginx 默认会把请求体缓冲到临时文件,那样服务端不落盘就是假的。 这不是性能调优,是这个功能成立的前提。 (生产那份在 /opt/1panel/www/conf.d/pmon-ip.conf,改完需 reload 才生效 —— 臣当时改了没 reload,从公网打仍是 404,白排查了一阵。 reload 用 kill -HUP master 即可,因为 openresty 可执行不在 PATH 里。)
上一提交没找到锚点(仓库这份用 upstream 名 pmon_gateway、注释是中文, 与生产那份的字面 socket.io 不同),臣**没有硬塞**,这次按实际结构补上。 ★ 关键两行:proxy_request_buffering off / proxy_buffering off —— nginx 默认把请求体缓冲到临时文件,那样服务端不落盘就是假的。 生产侧已改并验证:外网 POST /relay/open → 200 且返回两条一次性令牌。
产品负责人拍板"优先选快的、局域网优先、公网次之、中继兜底、中继要有限制"。 本提交补齐**客户端**这一环,三层第一次真正接通。 ## 服务端:新增 wan_endpoints 与放宽 offer 校验 - **新增 `wan_endpoints`**(公网直连):与 `lan_endpoints` **分开两个字段**, 这样"私有优先"的排序才有地方落地,服务端也能一眼看出要去连的是内网还是公网。 ⚠️ 但两者**共享同一条禁令**:`169.254`(云元数据,SSRF 头号目标)/ `127` / `0.x` **无论出现在哪个字段里都不收**。"允许公网"不等于"允许任何地址"。 - **放宽 `_buildOffer`**:早先硬性要求"必须有内网端点",那会让"不在同一局域网" **直接变成功能不可用**。现在改成**三条路至少有一条**。 中继那段服务端**只校验形状** —— 取数权在 32 字节随机令牌上, 伪造 id 没用、把 id 转给别人也没用,所以不必回查会话。 ## 客户端 - **中继通道**(`openLanRelay` / `uploadViaRelay` / `downloadViaRelay`) - **下载校验只有一处**(`_receiveToFile`):局域网与中继**共用同一份收尾比对**。 各写一份迟早漂移,而这是"文件到底对不对"的唯一判据。 - **中继凭据按 `frameProvider` 同一模式注入**:查看端本来没有设备令牌, 而采集端总有 —— 且两者同处一个 App、采集端总在跑,所以两端都取得到。 - **上传要等对方**:中继是"两条连接对接后直接 pipe",发送端早到了没地方去。 故**轮询重试**(先等响应头证明管道已建立,再发字节), 这样不必为"对方开始收了"再加一个信令事件。 ## ★ 界面上必须明示走的是哪条路 - 走中继:已走**中继**…会消耗流量包 + 限速 X MiB/s - 走直连:已走**局域网直连**(最快、且不消耗服务器流量) - 接收侧完成后同样标注「本次经中继中转(不落盘)—— 消耗了服务器流量包」 / 「本次走直连 —— 未消耗服务器流量」 - 中继**上传进度条只在真走中继时出现** —— 直连是秒传的, 给它加进度条只会让人以为卡住了。 ## 验证 - 信令端到端 **14/14**,四条最关键: * **只有中继、没有内网端点 → 仍能发出**(三层的前提) * 公网端点走 `wan_endpoints` → 通过 * **云元数据地址在 `wan` 里同样被拦** * **三条路都没有 → 被拦**(不发一条"哪个都走不了"的 offer) - 客户端 **387/387**,analyze 干净,Android release 构建通过(19.9MB) - 线上:`/relay/open` 经公网 → 200 并返回两条一次性令牌 - 出包 dist/inkwatch-eink-1.2.0.apk ⚠️ 诚实边界:真机形态(两台设备分别处于同网段 / 不同网段)未实测。
新增解析用例,重点守两条最容易漏的: * **只有中继(没有内网端点)也必须能解析** —— 三层里的最后一层, 解析不了就等于没有,而不在同一局域网也能传全靠它; * **三条路一条都没有 → 不解析**,即不发一条哪个都走不了的 offer。 另守:走直连必须有领取令牌;中继 id/token 缺一视为没有中继(不当半个对象用)。 客户端 387 → 392/392。
## 背景:三层降级上线后做真机验证 产品负责人要求实测,臣写了探针用**客户端自己的代码**走三层 (另写一份测的只是"我以为的逻辑")。**第一层局域网直连当场全绿**, **第三层中继连续三次失败** —— 过程与结论如下。 ## 修掉的三个真 bug(都值得记) **① 失败的中继会占满并发额度** 一次失败(对端没点收下)会让空壳会话活到总 TTL(10 分钟), 期间再开中继永远是"请等前一条结束"——**而那条根本没人传**。 → 加 `sweepUnpaired()`:`open` 时先回收"两端都没连、且过了等待窗口(3 分钟)"的空壳。 **② HEAD 预检被 Express 的 `app.get` 抢走** `app.get('/relay/:id')` **会自动处理 HEAD 请求**,且注册在 `app.head` 之前 —— 于是 HEAD 落进"下载端"逻辑,拿**上传令牌**去验**下载令牌**,必然 404。 症状是"发送端永远等不到对端已就绪",而日志只有一串 E_BAD_TOKEN,**看不出是 HEAD 引起的**。 **③ 客户端 close 顺序导致死锁** 原写法"先 `await req.close()` 拿响应头、再写 body" —— 但 Dart 的 `close()` 要等 body 完整才把请求发出去,于是它等一个永远不会来的响应,**请求根本没出门**。 ⚠️ **服务端用 curl 手测全绿**(curl 是先发 body),**只有真客户端能暴露它**。 ## 简化设计(前两次都在打补丁,这次换形态) 取消"发送端先探对端在不在"那整套预检 —— 它对,但脆。 改成 **`attachUploader` 对端没到就挂着等**,接收端一到就叫醒它。 "**谁先到都行**"本来才是中继的自然语义,客户端因此什么都不用协调: 直接写完 body 再 close 即可。 ## ⚠️ 但仍然没打通 当前症状:**接收端(GET 侧)的连接被重置**,服务端日志为 `[relay] 中断: 接收端断开`。臣**三次尝试后停下**,不再盲试 —— 手上有明确下一步(查 GET 侧为什么先断:嫌疑在 `HttpClient.idleTimeout` 默认 15 秒,与"挂着等对端"的时长冲突),但**没有验证过的结论, 所以不写成"已修复"**。 ## 现在的真实状态 - ✅ **第一层:局域网直连** —— 真机验证通过(4MB,sha256 一致) - ❌ **第三层:中继** —— **未打通**,用户走到这层会看到"中继传输中断" - ⚠️ **第二层:公网直连** —— 逻辑完整,但无真实公网可达地址可测 - 服务端 `relay-test` 18/18、客户端 392/392、analyze 干净(**它们覆盖不到这个缺陷**) ## 教训 **"服务端用 curl 测过"不等于"客户端能用"** —— 这次两个 bug(③ 与 ②) 都是只在真客户端形态下才现形的。**端到端探针必须用真代码走真链路**, 手测只能证明服务端自己没错。
onMetrics 每帧原 4 次顺序往返(pushFrame → touchPresence EXISTS → markOnline → getSubscribers),合并为 pushFrameAndFanout 单 multi; touchPresence 的 EXISTS 与写入并入同一 multi(2→1)。语义逐项对齐: presence 只写 {ts}、SMEMBERS 原样返回、任一步抛错整体失败。 pushFrame / markOnline 两个再无调用方的旧入口按"同一件事不留两个 入口"的纪律删除。 三处全表扫描(Array.from(this.sessions...))改索引: _agentByDevice / _viewersByAccount 存同一 session 对象引用(单一真相源 仍是 sessions 表),hello/subscribe 登记、disconnect 摘除; _findAgentByDevice 带 !s.gone 竞态防护。首帧标记 Set<字符串> → Map<账号, Set<设备>>,回收从 O(全表) 降为 O(该账号设备数),计数账目平。 正确性修复(均经变异测试自证:删掉即红、恢复即绿): · relay sweep 双类判定:传输中按 lastActivityAt(每 chunk 刷新), 修「慢传被误删致每账号并发 1 额度统计破」;挂死传输仍有兜底; · LoginThrottle 加 FIFO 硬上限 + hit() 命中移队尾(淘汰序 FIFO→LRU), 修「一次性 key 喷洒把热点 key 计数冲掉、限流被绕一截」; · 角色翻转(协议违规:双角色挤一条连接)对称摘另一侧索引, 修「索引留死 session 引用、反复违规无界增长」; · relay 限速定时器覆盖前先取消,修「与背压 drain 交错时限速被突破一瞬」。 getOwnerId 加缓存(只缓存非 null —— 缓存 null 会造出"设备刚注册 2s 看不了"的拒绝窗口;owner_id 三个写点均不改非空值,已逐一实证); claimDevice 成功后广播失效(纵深防御)。 实测(2000 帧突发 ×3 查看端,5+5 轮,tests/perf-bench.js): 墙钟 0.42s→0.21s、吞吐 ~4800→~9350fps(+95%)、p50 8ms→2ms、 p99 17ms→5ms、丢帧 0;稳态 68fps/订阅/offer 无差异。
tests/perf-bench.js:四场景基准(metrics 定速/突发/内存/服务端计数器、 churn 订阅风暴、offer 定向时延、scan 索引微基准),--json 机器可读。 存在理由:性能优化必须"前后同一把尺",不接受"应该更快了"。 本地四容器栈的镜像可能比仓库旧(曾吃过 09-15 镜像的亏:测的根本不是 当前代码)——工具注释里写明测前先 up -d --build 并用 docker exec grep 验证代码版本。本次基准就是这么发现容器缺 viewer:unsubscribe 的。 docs/perf-baseline-{before,after}-2026-10-14.json:5+5 轮完整存档。 结论:旧代码突发呈双峰(~4800/~9350fps 两档),新代码全快档; 中位墙钟 0.42s→0.21s、p50 8ms→2ms、p99 17ms→5ms、丢帧 0。 run-all.sh 接入三个离线套件(会话索引 50 / 登录限流 22 / 中继 30), 计数标签随实际断言数修正。
一个 pmon-app-1 容器跑 postgres + redis + gateway + business (supervisord 各自守护、各自重启),外加跑完即退的 migrate。 合容器不合进程:两个 node 进程仍是独立进程,SIGTERM 直达原生优雅 关闭处理器,全部既有安全测试原样有效。 关键不变量(均有测试看守): · compose project 仍是 pmon、数据卷仍是 pmon_pg_data/pmon_redis_data → 与四容器路径随时互切、数据不动(卷名锚点,改名=清库); · 端口全部绑 127.0.0.1(云防火墙全端口放通,0.0.0.0=公网直露); · init.sql「空数据目录才初始化」语义复用官方 entrypoint,不重写; · 启动门 start-{gateway,business}.sh 复刻 depends_on service_healthy, 防全新卷首启时 node 抢在 PG 初始化前 FATAL 停摆; · 安全开关两个进程同读一份环境,一处传全。 dockerfile-coverage-test.js 扩展:「all」镜像双入口逐断 require 图、 安全开关通路按 compose 文件分别断言(含服务名映射)、新增第 6 段 端口回环断言(IP 前缀可选——裸映射 "3000:3000" 恰是绑 0.0.0.0 的 最危险写法,第一版正则漏了它,审计加固)。 代价如实记入 runbook 7.3:进程隔离弱于四容器、重启四进程齐动、 business 启动参数比四容器多堆上限(D-1)。
lan_service.dart 的服务器基地址示例换成 203.0.113.7:3000 并注明 「示例用文档段地址,勿写真值」。历史重写(tree-filter)已把含真实 IP 的三个提交改写,工作树这处是同一脱敏的最后落点。 privacy-scan 的公网 IPv4 规则加窄放行:172.15.0.1 / 172.32.0.1 两个精确串是 172.16/12 私有段判定的边界探针(tests/lan-endpoint-test.js 与 client/test/lan_service_test.dart 用),它们存在的意义就是 「形状是公网 IP 但不是私网」——换成文档段地址就测不到「把 172 整段 误判成私有」的回归。只放行精确串,不给任何真实地址开口子。 全量扫描(工作树 + 全部提交 + 提交信息):219 文件 / 59 提交零命中。
仓库审计(提交后)发现的三处文档漂移: · README 快速开始缺单容器路径入口(runbook 第七节写了但门面没指路); · README 验证段缺 perf-bench(性能口径工具,动热路径前后各跑一次); · README 套件计数停留在 18,实际 25(本次全量跑出来的实数); · rename-inkwatch.md 未登记 docs/ 下两个带旧名的历史方案文档 (pmon-v4-架构方案-V4.0.html / 商业化实施方案V4.0.docx)—— 内容是 PMon 时代原文,刻意保留文件名作历史存档,登记备查。 TODO/FIXME 全库零命中;架构文档为纯设计定位,部署内容以 runbook 为准(第七节已含与 V4.0 第 11 章的互切说明),不强制对称。
用户定:除 README / LICENSE / .env.example 外,文档类文件只在本地保存, 除非项目运行离不开才提交。docs/ 全部 11 个文件(架构方案、专项设计、 腾讯云运维篇、改名记录、性能基准 JSON、两份旧名历史存档)无一是运行 依赖(引用全是注释与文档间链接),整体移出。 本地存档:~/WorkBuddy/水墨屏工具/inkwatch-docs-local/(412K)。 同时清掉 patch_relay_v2.py —— 一次性字符串手术脚本(给旧 relay 修复 用),现在跑只会损坏 relay.js,属运行无关物。 连带修正(不留悬空引用): · README:目录树去 docs/、单容器取舍改为内联、perf-bench 数字改本地存档 · client/README.md:三处 docs 引用去路径留实质 · eink_board.dart / config_migration.dart:注释去路径留实质 · docker-compose{,.all,.prod}.yml:三处"详见 docs/…"改为内联关键纪律 · privacy-scan.js:quotepath 判据的例句随中文名文件消失而改写,判据保留 · .gitignore:加 docs/ 与 patch_*.py 段(政策机械固化,防再误提交), 顺手去掉尾部重复的 .git-rewrite 条目 验证:隐私扫描 207 文件零命中;镜像覆盖 43;Dart 392 全过; 全仓库 docs/ 悬空引用为 0。
统一应用多模式连接设计的第 0 阶段(纯收口,行为零变化): · net/probe.dart(59 行)+ net/offer.dart(246 行):从 host/lan_service.dart 原样抽出探测(probeLanEndpoint/pickReachableLanEndpoint)与 offer/ticket 编解码(LanIncomingOffer/LanTicket/parse*)。原文件只留 export 转出, 既有调用方(viewer/transfer_card/测试)零改动——全量 409 项证明纯搬迁。 这是为四驱动共用做准备:探测与邀约解码只许有一份。 · link/link_mode.dart:四种模式(云端/内网/P2P/中继)的词汇与可解释评分 (100 − RTT/30 − 丢包×2 − 成本权重,钳 0–100)+ 迟滞常数 8 分。 成本进分数是刻意的:轻量服务器按流量计费,不把成本写进排序, "自动选路"就会在半夜把流量包烧光。 · link/link_hub.dart:每(会话 × 对端)一实例的连接枢纽。**P0 只观测、 只建议、不控制**——数据面仍走 ViewerSession 老逻辑。推荐变化经既有 onLog 诊断出口可见("先让走哪条路变成可验证的事实,再去动数据面")。 · host/viewer.dart:十一处观测点接线(+18 行纯插入、零删除):云端连/断/ 连接失败、LAN 探测结果、LAN 数据面开/关、两条摄取路径的帧来源、stop 时回收。 测试:link_hub_test 16 项(评分三部件逐一钉住/迟滞边界恰好 8 分不换 9 分换/ 活跃路追踪/observeRtt 不凭空造度量/表恒 ≤4 有界);viewer_test +1 接线测试。 变异自证 ×4:删迟滞 → 2 红;坏评分公式 → 4 红;摘云端观测 → 1 红;恢复即绿。 全量:Dart 409 项全过;viewer-e2e 真集成 21 项全过(真网关真会话)。 与设计的两条偏差已记入本地设计文档 §9(TransportDriver 接口推迟到 P1 等第一个真实现者;P0.3 以事件旁路接线代替驱动实例)。
P1 阶段的结论先行:**「中继端到端未打通」是过时批注**——对活网关实测, 服务端三条路由与客户端三个函数一直是通的(open 200 → PUT/GET → 字节 全等)。真缺的是自动化验证与代码归位。本轮补上: · net/relay_client.dart(227 行):LanDownloadResult / receiveToFile / LanRelayChannel / openLanRelay / uploadViaRelay / downloadViaRelay 从 host/lan_service.dart 原样抽出(纯搬迁,export 转出,调用方零改动)。 这样 link/ 不必反向依赖 host/,分层纪律不破。 · link/driver.dart:TransportDriver 契约(mode / probe / close)。P1 的 RelayDriver 是第一个实现者,契约按真实需要定;数据面方法故意不立 (四种模式传输形态差异仍大,此时统一只会造空壳适配器)。 · link/relay_driver.dart:探活走 GET /health(**不烧配额**——探活若走 open 会占满"每账号并发 1"的闸,选路一频繁用户就永远"请等前一条"); openForOffer 委托 openLanRelay;upload/download 委托既有函数。 host 的 openRelayForSend 改为仅注入凭据、逻辑全交驱动。 · tests/relay-route-e2e.js(14 项,需网关):打真路由验八组—— 完整往返 / 401 缺身份 / E_BAD_SIZE / E_TOO_LARGE / E_TOO_MANY(并发 1)/ 超声明即断连且不超量 / 错向令牌 404。relay-test.js 测不到"路由怎么接 进来"(曾出过类全绿、路由 503 的事故)。 · client/bin/relay_check.dart + tests/relay-client-e2e.js(6 项):跑客户端 自己的 Dart 实现打真网关——这是「服务端通 + 客户端函数在」真的连起来 用的唯一证据。CLI 输出机器可读 JSON,e2e 断言字节数与 sha256。 · client/test/relay_driver_test.dart(7 项,真起本地 HttpServer): health 200+ok / degraded 200+ok:false 按不可达 / 500 / 端口没人听 / 缺凭据抛人话 / 409 中文原因原样上抛 / close 幂等。 最关键一条:**探活只许打 /health,绝不许打 /relay/open**(变异自证: 改成打 open → 精确变红)。 全量:Dart 416 项全过(409+7);run-all 26 通过 / 3 失败——3 个失败中 2 个是既有(指标字段一致性 / FFI 冒烟的 Rust 事件漂移),第三个是隐私 扫描(另一笔提交信息含本机 denylist 词,已在下一轮处理)。
能力就位(不是端到端打通互联网 NAT —— 见文末诚实边界): · net/candidates.dart:host/srflx 两类候选(relay 类别不做——本项目的中继 是独立通道,再做 ICE relay 候选等于再造一套)。上限 8、按 endpoint 去重、 脏项逐条丢:打洞器会对候选发包,脏地址等于替攻击者打探测。 · net/stun.dart:RFC 5389 最小 Binding 客户端(零新依赖,约 100 行纯逻辑)。 只做两件事:发请求、解 XOR-MAPPED-ADDRESS。安全要点:校验事务号 (防串包投毒)、只认 Binding Success、截断不抛。 · link/p2p_driver.dart:UDP 打洞。同时向所有候选发 PUNCH:<nonce>, 收 PONG/PUNCH 即成功并回 ACK;先通先赢,死候选拖不住活候选。 **非 PUNCH/PONG 的包一律忽略**——否则一次随机 UDP 扫描就能让 "打洞成功"变成谎言(有专门用例守着)。对称 NAT 打不通如实返回失败。 · offer 管线:candidates 作为增量元数据随 agent:offer/viewer:offer 传递; 服务端 normalizeCandidates 校验(host 只收私网、srflx 只收公网、 拒回环/云元数据、上限 8),不参与"三条路至少一条"的判定。 · schema-consistency 第 13 段:MAX_CANDIDATES ↔ kMaxCandidates 相等、 类型词汇一致、srflx 私网禁令在位(跨语言护栏)。 测试:Dart 436 全过(416 + STUN/候选 12 + 打洞 7 + offer 解析 2); lan-endpoint 41 项(+14 候选);run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。变异自证:摘 host 私网禁令 → 恰 1 红。 网关容器已重建确认是新代码(grep normalizeCandidates = 2)。 诚实边界:真实跨网打洞成功率取决于双方 NAT 类型;对称 NAT 必失败→落中继。 数据面接入(P2P 承载指标/文件)属 P3 编排。
· link/handover.dart:切换状态机 idle→decided→proving→overlapping→ retiring→done,失败 aborted(旧路保持)。不含定时器——超时由调用方 isStalled() 判,测试不必等真时间;跳跃/倒序一律忽略(宁可停住也不 做半截切换)。接入 LAN 进/出两个方向:探测证通即进重叠期,首帧到达 才退役旧路。时序与既有行为一致,只是把散落 if 变成可读相位。 · LinkHub.setPreferredMode():手动覆盖跳过评分与迟滞(人的判断优先于 算法);指定的路不可达时回落自动并在日志说明——不能因为用户选了 一条不通的路就装死。 · ViewerSession:linkSummary / preferredMode / setPreferredMode; ViewerStatus 加 linkLabel / linkDetail;强制云端时禁用内网自动切换。 · UI:顶栏徽标「在线/断开」升级为「在线 · 内网」,点击弹菜单 (自动/内网/云端/P2P/中继),勾选回显当前偏好;全走主题令牌。 ★ 过程中抓到并修掉一处真事故:最初用 hub.changes.listen → _setStatus 做同步,导致 widget 测试内存暴涨被系统杀(SIGTERM 137,且**回退页面 改动后仍复现**才锁定是会话侧)。根因是"流订阅→状态更新→页面重建" 在有周期重建的页面里形成失控环。改为三处显式同步(云端连上/进内网/ 离内网)后正常。教训已记入设计文档 §9。 测试:Dart 445 全过(+Handover 6、+偏好覆盖 2);页面 28 项回归; run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。
P3 留下的边界("P2P 是能力而非在用通道")本轮闭合。 · P2pSession(并入 link/p2p_driver.dart):打洞成功后**保留 socket**—— NAT 映射绑在本地端口上,关掉就废、重开等于重打一次洞。帧协议单包: PMON-FRAME-REQ:<nonce> → PMON-FRAME:<nonce>:<json>,payload 与云端 server:metrics 同形(查看端两套数据源共用一套解析)。nonce 必须回显 (迟到/串包丢弃);单包上限 8KB,超了宁可不回也不发分片包。 · 采集端:收到带候选的 offer → 开数据面 → startServing(() => _latestForLan) (与局域网直连同一份数据,不新造缓存)。 · 查看端:收到带候选的 offer → 开数据面 → 2 秒节拍拉帧 → seq 去重后走 同一条摄取路径 → hub 记 LinkMode.p2p;连续 3 次无响应即关闭回落。 · LinkHub 加 observeP2pProbe(P2P 的分数进选路,打通与否如实记录)。 ★ 两个坑(都已修,且值得记): 1. RawDatagramSocket 是单订阅流——"打洞一个监听、数据面换一个监听"会抛 "Stream has already been listened to",异常被 catch 吞成 null,表现 为"打洞无声失败"。只能一个监听器活到底、按前缀分派。 2. 测试辅助函数把对端候选端口写死,参数只改了本地端口 → 只有第一组 恰好对上,其余全挂。辅助函数必须全参数化。 测试:Dart 451 全过(+会话 6 项);变异自证(响应 nonce 写死 → 拉帧用例 精确变红);run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。 限制(诚实):候选目前随文件 offer 交换,故指标走 P2P 发生在该对设备 发过一次文件之后。要"订阅即可 P2P"需把候选上报挪进 agent:hello/快照, 属协议扩展,下一轮做。
首次真跑(腾讯云切换 all-in-one)时抓到:migrate 连 -h 127.0.0.1 却 在自己的网络命名空间里,pg_isready 无限循环、迁移永远不跑。 四容器时代用服务名 postgres 所以没暴露;单容器化时漏了这条。 加 network_mode: "service:app" 后 migrate Exited(0)。
改 name: pmon → inkwatch,让容器名与项目名一致。 ★ 关键防呆:name 同时是**卷名派生源**,故 volumes 段显式锁定 pg_data: {name: pmon_pg_data} / redis_data: {name: pmon_redis_data}。 不锁的话 compose 会去找 inkwatch_pg_data 这套新空卷 = 清库。 实测:切换后卷仍只有 pmon_pg_data / pmon_redis_data(无 inkwatch_*), devices=6 不变,migrate Exited(0),gateway/business 200。 镜像同步改名 pmon-all → inkwatch-all(docker tag 复用,不重建)。
5432 原本只为本机 psql 直连排障而发布,收益仅是少敲一次 docker exec, 却让宿主多开一个可连的口子。改由 docker exec 进容器排障。 实测:docker port 只剩 3000/4000(均绑回环),devices=6、gw/biz 200。
对外只发布一个口:3500 = 容器内 nginx 统一入口 /api/ → business(4000) 其余 → gateway(3000)(socket.io / health / relay) · deploy/nginx-all.conf(新):容器内入口配置。关键细节: - /healthz 本地直返(区分"入口挂了"与"后端挂了") - ★ X-Forwarded-For **透传**而非追加:若用 $proxy_add_x_forwarded_for, XFF 会变成 "client, 127.0.0.1",而 business 的 TRUST_PROXY_HOPS=1 取到的就是 127.0.0.1 —— 所有客户端在配对码限流看来是同一个人。 故用 map:上游有 XFF 就原样透传(hops 语义不变),无才补 remote_addr。 - relay 必须 proxy_request_buffering off + proxy_buffering off(否则 上传体被缓冲到临时文件,"不落盘"是假的) · Dockerfile.all:装 nginx、整份替换 nginx.conf、EXPOSE 只留 3500 · supervisord:加 [program:nginx](QUIT 优雅退出) · healthcheck-all:加第 5 项(入口 /healthz),入口挂=全站挂,必须一起判 · compose.all:ports 只留 "127.0.0.1:3500:3500" · openresty-pmon-ip.conf:由"按路径分反代 3000/4000"简化为**单一 upstream 3500** ——"哪个路径给哪个进程"只在容器内定义一次,接入层不再有第二份分流清单 · dockerfile-coverage 第 6 段:断言由"≥3 条端口"改为"必须有 3500 且 **不得发布 3000/4000/5432/6379**"(比原来更严,防绕开统一入口) 实测(现网):docker port 仅 3500;五进程 RUNNING;healthy; 3500 上 /healthz ok、/health 通、socket.io 握手拿到 sid、relay 往返 2048 字节全等; 经 80(OpenResty→3500)再验 relay 128 字节全等。 变异自证:删端口 → 精确 2 红;恢复 → 45 全过。
按评估结论(性能无压力 p99 5-11ms/CPU 0.09%,真风险是"挂了没人知道") 做的三处低成本加固,不改架构: · PG 日志前缀 + 慢查询(migrate.sql / init.sql,ALTER SYSTEM 幂等): - log_line_prefix = '[postgres] %m [%p] ':单容器五进程共用一个日志流, PG 原来无标识,排查要靠猜;现在与 [gateway]/[business]/[supervisor] 对齐 - log_checkpoints = off:每 5 分钟两条 checkpoint 是纯噪音 - log_min_duration_statement = 200:**慢查询直接落日志**,不依赖 pg_stat_statements(免装扩展、免重启),是排查"这次为何慢"最省事的抓手 走 ALTER SYSTEM 而非改 supervisord command:%m/%p 里的 % 会被 supervisord 的 %(ENV_x)s 插值解析,绕开这类坑。 · healthcheck-all.sh:set -e 改为**逐项 chk + ✓/✗ 输出**。 原版一挂就退出,docker inspect 的 Health.Log 里只有空输出—— unhealthy 时看不出是哪一项,等于把排查成本转嫁出去。 · deploy/watchdog.sh(新):每 2 分钟探 /healthz + /health, **只在状态变化时**写日志(DOWN/RECOVERED),可选 INKWATCH_ALERT_CMD 外部通知。装法:/usr/local/bin + /etc/cron.d/inkwatch-watchdog。 坑:手动以普通用户跑会 Permission denied → 预建 /var/log 与 /var/lib 下的文件并 chown 给运维用户(cron 以 root 跑不受影响)。 坑:curl 连接失败时 -w 已打印 000 且退出码非 0,再 `|| echo 000` 会拼成 "000000" → 改为"输出为空才兜底"。 实测: - PG 参数生效(show 三项确认)、日志里 checkpoint 噪音消失、 business/gateway/supervisord 行清晰可辨 - 健康检查输出五项 ✓ - 看门狗:正常 exit 0 / 故障 exit 1 并记 DOWN / 持续故障不重复写 / 恢复记 RECOVERED;crond 在跑,cron.d 已装 - schema-consistency 100 项(2 个既有 Rust 事件漂移)、 dockerfile-coverage 45/0、privacy-scan 零命中
生产压测抓到的真缺陷(**统一入口引入**,四容器时代无此层): 现象:经 3500 传 1MB 中继,只收到 130699 字节就挂死;直打 3000 完整。 根因:容器内 nginx 配了 upstream keepalive 池,却在非 WebSocket 请求上发 `Connection: close`(map 的默认分支写成 close)。两者语义冲突——连接被 标记关闭却要归还到池里,导致上游响应流在 ~128KB(正好是 nginx 的 proxy_buffer 量级)处停住。 修法:map 的 `''` 分支改为空字符串(保持 keepalive 复用),WebSocket 仍 upgrade。 这是 WebSocket + keepalive 并存的标准写法。 修复后:1MB 经 3500 完整传输 0.433s(与直打 3000 一致)。 同时新增 tests/bench-srv.py(生产压测,三维度:HTTP 吞吐 / socket.io 握手 / 中继传输),用共享容器网络命名空间跑,只读不写库。 压测结果(现网 2 核 3.5G): · HTTP /health:1000 请求 / 1.21s = **827 QPS**,p50 22ms / p99 66ms · socket.io 握手:200 次 / 0.15s = **1369 QPS**,p50 6.5ms · 中继 1MB:3.05 MB/s(限速目标 8MB/s 的 38%) · 中继 10MB:**异常**,见下 · 容器 CPU 峰值 **113%**(2 核吃满 1 核以上),内存 170MB ⚠️ 遗留缺陷(已确认、未修,需专门一轮): 中继传 **>约 2.6MB** 时吞吐骤降到 60-180KB/s(curl 对照一致,非压测脚本问题)。 已排除三个假设:容器内 nginx(本轮已修 128KB 卡点)、限速常量(放大 128× 仍慢)、 背压 pause(摘除仍慢)。剩余嫌疑在 gateway 的 pipe/写入模型。 这是**既有缺陷**(直打 3000 同样复现,四容器时代即存在),非本轮引入。
背景:生产压测确认中继 >1MB 时吞吐崩塌(2MB 传 20s 只到 139KB)。 缺陷**本轮未修好**,如实记录尝试过的五条路都失败(详见 relay.js 注释与 runbook): ① 放大限速 128× → 仍慢;② 摘背压 → 仍慢;③ 背压改 once('drain') → 仍慢;④ 完全去掉限速 → 更差(139KB 卡死);⑤ 改 pipeline() → done() 依赖的 onDrain 被删导致 /relay/open 崩,已回滚。 已排除:容器内 nginx(另修 128KB 卡点)、body-parser(PUT 路由无)、 TCP/内核(容器内纯 HTTP 4MB 响应仅 497ms)。 因此在根因修好前收紧上限: · MAX_ONCE_BYTES: 2 GiB → **1 MiB**(实测稳定边界:1MiB 337ms 完整, 2MiB 卡)。可用 RELAY_MAX_BYTES 覆盖,修好缺陷后调大即可、不必改代码。 · 报错文案改 **humanBytes() 自适应单位**(原来写死 "GiB",上限一变就错)。 文案里给出路:"较大的文件请用局域网直连"(LAN 直连不走中继、不受此限)。 · compose/.env.example 透传 RELAY_MAX_BYTES。 · relay-route-e2e 的 E_TOO_LARGE 断言**解耦单位**(匹配「上限」二字, 不再绑死 GiB —— 否则改上限就假红)。 现网验证:恰好 1MiB → 完整传输 337ms;1MiB+1 → 409 + 人话;2MiB → 409; 健康检查五项全过。
★ 这是**客户端连不上**的真凶,也是我上一轮报告里说"仍然待办"的那条。 根因:1Panel 的 WAF 在 http 级注入 access_by_lua_file /usr/local/openresty/1pwaf/waf.lua 且**只放行登记在 1Panel 站点列表里的域名**。pmon 的接入层是手工落盘的 conf (不在 1Panel 站点列表),于是所有走 upstream 的请求一律 403; 而 /healthz 是本地 return 200,**反而正常** —— 极易误判成"服务是好的"。 修法:在 pmon 的 server 块里写 `access_by_lua_block { }` 覆盖 http 级指令。 只对本站跳过 WAF,rbook/searxng 实测不受影响(502/301 原样)。 ★ 途中踩的两个坑(都已固化进脚本,防止再犯): 1. **仓库模板的 server_name 是脱敏占位** 203.0.113.10。直接 scp 覆盖生产 → Host 不匹配任何 server → 落到 1Panel 的 default_server → 又被 403, 且 /healthz 仍 200,看起来像"生效了"。我第一次就是这么被骗的。 2. 只替换 server_name 不够:注释里还有一处占位(客户端指向的地址文案)。 新增 deploy/apply-openresty.sh: · 全文替换脱敏 IP(可用 --dry-run 预览) · **自检生成文件里不得残留占位 IP**(残留就报错退出,不写生产) · 自动 nginx -t + reload + 回环自检(打印 /healthz 与 /health 的状态码) 现网验证(从另一台外部机器 <EXTERNAL_HOST>): /health → 200,socket.io 握手 → 拿到 sid + upgrades:["websocket"] rbook → 502(其自身后端未起)、searxng → 301(均未受影响)
体检发现护栏自身有三处缺陷。护栏是后续所有改动的前提,故先修它。 1) schema-consistency-test.js 第 13 段被误包进 if (fail > 0)({ 在 643 行、} 在 668 行), 全绿路径下那 7 条断言从不执行、脚本照样返回 0;而汇总行又打印在它之前, 即使执行也不计入统计 —— 护栏看起来在工作,实际没有。 改为:第 13 段无条件执行 + 汇总移到文件末尾 + 退出码按失败数返回。 实测 100 通过/2 失败 → 107 通过/0 失败(多出的 5 条正是此前从未跑过的)。 变异自证:把 candidates.dart 的 kMaxCandidates 8 改成 9,本套件 exit=1 并报 「上限值一致」;改回即恢复绿。修复前此变异根本测不出来。 2) Rust 与 JS 事件清单漂移:transport.rs 上行 4/下行 6,protocol.js 上行 7/下行 8。 Envelope::validate() 会拒绝表外事件报 E_BAD_TYPE,属潜伏故障。 补齐 5 个事件并把长度常量改为 4→7、6→8,同步 transport.rs 里写死数量的单测。 P2P 入口已下线但代码保留,故事件表按完整集合补齐;代码注释已记明: 将来若物理删除 P2P 代码,必须同步删掉这三个事件,否则就是给死代码陪绑。 3) theme_guard_test.dart 的 _uiFiles 漏登记 transfer_card.dart(630 行) 与 skeleton.dart(111 行),共 741 行界面代码不受护栏保护。已补登记。 两文件本身无硬编码色值,登记后直接绿。 新增 tests/guard-structure-test.js(36 项):汇总行必须晚于最后一个断言 + node --check。 刻意不采用"process.exit 只许一次"(实测 12 个文件各有 2-5 次,前置依赖缺失时早退正当) 与"汇总行缩进为 0"(本仓有脚本主体包在 main() 里,属正常结构)。 变异自证:造一个复刻上述形态的探针,守卫报「汇总@17 最后断言@20」并 exit=1。 验证:run-all.sh 28 套件 27 通过/1 失败(唯一红为隐私扫描的存量公网 IP,另行处理); cargo test --release 59/0;flutter test 451/0。 顺带订正 run-all.sh 头部过时计数(指标一致性 99→107、Dart 416→451), 并注明计数以实跑为准 —— 静态数 test( 会漏掉 for 循环内动态生成的用例。
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。
E1(网关重启后补传帧重复入库)的第一段:把 session_id 落到库。 ## 做了什么 - device_metrics 加 session_id 列(init.sql 建表 + migrate.sql 迁移,两条路径同源)。 - gateway 入队指标帧时带上 session.sessionId —— 此前 806 行已经在用这个值判重, 但没往下传,所以 DB 侧永远拿不到它。 - repository 的 INSERT 列清单加 session_id,并补 ON CONFLICT DO NOTHING。 - 固定头列清单(ts/device_id/seq/session_id)收到 metrics-schema.js 的 METRIC_TABLE_HEADER_COLUMNS,仓储写入与一致性测试都引用它。 ## 关键发现:原定的"DB 唯一索引"方案在本项目行不通 架构评审给的方案是建 (device_id, session_id, seq) 唯一索引。我在真实部署形态 (本机 Docker 的 TimescaleDB,device_metrics 有 262 万行)上实测,直接报错: ERROR: cannot create a unique index without the column "ts" (used in partitioning) 原因是 device_metrics 是 **TimescaleDB hypertable** —— pg_inherits 里挂着 6 个 _timescaledb_internal._hyper_1_*_chunk,hypertable 按 ts 分区,而 PostgreSQL 硬性要求分区表上的唯一索引必须包含分区键。把 ts 放进唯一键同样不行: ts 由服务端改写,补传同一帧时 ts 不同,唯一键就失去幂等语义 —— 骨架期那条"不加唯一约束因为 ts 会被改写"的注释想说的正是这点, 它错在把结论下成了"所以不能建",而正确结论是"唯一键不该含 ts,改由应用层承担"。 顺带纠正一个认知:pg_relation_size 在本机报 0 bytes 是 Docker Desktop VirtioFS 的统计怪象,262 万行是真实的(count(*) 45ms,全在 page cache)。 排查时不要被那个 0 带偏。 ## 因此 ON CONFLICT DO NOTHING 是"安全网"而非本层去重手段 现在是 no-op(没有唯一约束)。留着是因为 PG 的 INSERT 是语句级原子: 一批 5000 帧里只要有 1 行撞约束,整条语句抛错、_flushStorage 只 catch 掉 打一行日志 → 整批(含合法帧)全丢。将来若在普通 postgres 分支补上约束, 没有这一句会立刻变成"一条脏数据毁掉整批"。 ## 尚未完成(如实标注) 真正的幂等防线要挪到**网关侧持久化去重窗口**(原进程内 Map 重启即清空)。 方案已定:seq 窗口按 (deviceId, sessionId) 落 Redis,用 ZADD NX 的返回值 做原子判重,并并入 pushFrameAndFanout 那个已有的 multi —— 不新增往返 (本项目历史教训就是每帧多次 Redis 往返把帧率上限按死了)。 但这次没做完:_isDuplicateSeq 目前是同步函数、被 seq-session-dedup-test.js 的 28 项直接测,改成异步会波及那批测试,需要单独一轮谨慎处理。 ## 验证 本机 TimescaleDB 实测:ALTER TABLE ADD COLUMN 3.67ms(PG16 元数据操作,不重写表); 自带的去重 CTE 在有 262 万行的表上耗时 2.85s(seq scan),这也是为什么 "部分索引只覆盖新数据"的思路值得 —— 但 hypertable 不支持,最终没用上。 schema-consistency 107/0;seq-session-dedup 28/0;run-all.sh 28 套件 27 通过/1 失败 (唯一红为隐私扫描的存量远端引用,本地历史已脱敏干净,待推送后消除)。
问题:桌面端有两种合法采集形态 —— GUI 内建(关窗即停)与无界面常驻 agent。 实测确认:加这把锁之前代码里**没有任何跨进程保护**(PmonCore.instance 只是进程内 单例,对另一个进程毫无约束)。两者同时跑会双写同一 device_id:各自 seq 从 1 独立递增,服务端的 seq 去重窗口把两条流混在一起判重 → 丢帧或重复帧, 历史曲线出现台阶与毛刺,且**不报任何错**。 方案 A+C(产品侧确认): - A(兜底):新增 lib/host/agent_lock.dart,用 dart:io 的 FileLock 做跨进程 互斥。agent 启动时 tryAcquire(),拿不到就退出。 - C(体验):GUI 启动采集前 isHeldByOther(),为真则不启动自己的采集, 只做查看,并明确提示"后台常驻采集已在运行,本界面仅查看"。 而不是静默地少采一半数据。 三个关键细节: 1. agent 抢不到锁必须 **exit 0**。launchd 的 KeepAlive.SuccessfulExit=false 语义下,非零退出会被当成崩溃并立刻重启 → 两个进程互踢,无限重启循环。 2. 用 FileLock.exclusive(非阻塞)而不是 blockingExclusive:后者会让第二个 采集进程一直挂着等,用户看到的是"卡住不动"。 3. 锁文件放 ConfigStore.dir(与 config.json 同目录),不另算一份路径 —— 本项目"同一份路径写两处"已翻车多次,安装路径只能由一处派生。 为什么用文件锁而不是 pid 文件 / 命名管道: - 不依赖平台:dart:io 的 FileLock 在 macOS/Linux/Windows 通用, 不必为三个平台各写一套,也是多端扩面的最低成本路径。 - 不会留"僵尸锁":记录锁随 fd 由内核释放。pid 文件方案在 kill -9 后会留下 死 pid 永久阻塞下一次启动。 验证: - 单测 6 项(获取/释放/幂等/残留空文件不算占用/探测是只读的/路径口径)。 其中"探测后别人仍能加锁"守的是最隐蔽的一种自锁 —— isHeldByOther 若忘了 unlock,查一次就把采集永久锁死。 - **跨进程真实验证**(单测覆盖不到,因为 POSIX 记录锁是进程级的): 用 Python fcntl.lockf 持锁(与 Dart 的 FileLock 同为 F_SETLK,可互通), Dart 侧得到 BLOCKED: Resource temporarily unavailable; 外部释放后再测得到 ACQUIRED。双向都验过,排除"恒失败"的假绿。 - dart analyze lib bin 无问题;flutter test 469/0(+6)。
## 缺陷 `gateway._seqWindow` 是进程内 Map。网关重启窗口即清空,而客户端 session_id 不变、 会重发最多 120 帧补传 → 全部被判成新 seq → 重复入库 → 历史回溯的均值被慢慢拉偏, 且**不报任何错**(只翻库才看得见 seq 重复)。实测复现:10 帧正常入库后重启网关、 同会话重发同 10 帧,`device_metrics` 由 10 行变成 **20 行**(修复后仍为 10 行)。 ## 为什么不能放 DB 层(实测已排除,不再试) device_metrics 在 TimescaleDB 分支上是 hypertable(按 ts 分区),PG 要求分区表上的 唯一索引必须包含分区键,实测报: ERROR: cannot create a unique index without the column "ts" (used in partitioning) 而 ts 由服务端改写(同一帧补传时 ts 不同),放进唯一键就丧失幂等语义。 详见 deploy/init.sql 中 idx_metrics_device_seq 上方的注释。 ## 改法(不新增任何 Redis 往返) - store.pushFrameAndFanout(deviceId, frame, dedup):签名加可选第三参,null 时行为与 改动前完全一致;去重命令**追加在原来那一个 multi 末尾**,返回值改为 { subscribers, isNewSeq },并改用显式索引(原 res.length-1 会指错项)。 - gateway.onMetrics:按 `seqwin:<device>:<session_id>` 传 dedup。键里带 session_id 是刻意的 —— 新会话 = 新键,天然实现"换进程就重置窗口"。 - gateway._hydrateSeqWindow:内存窗口不存在时(网关刚重启/设备首次出现)用 zRange 从 Redis 读回最近 N 个 seq;在 onHello 里 await,**每设备只查一次**。 - 判据优先级:Redis 的 isNewSeq 优先(跨重启权威),内存窗口为兜底(零往返, Redis 不可用时用它挡住同进程重复)。 - 裁剪窗口与续 TTL 按 32 帧节流(首帧必发,保证键生下来就带 TTL)。理由见实测: 三条命令每帧都发时突发 p99 由 6ms 升到 13~15ms(Redis 单线程,突发约 1 万 multi/秒, 瓶颈是命令条数);节流后每帧 8 条命令,p99 回到改前水平。判重正确性不受影响。 ## 失败语义:与配对码**相反**,别"统一" 配对码守的是安全边界(判定不了就关门,失败关闭);去重守的是数据正确性 (判定不了就**放行入库**,宁可偶发重复一行,不可整段历史丢数据)。 故 pushFrameAndFanout 抛错时 catch 住、`_dedupDegraded++`、warn 日志、 把帧照常入队落库,并在 /health 暴露 `dedup_degraded`。代码注释里写明了这个区别。 ## 顺带发现并处理(请复核) 无 session_id 的老客户端(1.0.8 及更早)**刻意不落 Redis 窗口**: 没有会话标识就无法把窗口划到会话作用域,若按 `nosession` 建持久窗口, 老客户端进程重启后 seq 从 1 重来 → 全部命中旧窗口 → 被判重复丢弃, 正是当年"永久哑火"事故(这次上界是窗口 TTL,但一小时无数据同样是丢数据)。 实证:去掉这条守卫后连跑两次 tests/persistence-restart.js,第二次 "落库行数与上报帧数一致" 变成「期望 30 实得 0」。现行客户端 (client/lib/host/link.dart 的 _sessionId 每进程生成)不受影响。 ## 新增套件 tests/dedup-restart-test.js(22 项,已接入 run-all.sh) ① 重启后同会话重发同批 seq → 行数不变(E1 修复目标) ② 新会话从 seq=1 重发 → 必须能入库(win.sid 历史事故的回归守卫) ③ Redis 不可用 → 帧仍落库 + dedup_degraded 增加 + 补齐不抛 ④ 无 session_id 不建 Redis 窗口(见上) 变异自证:把 dedup 传参改成 false(等价"没接线")→ ①② 相关 3 项全红, 恢复后 22/22 绿。 ## 验证 - tests/run-all.sh:28 通过 / 1 失败(唯一红 = 隐私扫描的公网 IP 项,本次未触碰) 其中 持久化与重启恢复 24/24、去重跨重启 22/22、Rust 60/0、Dart 469/0 - cargo test --release:60/0(连跑 3 次;run-all 首跑偶发 transport::tests::backoff_grows_... 抖动一次,见回报) - client flutter test:469/0 ## perf-bench 前后(同机同一脚本,本地栈;含 3 次交错复测) 改前(HEAD ae90726)burst p50/p99 = 1/6、4/18、5/14、1/9 ms,recv 9434~9709 改后(本次) burst p50/p99 = 1/4、1/6、1/6、2/7、3/7 ms,recv 9302~9569 steady(定速 100fps)p50 0~1ms / p99 1~3ms,两边一致;churn p50 3ms、offer p50 1ms、 heap 增量 34MB→31MB 均在噪声内。p99 中位数由 14ms 降到 6~7ms,无明显回退。
## 结论:断言过严(抖动实现本身正确,未改退避算法) `apply_jitter_downward` 只向下扣 [0, capped*ratio],结果恒落在 [capped*(1-ratio), capped] 内,**绝不突破 max_ms** —— 上限这条硬承诺没被破坏, 抖动叠加方式也没有写错,不存在"源码 bug"那一路。 严格单调不成立是设计内的必然:封顶后 capped 恒为 60s,扣减量 offset = (now_ms() ^ capped.rotate_left(13)) % (span+1),每跨 1 毫秒 offset 就 +1, 于是后一个样本比前一个正好小 1ms(实测 59009 → 59008、50901 → 50900)。 原断言要求"相邻样本单调不减",等于要求这件不可能的事。 ## 复现(真复现,非推测) - 并行单跑该用例:48 次内命中 `退避应单调不减: [... 57955, 57954, 57954 ...]` - 变异回严格单调后,16 进程 × --test-threads=61 并发整包跑:第 1600 次命中 `[... 50901, 50901, 50901, 50900 ...]` 单跑命中率约 1/1000(12 次调用里只有封顶区那段约 1µs 窗口可能跨毫秒), 这正是它"平时全绿、负载高才红"的原因。 ## 改动 - transport.rs 主用例改按**档位表**断言(capped(n)=min(base*2^n, cap)): 每个样本必须落在本档位抖动带 [level*(1-ratio), level];相邻样本回落不得超过 一个抖动幅度(这才是"防退化"该守的不变量);末次必须真的顶进封顶带。 注释写清为什么不能要求严格单调,防后人改回去。 - 新增 capped_backoff_dips_within_jitter_band:在封顶区**跨毫秒**采样, 断言"必然观察到回落,且回落幅度 ≤ 一个抖动幅度",把"回落是正常行为" 钉成可执行断言(而不是靠注释约定)。 - 新增 Backoff::base_delay_ms / max_delay_ms / jitter_ratio 只读访问器: 测试不再重抄 1000 / 60_000 / 0.3,避免第二个真相源。 - buffer.rs 头注释 "±jitter" 与实现(只向下)不符,一并改正。 ## 变异自证 1) 把 dips 用例的容差断言改成 `after >= before`(严格单调)→ 确定性变红 3/3, 报文 `45363 → 45362`。 2) 把主用例容差断言改回 `w[1] >= w[0]` → 上述并发压测第 1600 次变红, 特征与原报告一致;恢复后 500 次整包并发复跑全绿。 ## 测试 改前 60 passed / 0 failed;改后 61 passed / 0 failed(净增 1 条)。 仅动 rust-core/src/buffer.rs 与 rust-core/src/transport.rs,server/ tests/ client/ deploy/ 未触碰。
## 缺陷 设备时钟被 NTP 调整(尤其回拨)时,喂给 Rust 状态机的 now 会倒退,而 transport.rs 的重连判断是**绝对值比较**: transport.rs:570 if self.reconnect_at_ms != 0 && now >= self.reconnect_at_ms `reconnect_at_ms` 是 on_disconnected 时用 now + delay 算出来的 (transport.rs:511 / 577)。墙钟一回拨,它就落在"未来"→ 条件长期不成立 → **重连挂死**:界面只有灰灯,不报错、不留痕,用户只能说"连不上了,重开就好"。 三处病灶(都已核实): · client/lib/host/link.dart:273 static int _now() => DateTime.now()... · client/lib/host/viewer.dart:761 同上 · client/lib/link/link_hub.dart:234 `_monotonicishMs() => DateTime.now()...` 而它上一行注释写着"刻意不用 DateTime.now()" —— 注释写了正确意图, 实现和函数名(monotonicish = 假单调)都是错的。 ## 改法 新增 client/lib/host/clock.dart(纯 Dart,不 import flutter,与 timing.dart 同风格): · `wallMs()` —— 墙钟,只用于线格式/落盘/日志 · `MonotonicClock` —— **真单调**:构造时取一次墙钟作基准, 之后 `originMs + Stopwatch.elapsedMilliseconds` · `processNowMs()` —— 进程默认单调读数(各处的默认注入值) · `debugOverrideProcessClock()` —— 仅测试用,验证"默认值确实走单调时钟" 刻意不是"把 DateTime.now() 包一层":那样回拨问题原样存在,等于白做。 用 Stopwatch 而非墙钟的好处是增量真单调;基准取墙钟则让读数仍贴近真实时间, 日志/排查时看得懂(不会变成"启动后第 12 毫秒")。 三处调用点改为走该时钟,且都可注入(`ClientAgent(nowMs:)` / `ViewerSession(nowMs:)` / `LinkHub(nowMs:)`,后者本来就有这个缝)。 link_hub 的假单调函数删除,注释改写为真实原因(并留下这段历史教训)。 ## 故意保留墙钟的地方(改了就是新 bug) | 位置 | 用途 | 为什么必须墙钟 | |---|---|---| | link.dart:294 | 日志行前缀 [HH:MM:SS] | 人要读"几点发生的事" | | link.dart:541 | 生成 session_id 的时间戳段 | 给人看、可排序的标识,不参与间隔计算 | | link.dart:557 | agent:hello 信封 ts | 线格式字段,服务端据此排障/落库 | | viewer.dart:1036 | viewer:subscribe 信封 ts | 同上 | | viewer.dart:1318 / 1378 | lastFrameLocalMs | 界面用 DateTime.now() 与它相减算"离线多久" | | viewer.dart:1579 | 缓存 saved_at | 下次启动要跟真实"上次保存时间"比,单调基准跨进程不可比 | ## Rust 侧持久化结论:**没有任何持久化**(换基准安全) 依据: · rust-core 全仓无 `fs::` / `File::create` / `write_all`(grep 无命中), 状态机不落盘、不跨进程传递; · `reconnect_at_ms` 只是 `Transport` 的普通字段 (transport.rs:465 定义、495/503/511/531/570/577 使用),生命周期 = FFI 句柄对象; · `Serialize/Deserialize` 只挂在 `Envelope`(transport.rs:134),不含状态机时间戳; · 故"进程内单调基准"完全安全,不需要动存档格式。 一处**既有**的轻微混用(本次未改,rust-core 不在边界内):`with_capacity` 用 Rust 自己的 `now_ms()`(系统时钟,transport.rs:487)给 HalfOpenDetector 播种, 而宿主喂的是单调值 —— 两者只差"进程启动→建句柄"的毫秒级偏移,且所有比较都走 saturating_sub,不会出现负数或大跳。若要彻底单一时间源,可让 host 在 onConnected 首次喂值时覆盖播种(建议另开一条)。 ## 新增测试 client/test/clock_test.dart(16 项) ① MonotonicClock:10 万次调用严格不减 / origin 权威(origin=0 时读数必须是个位数 毫秒级 —— 这一条能抓住"包一层 DateTime.now()"的假实现)/ 贴近墙钟 / 实例独立 / 注入可恢复 ② **真 Rust 状态机**(走 FFI,不是模拟):正常推进到点才重连(差 1ms 不触发); 回拨 1 小时连喂三拍全 Idle(缺陷本体的证据);同一实例改喂单调值立刻 ReconnectNow; 前跳 1 小时只触发 1 次 ReconnectNow(不连发,避免唤醒后连接风暴) ③ ViewerSession:注入脚本时钟 → policy 收到的 now 就是脚本值,且全部不是墙钟量级; 等 700ms 让 500ms 事件循环跑一拍 → nextAction 收到的 now 也来自时钟且单调不减 ④ 默认路径:LinkHub / ViewerSession 不传 nowMs 时确实用 processNowMs ⑤ 源码结构守卫:三处不得退回 `_now() => DateTime.now()`,不得再出现假单调函数名, clock.dart 必须真的用 Stopwatch 算增量(与 theme_guard_test 同一套纪律) ## 变异自证(逐个注入 → 变红 → 恢复 → 绿) 1) clock.dart 的 nowMs() 改回 DateTime.now()(假单调)→ 3 项红(含"origin 权威") 2) viewer.dart 忽略注入时钟、改回 DateTime.now() → 4 项红,含两条 ★ 行为断言 ("policy 收到的 now 就是脚本值" / "事件循环 now 单调不减") 3) link_hub.dart 默认时钟改回 DateTime.now() → ④ LinkHub 用例红 4) link.dart 默认时钟改回 DateTime.now()(采集端无行为观测缝)→ ⑤ 源码守卫红 恢复后:16/16 绿。 ## 测试数字 - cd client && flutter test:**485 通过 / 0 失败**(基线 469,净增 16) - cd rust-core && cargo test --release:**61 通过 / 0 失败**(未动 rust-core)
## 缺陷(最后一个数据正确性缺陷,与 E1 同级) gateway.js 的 `_flushStorage()` 旧实现是「先把队列整个 splice 走,再 try/catch 只 log」: const batch = this._storageQueue.splice(0, this._storageQueue.length); try { await this.repo.insertMetricsBatch(batch); } catch (e) { this.log.error('指标批量落库失败', e.message); } catch 之后批次**已经不在队列里**:PG 抖动 / 磁盘满 / 连接池耗尽时, 一次失败丢掉**整批(最多 5000 帧)** —— 不留计数、不重试、`/health` 完全正常。 对照同文件"队列满丢最旧"那条路径(_storageDropped + 周期 warn),这条是裸的。 与"静默丢数据"同源,只是这次连丢都没记。 ## 为什么用**独立重试槽**,而不是把失败批次放回主队列 放回主队列是最直觉的写法,但有两个硬问题: ① 与"队列满丢最旧"语义打架:`_enqueueForStorage` 满时会 shift 掉**队首**, 把失败批次放回去等于让"最该被救的旧数据"变成"最该被丢的旧数据"; ② 毒丸(某帧的值始终违反约束,重试多少次都失败)会永久占据队首, 把后面所有**新数据**一起堵死 —— 一条坏帧拖垮整台设备的落库。 故改为 `this._storageRetry = { batch, attempts, lastError }` 独立存放。 ## 毒丸如何隔离 每轮 `_flushStorage()` 的顺序是:**先试重试槽一次 → 然后无论成败都照常处理主队列**。 · 旧批失败**绝不阻塞**新数据落库(这是本缺陷最关键的性质); · 不在同一次 flush 内忙等重试(会把 2 秒定时器拖住,让"暂时抖动"滚成"持续积压"); · 有界:attempts 超过 `_storageMaxRetries`(默认 3,即首次 + 3 次 ≈ 8 秒窗口) → 调 `_discardStorageBatch()` **明确丢弃**; · 重试槽已被占用时新失败的批次不挤掉它(挤掉等于"更早的数据先死"), 也不攒着(内存必须立刻有界),同样走"明确丢弃 + 计数"。 ## `_storageFailed` 的语义(与 `_storageDropped` 不是一回事) · `_storageDropped` = 队列满,主动丢**最旧**(有设计的有界策略,正常压力下的取舍) · `_storageFailed` = 写库失败、重试也救不回来而**明确丢弃**的**帧数** (>0 就意味着真丢数据,要查 PG/磁盘;`/health.storage_failed` 直接暴露) 告警节奏:首丢必报一条(带帧数、已尝试次数、错误原文、前 3 帧 `device_id@seq`, 便于事后判断丢了哪台设备的哪一段、要不要补),之后每累计 100 帧一条, 对齐 `_storageDropped` 的节奏,避免持续故障时每 2 秒刷一条把日志淹了。 `/health` 另加 `storage_retry_attempts` / `storage_retry_frames`: 运维据此区分"暂时抖动(attempts 在涨但 finally 落库)"与"PG 一直不可用"。 `drainStorage()` 改为最多 3 轮:一次 flush 只给重试槽一次机会,关闭时多给几轮, 仍救不回来的打印一行"关闭时仍有 N 帧未能落库"(不静默)。 ## 顺带删一处死代码 `_releaseSeqWindow(deviceId)` 全仓零调用(只有定义),已删除并在原位置留注释说明 为什么现在不需要它:E1 之后去重窗口的权威副本在 Redis,断连**刻意不删** (那正是"重启后补传仍能判重"的依据,靠 TTL 收敛);内存窗口只是兜底判据, 条目数由 `_seqWindowMaxDevices` 的 LRU 上限约束,不需要断连时主动回收。 ## 新增 tests/storage-failure-test.js(29 项,已接入 run-all.sh 离线段) 1. 有界重试:批次进槽不消失 → attempts 1..3 → 超限明确丢弃(6 帧计数准确)、 总尝试 4 次、丢弃告警只 1 条且含帧数/次数/错误/样本 2. 重试成功后不丢数据:前 2 次抛错、第 3 次成功 → 该批 4 帧**原样补上**(seq 完整)、 `_storageFailed` 保持 0 3. **毒丸隔离(最关键)**:含 dev-poison 的批持续失败,同时灌入新帧 → 新帧照常落库(3 → 6 帧),毒丸最终被丢弃且不计入新帧 4. 多次丢弃:累计帧数 5 / 批数 2 准确,告警仍只 1 条(未到 100 帧阈值不刷屏) 5. 回归:正常路径 `storage_failed` 恒 0、队列满 `storage_dropped` 语义不变、 `drainStorage()` 把重试槽那批也刷净 6. 真进程 `/health`:`storage_failed=0`、`storage_retry_attempts=0`、 `storage_retry_frames=0` 且 `storage_dropped` 仍在(PG/Redis 不可达时明确跳过) ## 变异自证 把 `_flushStorage` 的失败分支改回旧实现(只 log、批次消失)→ **16 项红**(第 1 段 7 项全红、第 3 段"进槽/最终丢弃"2 项红、第 4 段 3 项红、 `drainStorage` 1 项红);恢复后 29/29 绿。 注:第 3 段那两条 ★"新帧照常落库"在变异下仍绿 —— 因为旧实现是"立刻丢", 同样不阻塞;它们守的是"将来别为了重试而阻塞新数据",与本缺陷的计数/重试断言互补。 ## 三侧数字 - GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败** (30 个套件;唯一红仍是隐私扫描,本次未触碰) - cargo test --release:**61 通过 / 0** - cd client && flutter test:**485 通过 / 0**
## 改判内容(team-lead 复核结论) 上一版(58f82a6)在"重试槽已被占用"时把新失败批**直接丢弃**,只让最早那一批 享有重试特权。幂运算一下就看出问题:PG 抖动 10 秒(2 秒一轮 → 5 轮)时, 轮 1 批 A 进槽(有救),轮 2/3/4 的批 B/C/D 全丢,轮 5 槽 A 超限丢、批 E 也丢 —— **10 秒抖动只救回一批**,等于把"每批都丢"降级成"每 2 秒丢一批", 而短暂抖动恰恰是重试最有效的场景。故改为**合并进槽**。 ## 实现 - 槽的数据形态不变(`{ batch, attempts, lastError }`),但 `batch` 现在是 **多轮合并累积**的帧数组:失败时一律 `concat`,不再"占用即丢弃"。 - **有界性靠槽容量,不靠丢新批**:合并后若超过 `_storageQueueMax`(默认 5000, 与主队列同一上界)→ **截断**,保留**较早**的帧(先保已经等了更久的数据), 超出部分走 `_discardStorageBatch(..., '超出重试槽上限 N 帧')`:计入 `_storageFailed` + 告警,**明确丢弃不静默**。于是重试槽内存上界 = 主队列上界, 重试不会让内存膨胀。 - `attempts` 语义改为**槽的重试轮次**(每轮 flush +1,首个失败轮记为 1), 不再是"某一批的尝试次数"。超过 `_storageMaxRetries` → 整槽丢弃。 - 顺序不变(上一版写对的地方保留):**先试槽 → 无论成败都照常处理主队列**, 旧批失败绝不阻塞新数据;不在同一次 flush 内忙等重试。 ## 附带发现:默认重试窗也要放宽(这一条请复核) 改完合并后我用临时量具实测"PG 抖动 10 秒"(5 轮 × 4 帧 = 20 帧,第 6 轮恢复): - 默认 `storageMaxRetries = 3` → 只救回 **8/20** 帧(第 4 轮 ≈8 秒整槽被丢,丢 12 帧) - 因为合并后 attempts 记的是**轮次**,上限 3 意味着"只覆盖 4 轮 ≈ 8 秒", 与"要覆盖一次真实抖动"的意图不匹配。 故默认值改为 **9 轮 ≈ 20 秒窗口**(2 秒/轮 × (1+9))。放宽不增加内存:槽容量由 `_storageQueueMax` 硬约束,窗口长短只影响"抖动期间攒多久"。改后同一场景 **20/20 全部救回、`storage_failed` = 0**。上限机制本身仍由测试 1(显式传 `storageMaxRetries: 3`)钉住。 三个版本在同一量具下的实测对照: | 版本 | 落库 | storage_failed | |---|---|---| | 最初(只 log) | 0/20 | 字段不存在(静默) | | 58f82a6(槽占用即丢) | 4/20 | 16 | | 本次(合并 + 20 秒窗) | **20/20** | **0** | ## 测试(tests/storage-failure-test.js:29 → 41 项,全绿) 新增: - **3b 跨 flush 合并**:3 个周期各失败 3/4/2 帧 → 槽内 **9 帧**、`attempts` = **3** (不是 9);第 4 轮 PG 恢复 → **9 帧全部落库**、seq 1..9 顺序完整、`_storageFailed` = 0 - **3c 合并仍受容量约束**:槽上限 10,连续失败凑 15 帧 → 槽里只留 10 帧、 另 5 帧计入 `_storageFailed`、**只告警一次**、保留的是较早帧(1..10) - **3d 抖动 10 秒(默认窗口)**:5 轮 20 帧全攒槽、恢复后 20 帧一次落库 原有 5 条全绿(含"失败不阻塞新数据"毒丸隔离、重试成功不丢数据、计数/告警节奏、 队列满丢最旧语义不变、`drainStorage` 刷净)。 另:把 1/3/4 段的重试上限**显式**传 3,让"上限机制"与"抖动窗口"两件事在测试里 各自独立可读。 ## 变异自证 把失败分支改回旧实现(只 `log.error`、批次消失)→ **41 项里 27 项红**: 第 1 段 8 项全红、第 2 段 2 项、第 3 段 2 项、**3b 4 项、3c 5 项、3d 2 项**、 第 4 段 3 项、`drainStorage` 1 项;恢复后 41/41 绿。 (顺带加固了 3b/3c 的空槽判定为 `?.`,让回归时"报红"而不是抛异常中断整个套件。) ## 三侧数字 - GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**(30 套件;唯一红仍是隐私扫描,未触碰) - cargo test --release:**61 / 0** - cd client && flutter test:**485 / 0**
team-lead 批准 9 轮窗口的前提是"注释里写明依据",否则后人看到 9 会以为 是随手写的数。补清三点: · 9 轮 ≈ 18~20 秒(flush 周期 2 秒),覆盖 PG 重启 / 连接池耗尽后恢复的典型时长; · 放宽**不增内存**:上界由槽容量 _storageQueueMax(5000 帧)硬约束, 窗口只决定"抖动期间攒多久",两件事必须解耦、不要合在一起调; · 改回 3 会让 tests/storage-failure-test.js 的 3d 段("PG 抖动 10 秒必须全救回") 立刻变红,并直接说明"10 秒抖动救不回"。 纯注释改动,无行为变化。验证:storage-failure-test 41/41 绿; 10 秒抖动量具仍为 20/20 落库、storage_failed=0。
对应架构评审 2.6 节(P0-6):中继/PM-4 选②(先明示)、P2P/PM-5 选②(下线)。 ## A1 中继大文件:明示上限(不做流控) `RELAY_MAX_BYTES=1MiB` 让服务端从"卡死"变成"明确拒绝",但用户是 **选完文件、点了发送、进度条不动**之后才知道 —— 界面在骗人。 现在两处改: 1. `transfer_card` 的发出区在**还没选文件时**就把话摆在按钮下面: 「跨网时走服务器中继,当前单次上限 1.0 MB;更大的文件请在两台设备处于 同一局域网时传(直连不受此限制)」; 2. `_pickAndOffer` 里在 `openRelayForSend()` **之前**用纯函数 `relayLimitWarning(size)` 拦一道:超限则撤销本地票据、**不发起传输**、 把原因显示出来("没有开始传输"说清楚,别让人以为在等)。 阈值只有一处来源:`net/relay_client.dart` 的 `kRelayMaxOnceBytes`,注释指向 `server/gateway/relay.js:44-47`(服务端 `MAX_ONCE_BYTES`),界面不再手写 1048576。 文案已避开"监视/监听/追踪"类词(合规红线),并有测试守着。 ## A2 P2P 下线:隐藏档位,不删代码 按 2.6.2 的五条动作(第 4 条 Rust 事件常量已在 03b30c1 完成): 1. `viewer_page.dart` 数据路菜单摘掉 `p2p` 与 `relay`,只剩「自动 / 内网直连 / 云端订阅」;`onSelected` 同步去掉这两个分支。 `relay` 一并摘掉的理由:它只服务**文件面**(PM-E7),放在指标路菜单里 同样是"界面在说谎"。 2. `link_mode.dart`:`LinkMode.p2p` 枚举值保留(协议词汇/评分表/既有测试都引用), 加"待接线"注释;`relay` 同注。新增 `isModeSelectable()`(可选项的唯一判据) 与 `normalizePreferredMode()`(偏好收敛点)。 3. `link_hub.dart`:推荐逻辑**排除**已下线档位(`isModeSelectable` 过滤 candidates + 手动偏好分支复检),**不改评分权重** —— 只排除产出; 将来接线后权重原样生效。 4. `stun.dart` / `p2p_driver.dart` 文件头各加"待接线"说明,写明 "`stun_test.dart` 是真测试、保留;生产路径未调用"(依据:`stunDiscover()` 全仓唯一调用点是测试;`publishOffer` 两处注入点都没传 `candidates`)。 5. 没删任何 P2P 代码,没动既有 stun/p2p 测试。 ## 旧偏好回落(摘菜单后的必答题) 存量的 `p2p`/`relay` 偏好一律收敛成 **null = 自动**,收敛点只有 `normalizePreferredMode()` 一处,在三个入口生效:`ViewerSession.setPreferredMode`、 `LinkHub.setPreferredMode`、`viewer_page` 菜单勾选态。 不崩、不空白、不留"选不回来又不生效"的勾选态。 ## 测试 新增 `test/link_mode_test.dart`(8 项):可选项只有 3 个 / p2p+relay 不可选但枚举值仍在 / normalize 收敛 / hub 与 session 收到旧偏好都回落 / 只有 p2p 可达时推荐为空 / p2p 分数更高也不抢推荐 / 内网照常推荐。 `test/transfer_card_test.dart` +5 项:1 MiB 以内不提示 / 超限文案含"多大+上限+没开始" / 必须给出"换同一局域网"的可行动作 / 合规禁词 / **卡片在未选文件时就显示上限**(widget 级)。 `test/link_hub_test.dart` 改 1 处:那条"手动指定不可达模式 → 回落自动"原本用 `relay` 举例,relay 已下线会走不到该分支,改用在线档位 `lan`(用例意图不变)。 ## 验证 - `flutter test`:全绿(本文件改动前的共享树基线 485,本分支新增 13 项; 另有 engineer-2 并行新增的 16 项 app_paths 测试,合计 514) - `dart analyze lib bin`:**No issues found!**(不用 flutter analyze:本机中文路径会让它崩)
03b30c1
对应架构评审 2.5 节。此前 `bin/pmon_agent.dart` + `bin/pmon_install.dart` (install/uninstall/status/restart/verify)都已就绪,但 **GUI 一个入口都没有**, 用户得自己敲命令 —— 等于这个能力不存在。 ## 这一笔包含什么(GUI 侧) - 新增 `lib/host/resident_service.dart`:GUI 与 `pmon-install` 的**唯一接缝**。 只做三件事:**找到 CLI → 跑它 → 把 `status --json` 解析成类型**。 · 不在 GUI 里做 launchctl/plist:沙箱 GUI 写不了 `~/Library/LaunchAgents/`, 自启产物内容由 Rust 生成、落盘由非沙箱 CLI 执行;GUI 再写一套就是第二套自启逻辑。 · 不在 GUI 里自己 stat 文件/pgrep 进程:判据只有 CLI 那一处。 · `ResidentState` 三态(在跑 / 装了没起来 / 未安装)+ unknown(问不到)。 unknown **不猜**:字段缺、执行失败、输出不是 JSON 都如实说"看不清"并带原因。 · CLI 定位按三级回落:装好的 `installBinDir/pmon-install` → 开发树 `build/pmon-install` → `dart run bin/pmon_install.dart`;都没有就报 "先 dart compile exe ...",**不假装已安装**。 - 新增 `lib/ui/resident_card.dart`:卡片本体。 · 三态色点 + 一句话状态;"装了但没起来"必须说清判据(自启未加载 / 进程没起) 并给日志路径 —— 这一态最容易被误解成"装过就好了"。 · 按钮:安装并启动(未装)/ 重新安装并启动(装了没起)/ 卸载(已装)。 · **失败回显 CLI 原文**(stderr 尾部):P0-6 已让 installer 返回非零并打印 哪条命令失败,卡片把那几行原样显示(可复制),不用"安装失败"盖掉。 · 5 秒低频轮询(状态在外部进程里,事件拿不到;5 秒够"改完能看见")。 - `lib/main.dart`:采集页 dashboard 挂载,**仅桌面端** (`core.canRunInBackground && !AndroidForeground.isAndroid` —— Android 的常驻 是前台服务,语义不同)。 · `onResidentStarted` → 停掉 GUI 自己的采集:两个进程同时上报会把两条 seq 流 混进同一 device_id,服务端按会话去重判不清 → 丢帧或重复帧,且不报错 (与 `_start()` 里 AgentLock 那条同源)。 - `test/theme_guard_test.dart`:登记 `lib/ui/resident_card.dart` (新增 UI 文件必须进 `_uiFiles`,这条吃过亏)。 ## 测试(新增 21 项) - `test/resident_service_test.dart`(13):解析四类判据 / 三态判定只在一处 / 字段缺失不崩不猜 / **失败带具体原因** / 输出非 JSON → unknown / 真机噪声(`dart run` 把 "Running build hooks..." 拼在 JSON 同一行)仍能解析 / CLI 路径从 `installBinDir` 派生 + 三级回落 + 找不到给明确指引。 - `test/resident_card_test.dart`(8):未安装 / 在跑(含 pid、且不再给"安装"按钮)/ 装了没起来(说清判据 + 日志路径)/ 状态未知如实说 / **失败原因原样可见** / 安装成功才通知宿主停采集 / 安装失败不停采集 / 卸载回未安装。 ## 验证 - `flutter test`:**540 通过 / 0 失败**(本笔新增 21 项) - `dart analyze lib bin`:**No issues found!**(不用 `flutter analyze`:本机中文路径会让它崩) ## 与并行任务的边界(重要) `pmon-install status --json` 那一半(CLI 侧 `statusSnapshot()` + `--json`) 落在 `client/bin/pmon_install.dart`,而该文件正被 **software-engineer-2** 的 路径收敛任务改写中(未提交)。为不把他的在途改动卷进我的提交,本笔只含 GUI 侧; CLI 侧与 `test/installer_status_json_test.dart` 紧随其提交后单独落一笔。
`test/installer_status_json_test.dart`(4 项)。它 import `bin/pmon_install.dart` **在同一进程里**跑一遍 `statusSnapshot()`,再把结果 `jsonEncode → jsonDecode` 喂给 GUI 侧的 `ResidentStatus.fromJson`,断言四类判据(自启注册态 / 进程 / 配置 / 日志)两端字段名对得上。 ## 为什么必须有这条 `pmon-install status --json` 与 `ResidentStatus.fromJson` 是**同一条契约的两端**。 任一端改字段名,另一端不会报错,只会静默退化成"状态未知"——卡片永远显示 "看不清",而日志里没有任何异常。这与本项目当年"指标字段四处各写一套、交集只剩 3 个字段、静默丢数"是同一类故障(那次是靠 schema-consistency-test 才抓住的)。 故这里不测"CLI 能不能跑通"(真机验),只测**两端字段是否还对齐**。 ## 断言要点 - 四类判据齐全(缺一 GUI 就判不出三态); - `jsonEncode(snapshot) → fromJson` 后 state 落在合法三态内(若变成 unknown, 说明字段名漂移了); - `config.path` / `log.path` / `installed.agent_path` 三个路径字段非空 (这三条正是 engineer-2 路径收敛与本卡的交汇点); - `status --json` 返回 0(只读命令永不失败退出); - 人话输出与 JSON **共用同一份快照**(同一条断言保证"判据只有一处")。 ## ⚠️ 已知的串联顺序(必须连着落地) 本笔单独存在时会**红**:GUI 侧读的 `statusSnapshot()` 落在 `client/bin/pmon_install.dart`,而该文件正被 software-engineer-2 的路径收敛 任务改写(未提交,且两人的改动都集中在 `_status()` 同一处,非交互环境下 `git add -p` 用不了),按 team-lead 的决定由他一次性合并提交(commit message 里 会注明那段由我实现)。他那一笔落地后本测试即绿。 若有人 bisect 到本笔,看到的是"契约测试先行、实现紧随",不是回归。 验证:working tree(含未提交的 `statusSnapshot`)上 `flutter test test/installer_status_json_test.dart` → 4/4 通过; `dart run bin/pmon_install.dart status --json` 真机输出四类判据齐全。 ## 更正(数字,勿再照抄旧账) `698b680` 的 message 写「新增 21 项(13 + 8)」,**实际 B 的 GUI 侧为 22 项** (`resident_service_test.dart` **14** + `resident_card_test.dart` 8 —— 中途补了 「`dart run` 把 `Running build hooks...` 拼进 JSON 同一行」那条用例,从 13 变 14)。 `698b680` 未 amend,以保持"当时验证的是什么"可复核;此处补记以保证 `git log` 自洽。 逐文件实测:`link_mode_test` 8 / `transfer_card_test` 14(原 9 + 新 5)/ `resident_service_test` 14 / `resident_card_test` 8 / `installer_status_json_test` 4, 合计新增 39;`540 = 485(基线)+ 39(本任务)+ 16(并行任务 `app_paths_test`)`。
## 缺陷(合并前四处口径互相矛盾) - `install_paths.dart:27` 安装根目录**硬编码 macOS 路径**,零平台分支 - 同文件 `:33` 可执行文件名不带 `.exe`(Windows 上不可执行) - `app_config.dart:245-260` 反而有 macOS/Windows/其它三分支 - 动态库文件名另有 3 份(`core_lib.dart:17-21`、`pmon_install.dart:89` 二分支、 `:287` 三分支);plist 路径 `pmon_install.dart:184`/`:271` 各写一遍 后果不是"不好看",是**装得进、读不到**:Windows 上 agent 落到 `%USERPROFILE%\Library\Application Support\pmon_client\bin`,配置却写进 `%USERPROFILE%\AppData\Roaming\pmon_client`,两边都不报错。 ## 合并后的真相源:`client/lib/host/app_paths.dart` 两条**正交**维度都做成显式参数(不是靠 `if` 补): 1. **平台** —— `PmonPlatform` 参数化,且**路径语法随参数走**(`p.windows`/`p.posix`), 于是 Windows 口径能在 macOS 上被真测到(测真机形态,不是 posix 伪装)。 2. **进程形态** —— `PmonProc{installer, gui, guiSandboxed, injected}`。 Android 的 `getFilesDir()` 注入被**收进**这个参数,不再是并列的第二套机制。 消费方全部改为委托,不再复制任何路径字面量: `install_paths.dart`(保留全部既有 getter 名,bin/ 无需改调用点)、 `app_config.dart`(`ConfigStore._dir` 只剩一句 `dataDirFor`)、 `core_lib.dart`(动态库名取 `coreLibNameFor`)、 `pmon_install.dart`(`coreLibName` + `launchAgentPlistPath`)。 ## 沙箱维度:怎么处理的(架构文档的四段代码没覆盖,这里补上) 先核实事实:`client/macos/Runner/{Release,DebugProfile}.entitlements` 里 `com.apple.security.app-sandbox = true` —— **本项目 GUI 确实是沙箱的**, bundle id = `com.pmon.pmonClient`。所以这不是假想维度。 处理方式(不嗅探、不加注入通道): - 沙箱只体现为"家目录取值不同",不是另一套目录规则; `dataDirFor(..., proc: guiSandboxed)` 从**真实家目录**插入容器前缀, 与沙箱 GUI 拿自己 HOME(容器路径)算出的值**恒等**。 - 该等式写成了测试(三种形态各一条): macOS 非沙箱 → `安装目录 == GUI 数据目录`; macOS 沙箱 → `guiDataDirForInstallerView(h) == dataDirFor(容器家目录, gui)`; Windows/Linux → 配置目录同源(Linux 安装目录走 XDG 是有意的,见注释)。 - bundle id 有自动比对:测试读 `macos/Runner/Configs/AppInfo.xcconfig` 逐字核对。 ## 顺带修掉两个"假判断"(都在同一个文件,且直接决定 e2e 能不能说真话) 1. **`Future<int> main` 把退出码吞掉**:本机 SDK 实测 `int main` / `Future<int> main` 返回 3,进程退出码都是 **0**。即上一轮「安装器不再撒谎、返回非零」的修复 **到不了进程边界** —— 屏幕打印"安装失败",shell / CI 仍拿到 0。 改为 `Future<void> main` + `exitCode = await runCli(argv)`。 2. **`_agentPids` 只按 `pmon-agent --collector` 匹配**:会把**别人家**的采集端算进来 (本机就有一份 pid 1737 在跑),于是 install 假成功、uninstall 假失败。 改为按落位后的**绝对路径**匹配(`RegExp.escape` 防路径里的 `.` 当通配符)。 ## 新增 `tests/installer-clean-e2e.js`(26 项,已接入 run-all) 临时 HOME 隔离,从零走:落位 bin+dylib → 写配置 → plist → status 四项判据 → 卸载摘干净。要点: - 各平台期望路径在测试里**独立写死**(不 import Dart 口径)—— 否则口径写错时测试会"自洽地通过",等于没测。 - 特殊断言:**屏幕上说「安装失败」时退出码必须非零**(就是上面那个 P0)。 - `launchctl bootstrap` 在本环境必然失败(`Bootstrap failed: 5: I/O error`): 落位/配置/plist **无条件断言**,自启注册一项明确打印 ⚠ 并记 skip(附原因), 其它任何非零退出判失败。缺 dart / 缺 Rust 产物 / 缺采集端时 ⚠ 跳过并返回 0 (不静默放行、也不假红);跳过分支不打印汇总行,免得结构守卫把"没跑"看成"全绿"。 ## 验证(改前 → 改后) - `cargo test --release`:61/0 → **61/0** - `flutter test`:485/0 → **540/0**(含他人新增;本次新增 `app_paths_test.dart` 22 项) - `dart analyze lib bin`:**No issues found!** - `bash tests/run-all.sh`:30 套件 29/1 → **31 套件 30/1**(唯一红仍是隐私扫描,按要求未碰) - `node tests/installer-clean-e2e.js`:**26 通过 / 0 失败 / 1 跳过**(跳过=launchd 环境受限) - `guard-structure-test.js`:42/0(新增套件已通过"汇总晚于断言"守卫) ## 未做(清单外,只报告) - **macOS 沙箱下"配置读不到"仍未接线**:GUI 是沙箱的,读容器; 而安装器(非沙箱)与 launchd 拉起的采集端读真实家目录。路径层已能算出 两者位置(`guiDataDir`),但"配置究竟写给谁、要不要双写、GUI 怎么拿到"是 配置交接的决策,不属于本次路径合并,未擅自改。 - `pmon_agent.dart` 同样是 `Future<int> main`(退出码同样会被吞),不在本次边界内。 - 默认 label `com.pmon.collector` 在 `pmon_install.dart` 与他人新文件 `resident_service.dart` 各有一份(不是路径,未动)。
端到端验收抓到的真缺陷(不是理论问题,本机可复现)。 ## 血案形态 开发机上 `client/build/pmon-install` 是**几天前编译的旧二进制**,它不认识 `--json`,照旧打印人话 + 一行**配置 JSON**: === 配置 === {"server_url":"http://127.0.0.1:3000","device_id":"mac-autostart-test"} 而 `ResidentService` 原先的解析是「从下往上取第一个能解析成 Map 的行」—— 于是它抓到了**配置 JSON**,`ResidentStatus.fromJson` 得到: state=notInstalled、configPath=""、logPath=""、agentPath="" 卡片会一本正经地显示「尚未安装」—— **用户明明装过、而且在跑**。 这正是"形状对、内容错":接口对得上、值是错的,且不报错、不留痕。 ## 修法(两条,缺一不可) 1. **形状校验**:新增 `ResidentService.looksLikeSnapshot()` —— 只接受同时含 `autostart` 与 `process` 两段的 JSON。配置 JSON 缺这两段,直接不认。 2. **候选链逐级回落**:`resolveCliCandidates()` 返回全部候选(装好的 → `build/pmon-install` → `dart run bin/pmon_install.dart`),`status()` 逐个试, **只采用形状合规的那一个**;全都不合规时返回 unknown,并给出可操作原因 ("这个 pmon-install 不支持 `status --json`,请重新编译",附输出前 120 字)。 这样开发机上"编译产物过期"不再静默给错状态;生产上若用户只升级了 GUI 没升级 CLI,卡片会说"CLI 太旧",而不是谎报"未安装"。 `install`/`uninstall` 仍走第一个候选(那两个动作没有"输出形状"问题,失败原因原样回显)。 ## 测试(+3,共 17 项;全仓 543/0) - `looksLikeSnapshot`:只有 config 段的 JSON **不算**状态快照(血案本体); 缺 autostart/process 两段的不算;齐了才算。 - 过期二进制的输出 → **unknown**,绝不冒充 notInstalled;文案含"不支持"与 "dart compile exe"(告诉用户该做什么)。 - 候选链逐个试:过期那个被跳过,合规那个被采用,且断言调用顺序 `[过期二进制, dart]`(顺序本身也是契约)。 ## 真机端到端(验具走 GUI 真正走的那条路 `svc.status()`) ``` 候选 CLI 链: · .../client/build/pmon-install exit=0 输出: === 自启注册 === ...(旧版,无 JSON) · dart run .../bin/pmon_install.dart exit=0 输出: {"label":"com.pmon.collector",...} --- svc.status() 解析结果 --- state=running pids=[1737] autostart: registered=true loaded=true unit=~/Library/LaunchAgents/com.pmon.collector.plist config: exists=true path=~/Library/Application Support/pmon_client/config.json log: exists=false path=/tmp/com.pmon.collector.err.log agent: exists=true path=~/Library/Application Support/pmon_client/agent ✓ 端到端通过 ``` (`status --json` 的实现仍在 `client/bin/pmon_install.dart`,随 software-engineer-2 的合并提交落地;本笔只修**消费侧**,不碰他的在途改动。) 验证:`flutter test` **543/0**;`dart analyze lib bin` **No issues found!**
`'com.pmon.collector'` 原本在 `resident_service.dart` 与 `bin/pmon_install.dart` 各写一份。漂移的症状极隐蔽:GUI 查 A.plist、安装器注册 B.plist —— 两边都 "正常",只是问的不是同一个自启项,表现为"明明装了、卡片却说未注册"。 这与 install_paths / 指标字段 / 配对码是同一族缺陷,故同样收成一份常量。 本笔只含常量的**定义**与 GUI 侧消费者(纯 lib,自洽可红可绿): · 新增 `lib/host/autostart_label.dart` · `ResidentService.label` 默认值改为引用它 安装器侧那半行(`const _defaultLabel = kDefaultAutostartLabel;`)落在 `client/bin/pmon_install.dart`,该文件当前含另一位同学的在途改动,随他的 commit 一起落地;随之落地的还有源码守卫测试(字面量只许出现在 `lib/host/autostart_label.dart`)——守卫必须与两处消费者同笔,否则该 sha 单独 checkout 会红。 验证:dart analyze lib → No issues found;flutter test → 545/0
## 本笔包含什么 1. **进程探针三态化**(`_agentPids` → `probeAgentProcess()` / `AgentProcessProbe`) - 原实现 `Process.run('/bin/zsh', ['-c', 'pgrep -f ...'])`:**Linux 上 `/bin/zsh` 通常不存在** → `ProcessException` 直接抛穿 `install` / `uninstall` / `status`; Windows 上 `pgrep` 一定没有 → 返回空列表,于是"查不了"被显示成"未运行" (用户会去重装,而真问题是本平台没有探针)。 - 改为**不经 shell**直调 `pgrep -f <RegExp.escape 后的绝对路径> --collector`: macOS/Linux 通用,且路径含空格/引号时不再交给 shell 解析。 - 返回值带 `available`:`pgrep` 退出码 1 = 确实没匹配(唯一可信的"没在跑"), 其它非零 / `ProcessException` = **无法判定**。上层:`install` 不把"查不了" 算成失败(只 ⚠),`uninstall` 不把"查不了"说成"已停止"(只 ⚠), `status --json` 增 `process.probe_available/probe_tool/probe_error`, 人话输出显示「采集端: 无法判定(原因)」。只增字段,`running`/`pids` 语义不变, GUI 侧解析不受影响。 - **Windows 未实现**(刻意):写一条未经真机验证的 PowerShell `-like` 分支, 只会"看起来能判定",匹配一错就是又一次"用错事实下判断"。已留 `TODO(多端前置)` 并明确报"无法判定"。 2. **`tests/installer-clean-e2e.js` 新增 8 项 `status --json` 断言**(26 → 34 项) - 解析策略按 team-lead 要求与 GUI 侧一致:**从后往前**扫行、行内从后往前找 `{` 试解析 —— 因为 `dart run` 会把 `Running build hooks...` **拼在 JSON 同一行前面** (实测 `Running build hooks...Running build hooks...{"label":...}`), 按"整行是 JSON"判断会把唯一有效输出判成解析失败。 - 断言 `config.path` / `installed.agent_path` / `autostart.unit_path` 与真相源 按**临时 HOME** 推导的值逐字相等 —— 硬编码旧值不可能跟着临时 HOME 变, 故这是"路径收敛真的生效"的最强证据。 3. `statusSnapshot` 里 `log.path` 加 `TODO`:这是**唯一还没收敛**的路径 (Rust 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状, Dart 侧又写一份)。正确做法与 `allow_failure` 同款(让 `AutoStartPlan` 多返回 `stderr_log_path`),但需改 Rust 数据结构 + FFI 出口,超出"client 路径"边界,本轮未做。 ## 归属说明(team-lead 要求留痕) - `pmon_install.dart` 的 `statusSnapshot()` + `--json` 改动由**另一位工程师**实现; 因与路径收敛同处一个文件、非交互环境下无法按 hunk 拆分,实际已在上一笔 **c152ea2** 一并落地(该文件在 c152ea2 里是 151+/37-,即两人改动的合并体)。 本笔只在其上追加"探针三态化 + e2e 断言 + TODO",未改动其判据逻辑。 - 反向留痕:`51c85b0`(team-lead 记作 `3ee0630`,该 hash 在全部历史与分支中都不存在, 实际是 `51c85b0 test(常驻采集): 把「CLI 输出 ↔ GUI 解析器」两端的契约钉在一起`) 是"测试先行"那一笔 —— 它 import `installer.statusSnapshot`,而当时 CLI 里 `statusSnapshot` 出现次数为 **0**,单独 checkout 会因编译失败而红; **本笔的前置 c152ea2 使其转绿**。保留它不 revert,两侧都留痕,避免 `git bisect` 把"顺序"读成"回归"。 ## 验证(改前 → 改后) - `cargo test --release`:61/0 → **61/0** - `flutter test`:540/0 → **545/0**(含 `installer_status_json_test.dart` 4 项) - `dart analyze lib bin`:**No issues found!** - `bash tests/run-all.sh`:31 套件 30/1 → **31 套件 30/1**(唯一红仍是隐私扫描,未碰) - `node tests/installer-clean-e2e.js`:26/0 → **34 通过 / 0 失败 / 1 跳过** (跳过=launchctl `Bootstrap failed: 5` 环境受限,原因逐条打印) - `guard-structure-test.js`:42/0 ## 已核实的现场事实(供 team-lead 复核) `status --json` 在**真实 HOME** 与**临时 HOME** 下都取到正确路径,后者全部跟着临时 HOME 走,证明三个路径字段来自 `app_paths.dart` 推导而非硬编码: - 真实:`config.path=/Users/me/Library/Application Support/pmon_client/config.json`、 `installed.agent_path=.../bin/pmon-agent`、`log.path=/tmp/com.pmon.collector.err.log` - 临时:`config.path=/tmp/pmon-json-probe-xxxx/Library/Application Support/pmon_client/config.json`、`installed.agent_path` 同源变化、 且 `process.running=false`(**正版 pid 1737 不再被算进来**,路径作用域生效)
## 一、探针不可用 ≠ 没在跑(把上一轮糊回去的洞补上) 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)` 短路 → 「与界面输入不一致」报红,恢复即绿
## 这个洞怎么来的 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 实例"。 跨形态靠进程探测、同形态靠锁,各管一段。
## 依据:本 SDK 上 main 的返回值完全不设退出码(实测矩阵) Dart SDK 3.13.3,各跑 `dart run` 与 `dart compile exe` 两种形态: | 写法 | `dart run` | `dart compile exe` | |---|---|---| | `int main() { return 3; }` | **0** | **0** | | `Future<int> main() async { return 3; }` | **0** | **0** | | `void main() { exitCode = 3; }` | 3 | 3 | 即**只有显式 `exitCode =` / `exit()` 才生效**(官方文档说 `Future<int> main` 会设,本 SDK 不成立)。 所以 `cffdc50`「安装器不再撒谎、返回非零」的代码逻辑对,但**进程边界是死的**; `pmon_agent.dart` 同样是 `Future<int> main`,后果更重:plist 用 `KeepAlive.SuccessfulExit=false`,launchd 靠「非零退出」判崩溃 —— 退出码被吞成 0 等于**采集端崩了也不会被拉起**,用户只看到历史断档,无任何报错。 ## 改动:`bin/pmon_agent.dart` `Future<void> main` + `exitCode = await runAgent(argv)`;函数体抽成 `runAgent` (返回码语义清晰、可被测试直接调)。**语义逐条守住,没有一刀切归零**: | 退出点 | 改前 | 改后 | 理由 | |---|---|---|---| | `--help` | return 0(被吞成 0) | 0 | 帮助是正常完成 | | `--print-plan` 成功 | return 0 | 0 | 只打印,正常完成 | | `--print-plan` 且核心返回 error | return 0 | **1** | 用户要的「计划」没拿到(iOS 不支持/路径非法);退出 0 会让脚本以为拿到了产物 | | 抢不到单实例锁 | return 0 | **0(刻意不变)** | 非零会被 launchd 当崩溃立刻重启 → 两进程互踢成无限重启循环,比"没起来"更糟 | | `--duration` 正常到期 | return 0 | 0 | 冒烟/CI 的正常路径 | | Ctrl-C(SIGINT) | `exit(0)` | 0 | 用户主动停;非零会让 launchd 立刻拉起("关不掉的进程") | | 常驻等待(Completer) | return 0 | 0 | 理论不可达(只有信号或 kill 能结束) | | 未捕获异常(核心库缺失等) | 无 catch → 255 | **255(不变)** | 实测 `Future<void> main` 抛异常仍是 255;崩溃必须非零,否则 launchd 不重启 | 顺带把 `--print-plan` 里硬编码的 label 换成 `kDefaultAutostartLabel` (label 唯一真相源是 `host/autostart_label.dart`,不在 bin/ 再写一份)。 ## `pmon_viewer.dart` / `relay_check.dart`:**不需要改**,它们已经是显式 `exit()` 核对结果(`grep -c "exit("`): - `pmon_viewer.dart`:**7 处** —— `exit(4)`(缺 --account / 启动失败 / 缺 --device)、 `exit(3)`(配对失败 / 未订阅成功)、`exit(0)`(--list),末尾 `exit(ok ? 0 : 2)`。 - `relay_check.dart`:**2 处** —— `exit(same ? 0 : 1)`、catch 里 `exit(1)`。 `exit()` 比 `exitCode =` **更强**(立即终止并带上码),失败语义本来就到达边界。 我实测确认并写进了新套件:`pmon_viewer` 缺 `--account` → **4**,`relay_check` 打不通 → **1**。 顺带说明:`viewer-e2e-test.js:257` 那条 `cli.code === 0` 断言**因此是有效的**(不是死断言)。 为"统一写法"去把它们改成 `exitCode =` 是纯噪音,且会削弱语义,未做。 ## 新增 `tests/cli-exit-code-e2e.js`(11 项,已接入 run-all) **一律读子进程真实 exit code**(`spawnSync`/`spawn` 的 status),**不 import 被测代码断言返回值** —— 断言返回值正是这次漏掉的东西。覆盖: - 必须 0:`--help`、`--print-plan`、**抢不到单实例锁**、`--duration` 到期(后两条用"先起一个持锁实例再起第二个"构造,temp HOME 隔离) - 必须非零:`pmon-install install --agent-bin <不存在>`、`pmon-viewer` 缺 `--account`、`relay_check` 打不通网关 ## 变异自证 1. **旧写法复现缺陷**:把 `main` 临时改回 `Future<int>`,并让 `--help` `return 7` —— 新写法实测进程退出码 **7**;旧写法实测 **0**(7 被吞)。同一份代码、只改 main 签名。 2. **测试确实会红**:临时注释掉 `pmon_install.dart` 的 `exitCode = await runCli(argv);` → `cli-exit-code-e2e` ⑤ 变红(`exit=0`),`installer-clean-e2e` 的 「说失败⇒退出码非零」同时变红;恢复后两套全绿(11/0、34/0/1)。 ## 验证 - `dart analyze lib bin`:**No issues found!** - `flutter test`:**559/0**(含他人新增) - `cargo test --release`:**61/0** - `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰) - `node tests/cli-exit-code-e2e.js`:**11 通过 / 0 失败** - `guard-structure-test.js`:**44/0**
## 为什么必须补(不是防御性代码) `agentLockPath` = `ConfigStore.dir/agent.lock`,而 `ConfigStore.dir` 由 `HOME` 决定。macOS **沙箱** GUI 的 `HOME` 是应用容器,agent 由 launchd 拉起、`HOME` 是真实家目录: GUI → ~/Library/Containers/com.pmon.pmonClient/Data/…/agent.lock agent → ~/Library/Application Support/pmon_client/agent.lock **两个进程锁的是两个不同文件,谁也不挡谁** → 沙箱 GUI 里 `isHeldByOther()` 恒为 false。于是"agent 已在跑、用户又点了一次开始采集"→ 必然双写:两条 seq 流混进同一个 device_id,服务端按会话去重分不清,历史曲线出现无法解释的毛刺, **且不报错**。`da88e30` 只堵住了"装完之后"那个窗口,这条路径它堵不住。 ## 只修 macOS(不要把三个平台一起改) | 形态 | GUI 的 HOME | agent 的 HOME | 锁是否同一文件 | |---|---|---|---| | macOS 沙箱 | 应用容器 | 真实家目录 | ❌ | | macOS 非沙箱 / Windows / Linux | 同 agent | 同 agent | ✅ | 故 `probeInstalledAgent()` 在非 macOS 上直接返回 `notApplicable`,**一次都不执行 `pgrep`**(Windows 根本没有这个程序)。有一个测试专门守这条,防后人"统一成全平台"。 ## agent 路径为什么取自 CLI,而不是本地推导 沙箱 GUI 里 `installBinDirFor(macOS, homeDir)` 的 `homeDir` 就是容器路径,算出来 是"容器/…/bin/pmon-agent",用它 `pgrep` 永远匹配不到 —— 更糟的是会得到一个 看起来确定的 `notRunning`。CLI 是非沙箱进程,`status --json` 报的 `installed.agent_path` 才是 launchd 实际启动的那条(也是 installer 侧 `probeAgentProcess()` 用的同一处真相源)。 ## 判据 · `/usr/bin/pgrep -f <RegExp.escape(path + ' --collector')>` —— 绝对路径(沙箱 PATH 可能被裁剪)、转义(家目录里的 `.` 会当通配符)、带 `--collector`(与 plist 的 `ProgramArguments` 对齐,与 installer 同一套口径) · exit 0 → running;**exit 1 → notRunning(唯一可信的"没在跑")**; 其它非零 / `ProcessException` → `undetermined`(不能当成"没有") · `undetermined` → **不放行**:双写是污染数据,漏采只是缺数据 · 锁命中优先(它能挡住探针看不出的"两个 GUI 实例"),且命中后不再多花一次 CLI 往返 ## 一个如实记录的边界 `status()` 拿不到快照(打包后的 .app 且从未装过 CLI)时 `agentPath` 为空、无从 探测 → **放行**并留 `resident_probe_skipped` 日志。理由:若这也拦,等于让没装过 常驻采集的机器彻底无法采集(确定的永久性回归),换来挡住"装过之后又把 CLI 删了" 这个很偏的边界。这一条我在回报里也点了名。 ## 验证 - `flutter test` → **572/0**(新增 13 条) - `dart analyze lib bin` → No issues found - 真机(非沙箱调用方,agent pid 1737 在跑): `cli 报的 agent_path=…/pmon_client/bin/pmon-agent` → `探针结论=running` → `合成结论=后台常驻采集已在运行(进程探测确认),本界面仅查看(不会重复采集)` - 变异自证(三次,均已恢复): · 去掉 main.dart 的 macOS 分支 → 源码守卫 ⑤ 报红 · `exitCode != 0` 一律当 notRunning → 「其它非零 → undetermined」报红 · 去掉"非 macOS 不调探针"守卫 → 「Windows/Linux/Android 一次都不执行」报红
## 为什么单为一条日志加测试 `_guiCollectorBlockReason()` 里只有一处"不拦"的例外:`status --json` 拿不到快照 (打包成 .app 且从未装过 CLI)时 `agentPath` 为空、无从探测,于是放行。理由成立 (否则没装过常驻采集的机器彻底不能采集),但它是**唯一一处靠日志才能复盘的判断**: 将来有人问"这次为什么没拦住" → 没有日志就只能猜, 而猜出来的原因通常比没有原因更糟。 故加两条: 1. **落盘验证**:临时目录注入 `StartupLog.dir`,写一条 `resident_probe_skipped`, 读回 JSONL,断言 `e` 与 `f.reason / f.state / f.detail` 原样保留 —— 特别是 `detail` 不能被脱敏逻辑吞掉(那条是排障的唯一线索)。 2. **源码守卫**:`main.dart` 里必须留着这句 `StartupLog.event('resident_probe_skipped'`, 防"嫌麻烦删掉"。 ## 管道本来就在工作(实机证据,不是推断) 沙箱容器里的 `startup_log.jsonl` 现有 `boot ×3 / restore_done ×2 / started ×2`, 且 `main()` 第一段就 `StartupLog.dir = ConfigStore.dir`(先于任何 `_start`)。 也就是说这条链路是通的,新事件只是加了一个名字。 ## 验证 - `flutter test` → **574/0** - `dart analyze lib bin` → No issues found - 变异自证:把事件名改成 `resident_probe_SKIPPED_MUTATION` → ⑥ 的源码守卫报红,恢复即绿
dart compile exe
## 根因(不是我们的代码,是 SDK 行为变了) ``` $ dart compile exe bin/pmon_install.dart 'dart compile' does not support build hooks, use 'dart build' instead. Packages with build hooks: objective_c. ``` `objective_c` 是 `file_picker` 的传递依赖。于是 README 与历史脚本里的 `dart compile exe ...` **全部失效**,用户照抄必失败 —— 典型"文档在说谎"。 替代命令:`dart build cli -t bin/<x>.dart -o <dir>`,产物布局也变了: `<dir>/bundle/bin/<x>` + `<dir>/bundle/lib/<libs>`(多一层 `bundle/`,多出动态库)。 ## 1) "怎么构建"收敛到一处:`client/scripts/build_cli.sh` - 默认构建 `pmon_agent` + `pmon_install`,可追加其它入口(`pmon_viewer` 等)。 - 权威产物是 **bundle**(exe 与 lib/ 必须一起用),同时产出**扁平副本** `build/pmon-agent` / `build/pmon-install`,并把 `bundle/lib/` 的动态库平铺到 `build/`。 - 构建命令不再出现在别处:`client/README.md:98`、`:765-766` 已改为 `bash scripts/build_cli.sh`,根 `README.md:916` 的描述也改为指向脚本; `bin/pmon_agent.dart:13`、`bin/pmon_install.dart:6` 的注释同改, `pmon_install.dart` 找不到可执行文件时的提示也改为指向脚本。 ## 2) 安装器落位要带运行期库 `_install` 新增 `_runtimeLibsNextTo()`:覆盖两种产物形态 - bundle:`<bundle>/bin/<exe>` + `<bundle>/lib/<libs>` - 扁平:exe 与库同目录(脚本会把库平铺到 `build/`) 只按扩展名收 `.dylib/.so/.dll`,**不整目录拷贝**,并排除核心库(它单独落位,避免拷两遍)。 **依据(实测,不是猜)**: - `otool -L build/cli/pmon_agent/bundle/bin/pmon_agent` 里**没有** `objective_c.dylib` —— 它不是链接期依赖,而是运行期按需 dlopen 的库。 - 把 exe 单独拷到临时目录(不带该 dylib)后跑 `--help` 与 `--collector --duration 2`:**都 exit 0,日志无 dlopen/缺库错误** ⇒ 当前 CLI 已走到的代码路径**并不需要**它。 - 结论:拷贝是**自包含保险**(bundle/lib 是 SDK 为该 exe 声明的运行期依赖集), 不是修复一个正在发生的失败。代价一次文件拷贝;收益是将来若某条代码路径用到 ObjC 桥接,不会在运行期才报 dlopen 错、且错得与"装不上"完全不像。 第一版只在"exe 的 ../lib"里找库,实测**扁平形态装出来没有 objective_c.dylib** (脚本已把库拷到 build/,但 exe 的 ../lib 指向 client/lib);已补上"exe 同目录"这一支。 ## 3) `build/pmon-install`:**保留**,继续由脚本产出 理由不是"兼容旧习惯",而是:GUI 的常驻采集卡按候选链找 CLI, **第一候选就是 `build/pmon-install`**(`lib/host/resident_service.dart`)。 若它不存在,候选链只剩 `dart run` —— 而**最终用户机器上没有 Dart SDK**, 那条候选在真实安装里等同不可用,卡片会永远显示"找不到 CLI"。 故让脚本继续产出该路径,候选链才有意义。 ## 4) 完整验证:新命令构建 → 临时 HOME 安装 → 落位后真跑 ``` $ client/scripts/build_cli.sh dart: .../flutter/bin/dart 版本: Dart SDK 3.13.3 -- pmon_agent -- ✓ bundle: build/cli/pmon_agent/bundle ✓ 扁平副本: build/pmon-agent -- pmon_install -- ✓ bundle: build/cli/pmon_install/bundle ✓ 扁平副本: build/pmon-install $ ./build/pmon-install --help → exit 0 $ HOME=$TMP ./build/pmon-install install --no-start --label com.pmon.collector.flat ✓ 已落位: $TMP/Library/Application Support/pmon_client/bin/pmon-agent ✓ 已落位: .../bin/libpmon_core.dylib ✓ 已落位运行期库: .../bin/objective_c.dylib ✓ 配置: .../config.json ✓ 已写入: $TMP/Library/LaunchAgents/com.pmon.collector.flat.plist ✗ launchctl bootstrap → exit 5 Input/output error ← 本环境受限(老事实),失败语义正确非零 $ <落位目录>/pmon-agent --print-plan → exit 0,打出完整 plist 计划(核心库加载成功) $ HOME=$TMP ./build/pmon-install status --json → 路径全部指向临时 HOME,running=false $ HOME=$TMP ./build/pmon-install uninstall --label ...flat → exit 0,plist 已摘除 ``` ## 5) e2e 跟着更新(`tests/installer-clean-e2e.js` 34 → 37 项) - 采集端候选**优先取 bundle 形态**(`build/cli/pmon_agent/bundle/bin/`), 否则"落位要不要带运行期库"这条测不出来。 - 新增断言:`bundle 的运行期库已一并落位(objective_c.dylib)`。 - 新增断言:**落位后的可执行文件真的能跑** —— `--help`(exit 0) 与 `--print-plan`(exit 0 且输出含 `install_commands`,证明 exe + 核心库 + 运行期库三件齐)。 ## 验证 - `dart analyze lib bin`:**No issues found!** - `flutter test`:**574/0** - `cargo test --release`:**61/0** - `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰) - `node tests/installer-clean-e2e.js`:**37 通过 / 0 失败 / 1 跳过**(跳过=launchd 受限) - `node tests/cli-exit-code-e2e.js`:**11/0** - `guard-structure-test.js`:**44/0** ## 未做(边界外,需他人跟进) `client/lib/host/resident_service.dart:317/351/414` 与 `client/test/resident_service_test.dart:299` 里仍写着 `dart compile exe ...` 的提示文案 (那是 GUI 侧文件,本轮不碰)。建议替换为: `请先执行 client/scripts/build_cli.sh(产物 build/pmon-install)`。 另 `client/lib/**` 里"纯 Dart 才能 dart compile exe"的说法是**能力描述**(仍然成立), 未改。
log.path
`statusSnapshot` 里 `/tmp/<label>.err.log` 是唯一还没收敛的路径(Rust 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状,两边各一份)。 原注释只说了"应该让 AutoStartPlan 返回它",没写清**改成什么样**, 后人读到仍不知道落点在哪。按 team-lead 要求补上具体形态: 读 `AutoStartPlan.stderr_log_path`,**勿再在本地拼 `/tmp/<label>.err.log`** —— 与 `allow_failure` 同款:语义由 Rust 给,Dart 只消费。 仅注释改动,无行为变化。边界不变:需要改 Rust 数据结构 + FFI 出口, 不在"client 路径"这一轮范围内。 验证:`dart analyze bin` No issues found;`guard-structure-test.js` 44/0。
## 问题:我们把用户送到了一条死路 `resident_service.dart` 有三处**面向用户**的提示让用户敲 `dart compile exe bin/pmon_install.dart -o build/pmon-install`: - 找不到 CLI 时(`status()` 的 detail) - CLI 太旧、不认识 `--json` 时(`_statusOnce` 的 detail) - 安装/卸载前找不到 CLI 时(`_action` 的 message) 而 Dart 3.13 起该命令遇到 build hooks(依赖链里的 `objective_c`)**直接失败**, 用户照做后看到的是 `'dart compile' does not support build hooks` —— 比没有提示更糟,因为**他会以为是自己环境坏了**。 ## 改法:只指真相源,不抄命令 三处统一成:`client/scripts/build_cli.sh`(产物 `build/pmon-install`)。 构建入口的真相源是那个脚本(engineer-2 已把 README 与两个 bin 的注释/错误提示 一并改指它),这里不抄一份参数序列 —— 抄一份就多一处会漂移的副本。 ## 加了一条守卫,防它再长回来 `⑧ 构建命令只许指向能跑通的那条`: 1. 源码守卫:`resident_service.dart` 里不许再出现那个精确形态 (`dart compile exe bin/pmon_install.dart` —— 它只会出现在"让用户照着敲" 的位置),且必须含 `client/scripts/build_cli.sh` 2. 行为断言:`status()` 在"找不到 CLI"时给出的 detail 必须含 `build_cli.sh`; 装过 CLI 的机器跳过该用例(不假绿) ## 顺带确认:回落链不再被触发 新构建产物(`build/pmon-install`,6.2 MB,2026-10-27 23:46)实测**认识 `--json`**: 候选: …/client/build/pmon-install ← 第一候选直接答话 候选: dart run …/bin/pmon_install.dart ← 备用,未用到 state=running pids=[1737] `resolveCliCandidates()` 一个字符没改 —— 回落链从"今天的主路径"退回它本来的 身份(防御性代码),这正是不该把它写成主路径的理由。 ## 验证 - `flutter test` → **576/0**、`dart analyze lib bin` → No issues found - 真机 `status --json` → `state=running pids=[1737]`,三路径与 `serverUrl/deviceId` 解析正常 ## 备案(未做) `lib/` 里还有约 10 处文档注释用"能 `dart compile exe`"来解释"为什么这个模块 必须纯 Dart、不 import Flutter"。约束本身仍然成立(`dart build cli` 同样要求), 只是叫法过时。属文档口径整理,不在本笔。
## 改判:`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`(把"缺字段"当没证据)→ 「拿到在跑证据 → 停采集」报红
`tests/privacy-scan.js` 把 `/Users/<非白名单名>` 判成「高置信个人数据」,而我 在正则转义那条用例里用了 `/Users/me/Library/pmon(v2.3)/...` —— 那是**合成路径**,但扫描器 按形状判定,它照样红。 改成 `/Users/me/Library/pmon(v2.3)/pmon-agent`: - `/Users/me` 在扫描器白名单里(与 `resident_service_test` 等既有用例一致) - `(` 和 `.` 挪到目录名上,转义断言(`\.` / `\(`)一个都没减弱 工作树命中从 12 条降到 11 条(余下 6 条是我此前几笔提交历史里的同一字符串, 2 条是既有的公网 IP,3 条是 commit-msg 里的既有内容)。**历史那 6 条只能靠 重写历史清掉,不在本笔**(会连带重写并行同事的提交,需 lead 拍)。 验证:`flutter test test/agent_probe_test.dart` → 15/0;`node tests/privacy-scan.js` 不再报 `client/test/agent_probe_test.dart`。
## 现象(2026-10-27 实测) `$HOME/.cargo/bin` 不在 PATH 时(非登录 shell / CI / cron 都是这样): ``` tests/run-all.sh: line 52: cargo: command not found ◉ FAIL Rust 核心单测(59 项) 套件结果: 30 通过 / 2 失败 ``` 而单独 `cargo test --release` 是 **61/0**。也就是说脚本把**环境缺失**报成了一个 **测试失败**,汇总里那条红指向 Rust 代码 —— 排查方向被彻底带偏。这与之前修过的 "护栏假装在工作"是同一族:**不能让环境问题冒充被测对象的问题**。 ## 改法(两处 + 一个计数) 1. 脚本开头主动补 PATH:`$HOME/.cargo/bin/cargo` 存在就 `export PATH`, 否则退回 `command -v cargo`(允许自定义安装位置) 2. 仍找不到 → 新增 `skip()`,打印「⚠ 环境缺 cargo($HOME/.cargo/bin 不在 PATH?) ——这是环境问题,不是 Rust 代码的问题」,标 **◉ SKIP**,**不计入失败** 3. `skip()` 与 PASS/FAIL 三态分明:算 FAIL 会让人查被测对象,算 PASS 会把覆盖 缺口藏起来 —— 两者都是说谎。汇总行同步变成 `N 通过 / M 失败 / K 跳过` ## 验证 | 场景 | 结果 | |---|---| | PATH 含 cargo | 31 通过 / 1 失败(唯一红=隐私扫描,已知)| | **PATH 不含 cargo,但 `$HOME/.cargo/bin/cargo` 存在** | **自愈生效** → Rust 套件 PASS → 31 通过 / 1 失败(改前是 30/2 假红)| | 两者都没有(模拟 CI 无 Rust 工具链) | `◉ SKIP` + 原因说明,**失败计数仍为 0**(脚本不因此判失败)| `bash -n` 语法检查通过。第三种场景用假 HOME + 精简 PATH 单验过检测段与 `skip()`。
## 为什么必须补这句(team-lead 拍板采纳) 三个平台的 `install_commands`(真相源 `rust-core/src/keepalive.rs`)语义不同: | 平台 | 安装命令 | 装完是否立即在跑 | |---|---|---| | macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 | | Linux | `systemctl --user enable --now`(`--now`) | 是 | | Windows | `reg add`(HKCU Run 项) + `schtasks /SC ONLOGON` | **否,只在下次登录时起** | 于是 Windows 上「install 返回 0」**只承诺"已注册",从不承诺"已在跑"**。 可上游(GUI 常驻卡)曾把它读成"已启动",进而停掉自己的采集 —— 两个都没在采、界面还很平静。这行字把「启动」从**承诺**降级成**事实描述**。 ## 改动(`client/bin/pmon_install.dart`) 1. 新增 `launchSemanticsNoteFor(PmonPlatform)`(平台作参数的纯函数): · Windows → `仅在下次登录时启动` · 其它 → `安装命令本应已使进程立即启动,但本平台无法探测其是否在运行` 2. `install` **成功路径**打印 `说明:已注册 · <上面那句>`。 位置刻意放在 `hardFailures` 判定**之后**:安装失败时不该说"已注册"。 触发条件是"本平台有没有探针"(平台属性),与是否 `--no-start` 无关。 3. `status --json` 的 `autostart` 增 `note` 字段(探针可用时为 null; 不可用时为该句)—— 因为 Windows 上这行字是**唯一**的状态说明, GUI 读的是 JSON,不能只留在人话输出里。人话 `status` 也打印同一句。 ⚠️ **这是"平台 → 启动语义"知识的第二份**(第一份在 Rust 的 `install_commands`)。 不能改成嗅探命令字符串(本项目明令禁止判 `cmd.contains('bootout')` 那类)。 正解与 `stderr_log_path` 同款:让 `AutoStartPlan` 返回 `starts_immediately: bool`, Dart 只读不算。**本轮未做**(要改 Rust 结构 + FFI 出口),已写进注释, 并集中在**这一个函数**里,不允许出现第三份。 ## 测试 - 新增 `client/test/installer_launch_note_test.dart`(5 项):本机是 macOS, 跑不到 Windows 分支,故把平台做成参数逐条断言 —— 与 `app_paths_test.dart` 同一手法。 含反面对照(Windows 那句不得出现"已启动";macOS/Linux 那句不得出现"登录"), 以及两条**形状护栏**:读 `keepalive.rs` 断言 Windows 分支确实有 `/SC ONLOGON` 且没有 `systemctl`/`launchctl`、Linux 分支确实有 `enable --now`。 (写这条时踩了一次"抓错对象":`Platform::Windows =>` 在 `as_str`/`autostart_mechanism` 里也出现,第一版抓到显示名导致假红,已限定在 `plan_autostart` 内、 边界同时认 `=> {` 与 `=> Ok(AutoStartPlan {`。) - `installer-clean-e2e.js` 38 项(+1):JSON `autostart.note` **两条分支都断言** —— 探针可用必须为 null(不给假说明),不可用时必须非空且含"启动"。 - 变异自证:临时让 `probeAgentProcess()` 恒返回 unavailable → `status --json` 得到 `probe_available=false` + `note='安装命令本应已使进程立即启动…'`, 人话 `status` 出现 `说明: …`,e2e 走"非空"分支仍全绿;恢复后回到 null 分支。 ("install 真正成功 + 探针不可用"这个组合在本机**无法构造**:`launchctl bootstrap` 在此环境恒失败(I/O error 5),install 永远走不到成功分支 —— 如实说明, 不假装验过。install 侧与人话/JSON 侧调用的是同一个函数,字符串由单测覆盖。) ## 顺带修掉一个我自己引入的偶发失败(必须修掉,不能留着) `cli-exit-code-e2e.js` 原用 `127.0.0.1:3107` 当"打不通的网关", 但实测**8 次里有 2 次 3107 上真的有人在听**(relay 往返成功、`relay_check` 退出 0), "打不通 ⇒ 退出码非零"这条被静默反转 —— 它先是在 run-all 里红了一次我才发现。 改为**端口 1**(特权端口,正常环境永不监听),并在注释里写明"别换成看起来空闲的端口"。 换端口后连跑 10 次 0 失败。 ## 验证 - `dart analyze lib bin`:**No issues found!** - `flutter test`:**582/0**(含本笔新增 5 项) - `cargo test --release`:**61/0** - `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰) - `node tests/installer-clean-e2e.js`:**38/0/1 跳过** - `node tests/cli-exit-code-e2e.js`:**11/0**(连跑 10 次稳定) - `guard-structure-test.js`:**44/0** ## 归属备案 `tests/run-all.sh` 里我把套件计数 37→38 的改动,被他人提交 `205e862` 一并带走了(同文件、`git add` 粒度拆不开,与我上次遇到的情况对称)。 内容已在 HEAD,无丢失;此处留痕避免日后考古困惑。
此前账号体系只到"你是谁",这一轮补"你能做什么"与"账号出事怎么办": 忘了口令等于报废、授权只能发配对码(等于公开招租)、配额只报数不拦。 服务端 - shared/permissions.js:13 能力 × 3 角色 + PLAN_TABLE + authorize(), canViewDevice 降为 authorize(...,'VIEW_SNAPSHOT') 薄封装(单一真相源) - auth_tokens 一表统管 verify/reset/invite,只存 sha256,原子消费 - 新端点:会话列表/踢人、改密、验证与找回、注销(30 天冷静期)、 成员 CRUD + 角色、邀请接受、转移归属、解除归属、审计 - 配额 ENFORCE_DEVICE_QUOTA 改默认开启(唯一破坏性变更);用尽返回 402; 未验证邮箱收紧到 1 台 - server/shared/mailer.js:outbox/console/smtp(smtp 未实现,如实回 delivered:false); 令牌绝不进 HTTP 响应 客户端 - role_catalog.dart(Dart 侧镜像,跨语言一致性由测试锁定) - device_members_card.dart + account_card 增验证状态/改密/注销/找回 - ViewerDevice.role 透传;成员卡只对"当前查看的那台 + 已登录 + 有角色"出现 护栏 - permissions-test 29 / user-lifecycle-test 66 / schema-consistency 第 14·15 段 - account_security_test 13 / viewer_page_test ⑩ 3 - 全量回归 34 套件 / 1838 项 / 0 失败 顺带修(同一类"环境冒充缺陷") - user-lifecycle-test 改为自起临时实例(随机端口 + 自己的 outbox + 放宽限流) - persistence-restart 端口不再写死 3100(被同机另一项目占用→假红) - auth-mode-test 走真实邮箱验证,配额期待从 403 改为 402 - run-all/README 的套件计数与实跑对齐(Dart 416→598,标签长期漂移)
此前**没有任何运营端**:38 个端点全是用户视角(每条都带 WHERE owner_id=我), 看全站账号靠 psql、改档位靠手写 UPDATE、看在线上靠 docker logs。 运营动作既没有界面,也没有留痕。 身份模型 - ADMIN_TOKEN 环境变量(不加 users.is_admin:新库上"谁来设第一个管理员" 要么手改 SQL,要么留一个"首次访问即管理员"的接口 = 经典接管漏洞) - ADMIN_NAME 只作审计标签,不参与鉴权 四条失败关闭 - 没设口令 → 404(401 等于宣布"这里有管理台") - 短于 32 字符 → 同样 404(ADMIN_TOKEN=123456 比没有更危险) - 口令错 → 401,不回显任何片段 - 口令走 ?token= → 401(它会进 nginx 日志、浏览器历史、Referer) - 比较用定长摘要 + timingSafeEqual;10 次/分钟/IP → 429,锁定期间正确口令同样被挡 交付 - server/business/admin.js:18 个端点 + CSV 导出(BOM + 公式注入防护) - server/shared/admin-queries.js:运营者查询(分页/统计/存储/降采样) - server/business/admin/:5 页 + assets,零依赖无 CDN,手写 SVG 图表 - server/shared/presence.js:在线判定抽出来,业务端与管理端共用(不写第二份) - 写动作五个(改档位/封禁/强制验证/踢会话/解除归属),每个都落 admin_* 审计; CSV 导出同样记审计(带走的是全站邮箱) 三个真缺陷 - GET /api/audit **匿名可读全站审计**(实测 HTTP 200)→ 删除,改 /api/admin/audit - 存储统计报错 99.9%:TimescaleDB 数据在 chunk 里,父表 32KB vs 实际 2802MB; 超表 reltuples 也不可信(2802MB 报 0 行)→ 探扩展后累加 chunk + 精确 count(3s 上限) - COPY server/business/*.js 不含子目录 → admin/ 漏拷时"镜像能建、容器健康、 /api/admin/* 全通,只有 /admin/ 404";覆盖性测试按 require 图查,静态文件是盲区 → 新增「静态资源目录必须被 COPY」段,并修正 expandGlob 对目录的处理 护栏 - tests/admin-api-test.js 80 项(9 个隔离实例,每个口令形态独占一个进程) - dockerfile-coverage 45 → 49;全量 35 套件 / 1901 项 / 0 失败 / 0 跳过 - 变异自证:短口令当真→红;未配置改 401→红;CSV 去防护→红(直接打出裸公式行); 删 COPY 那行→7 个文件点名报出 顺带修 - .env.example 的 ENFORCE_DEVICE_QUOTA 原写 0,与"代码默认开启"相反 —— 照模板部署会把配额静默关掉,改 1
三处都是实测截图发现的,不是"看着可能有问题": 1. 总览「当前在线」用 LIMIT 500 采样算法。库里 1.3 万台设备时取到的是物理 顺序最前一批,真正在线的可能一台都不在里面,页面显示"0",而 0 会被读成 确定结论(旁边"采样 500 台"的小字没人看)。改为 SCAN 数 `presence:*` 键个数,与设备表规模无关;Redis 不可用仍返回 null 而非 0。 2. 审计「明细」列的 JSON 没有断行机会(无空格),`white-space: normal` 找不到断点 → 整串作为不可断词把表格撑宽,再被 `.card{overflow:hidden}` 从右边缘硬裁,既无省略号也看不出被截了。改为包一层带 max-width 的 div (`table-layout:auto` 下 td 的 max-width 不可靠)+ 省略号 + title 悬停全文。 3. 存储表的行数把估算值和精确 count 混在一列,分不清真假。估算值灰化, 精确值带「精确」徽标,表头去掉"(估算)"。 顺带修 countPresence 插进来时把 mountAdmin 的 JSDoc 顶开了的注释错位。
ENFORCE_DEVICE_QUOTA 默认翻成 1 之后,account/device-merge/auth-mode 三套 清一色报 E_QUOTA_EXCEEDED。根因不是配额坏了:这三套的造数方式是"注册 → 认领 2~8 台设备",而「未验证邮箱只能绑 1 台」比档位上限更早生效。 按"谁能表达意图"分工,没有改产品规则: - account / device-merge:造数前 markEmailVerified(归并另抬到 team 档,它要 造 6~8 台)。配额会把断言挡在更前面 —— 本该 409 的"认领别人的设备"先返回 402。 - auth-mode [D]:对照组原拿宿主机 :4000 顶替,隐含"它一定没开配额"。改为自己 拉一个 ENFORCE_DEVICE_QUOTA=0 的 :4012,并断言它对 /me 报 quota_enforced=false。 顺带校正两处标签漂移(结构性守卫 48→50、镜像覆盖性 45→49),重算全量为 35 套件 / 1965 项 / 0 失败,README 同步。 全量实跑:35 通过 / 0 失败;隐私扫描通过。
新装一台实例"归谁管"以前只能靠 .env 里手敲 ADMIN_TOKEN:运维要记住随机串、 没有管理员概念、改任何业务配置都得改文件再重启。本轮把这件事收敛成 「浏览器里走一遍向导 + 配置落库 + 管理端在线可改」。 数据库(deploy/migrate.sql,两条路径共用) - instance_config 单行表(CHECK id = 1):实例名、对外地址、自助注册、 强制登录、设备令牌、配额、中继上限、邮件、审计保留期 - users.is_admin 列 + 部分索引 - bootstrap_state 三态 pending / adopted / ready,由「库里有没有用户」裁决 —— 存量库升级不能断服,故不是布尔 向导(server/business/setup.js + setup/index.html) 四道护栏是前置条件而非加固项: 1. INSTANCE_SETUP_TOKEN 未设或 <32 字符 → 404(不是 401,不对外宣布它存在) 2. 只认 Authorization: Bearer;失败闸门在令牌校验**之前**(否则猜对就绕过限流) 3. 只能成功一次,由数据库裁决:UPDATE ... WHERE bootstrap_state <> 'ready' 4. 完成后永久 404,判据在库里(内存变量会让重启后复活) 鉴权(server/business/admin.js + admin-queries) - 管理端双通道:ADMIN_TOKEN(应急/自动化)或库里 is_admin 的会话; 审计记 operator,能区分"用管理员账号改的"和"用令牌改的" - 新增 /api/admin/admins、账号提权/降权(不许取消最后一个可用管理员 → 409) - 新增 /api/admin/instance-config:被 env 锁定的字段拒绝修改(409 E_ENV_LOCKED), 不静默成功 配置层次 - 库是真相源,env 是显式覆盖层;GET 返回每个字段的来源(db/env/default) - 密钥类配置一律不入库(库会被备份/导出/管理端读出) - 改完同进程立刻生效(onChanged),不等 3 秒轮询 网关(server/gateway) - **修复半个开关**:业务端的 REQUIRE_AUTH / REQUIRE_DEVICE_TOKEN 搬进库后, 网关若仍只读 env,管理端点开"强制登录"只会关住业务端,订阅通道照样放行匿名。 改由 index.js 每 3 秒把「库 + env 覆盖」推入 gateway.setSwitches(), 网关自己不碰数据库(热路径只读内存字段);/health 报生效值而非 env 字面值 - 单测用假 this 直调原型方法时 this.sw 为空 → 用冻结单例兜底,避免 TypeError 被误报成"会话索引坏了" 测试 - 新增 tests/setup-test.js(97 项):四道闸、参数矩阵、成功路径、 并发(用行锁把两个请求压进临界区,让"输家 409"成为必然而非运气)、 存量库升级(只提权不改口令) - 它自带 4 个临时库并跑真实 init.sql/migrate.sql —— 顺带把"全新库首次部署" 这条历史上出过建表顺序坑的路径也覆盖了 - dockerfile-coverage:开关扫描补上声明式 ENV_MAP(动态下标扫不到, 覆盖曾从 5 条悄悄掉到 2 条),并按字段名判定真正的消费方 验证:bash tests/run-all.sh → 36 个套件全部通过;六组变异测试逐一证明 关键护栏"删掉即红"。README 增「首次部署:先过一遍初始化向导」。
上一轮交付了实例配置表、向导与管理端,但有五个字段只落库没人读 —— 界面上能改、改完不报错、实际什么都不发生。本轮把它们全部接上, 并修掉一处更危险的同源缺陷。 ## 同一道闸漏了两个入口(安全) 「不许取消最后一个可用管理员」只守了降权。封禁与自助注销都没守: - 封禁唯一管理员后,available() 只查 `is_admin`(不看 disabled_at / deleted_at)→ 管理端继续挂登录页,却没人能登进去;没设 ADMIN_TOKEN 就只能改库救。 - 管理员注销自己同理:账号进冷静期,is_admin 还在。 修法:把「可用管理员」口径收敛到 admin-queries.js 一处 (ACTIVE_ADMIN_WHERE / hasActiveAdmin / isLastActiveAdmin), available()、降权、封禁、注销四个使用点全部走它。 ## 五个字段接线 - mailer:从模块级 `process.env.MAIL_DELIVERY`(启动定死)改为宿主注入 getter,每次发送重新解析 → 管理端改完立刻生效。 顺带消除双名:ENV_MAP 写 MAIL_MODE、mailer 读 MAIL_DELIVERY。旧名保留 (仅当 MAIL_MODE 未设时生效并警告),否则老部署会静默退回 console。 - 库默认 mail_mode 从"实际行为是 outbox"纠正为 console:outbox 会把重置/ 验证令牌明文写进服务器磁盘,这个便利不该是默认值。测试改为显式声明。 - public_url / instance_name:邮件落款(唯一真实消费点),解决"收件人分不清 是哪个自建实例发的"。 - free_device_limit:**最严重的一个**。界面写"免费 3 台",配额却一直取 PLAN_TABLE 常量,改成 10 也不生效。新增 planFor() 只覆盖 free 档 (pro/team 是产品定义,不接受运维改),规则只在 permissions.js 一处。 - audit_retention_days:新增 shared/audit-prune.js,每 6 小时按批清理 (ctid 分页 5000/批,make_interval 参数化),留空=永久,删完写一条 audit_pruned 留痕。留痕本身也会被同样对待,符合规则。 ## 顺手挖出的三处漂移 - idx_audit_created 只在 init.sql 有、migrate.sql 没有(按 created_at 删除 会全表扫)。已补。 - 两份 compose 透传不一致:RELAY_MAX_BYTES 单容器有、四容器无; FREE_DEVICE_LIMIT 两边都没有(.env 设了进不了容器)。已补齐。 - user-lifecycle-test 断言配额 3,却依赖开发库里当时的值(被上一轮冒烟改成 5 → 假红)。已显式声明 FREE_DEVICE_LIMIT=3,并把开发库复位。 ## 新增守门:tests/instance-config-wiring-test.js(56 项,纯静态) 每个 instance_config 字段必须在**真正的代码**里被读一次(admin/assets 与 setup/ 不算 —— 出现字段名是展示/提交,不是消费);每个 env 名必须在 .env.example 与两份编排里;扫描器自己也要自证(编个不存在的字段名必须报红)。 它第一次跑就抓到 free_device_limit 的 0 消费方 —— 立刻证明不是空转。 ## 界面如实标注 smtp 从未实现,向导页原写"smtp(真实发信,需 .env 配 SMTP)"是谎言,改为 "暂未接入传输层(选了也发不出信)";设置页对 outbox 标"令牌明文落盘"、 对 smtp 标"传输层未接入"。 ## 验证 - setup-test:97 → 126 项(新增三入口、发信真实行为、保留期清理与留痕) - 变异测试 4 组全部转红:封禁闸、注销闸、保留期留空照删、mailer 忽略库配置 - bash tests/run-all.sh → 37 个套件全绿
上一轮把 mail_mode 接通了,但 smtp 分支是 `return delivered:false` —— 也就是说生产上"验证邮箱 / 找回口令"等于不可用。这轮把它补成真的。 ## 为什么自己写,不装 nodemailer 需要的只有 EHLO / STARTTLS / AUTH / MAIL / RCPT / DATA,是 RFC 5321 里最稳定的 一层;而为它引入一棵依赖树,等于把**后端进程的出网权限**交给一堆第三方代码, 而这个服务处理的是口令与重置令牌。零依赖(net/tls/crypto)约 600 行,可控。 ## 三条硬约束 1. 失败必须说出来:任何一步不是预期码都抛 SmtpError,带 reason / stage / permanent(4xx 可重试、5xx 不可)。绝不"看起来发了"。 2. 口令不许明文上网:配了 SMTP_USER/PASS 而服务器不给 STARTTLS → 直接拒发 (starttls_unavailable),不降级成明文 AUTH。 3. 口令不进异常/日志:错误消息只由「阶段 + 服务器回复」拼成,AUTH 命令行 (含 base64 口令)永不入日志。测试里有专门一条断言异常不含口令。 ## 协议细节里真会咬人的 - 多行回复(250- / 250 ):把半截当完整 → 后续命令与残行错位、莫名 500 - STARTTLS 后必须重新 EHLO:服务商故意只在密文通道公布 AUTH - DATA 点填充:正文里独占一行的 `.` 会被当结束标记,**报文被截断且服务器回 250** - 编码:纯 ASCII 走 7bit(可读),非 ASCII 走 base64(不赌 8BITMIME) - 头折行:200 字中文主题 base64 后 800 字符,会顶到报文行上限 - 头注入:主题里一个 CRLF 就能凭空多出 Bcc: - 交出 socket 给 TLS 后仍要兜 error 监听,否则未捕获异常带走进程 ## QQ / 163 的三处不一样(PROVIDERS 表,只对识别到的主机生效) - 465 是**隐式 TLS**,不是 587;未显式配置按预设取 465+secure - **发件人必须等于登录账号**,否则 553 "Mail from must equal authorized user"。 mail_from 是独立配置项,极易填成 noreply@example.com → 现在在**连之前**拦住 (smtp_from_mismatch),文案直接说该改成哪个值 - 口令是**授权码**不是登录密码;535 不含这条信息 → 认证失败时追加提示 (腾讯企业邮不追加,它用真密码,加了就是误导) - 顺带堵纯配置矛盾:465 + SMTP_SECURE=0 会连上后一直等不到问候、15 秒超时, 现在 normalize 阶段直接报"去掉 SMTP_SECURE 或改 587" ## 真实服务器只读探测(不带任何凭据) smtp.qq.com:465 → 350ms,AUTH LOGIN PLAIN XOAUTH XOAUTH2,证书过系统 CA smtp.163.com:465 → 314ms,AUTH LOGIN PLAIN XOAUTH2 说明 465 可达、多行解析在真实回复上正确、两家都公布 AUTH LOGIN。 真实投递需账号本人的授权码,用 tests/smtp-live-test.js(未配凭据时跳过)。 ## 测试 - tests/smtp-test.js(92 项):自带假 SMTP 服务器,覆盖 STARTTLS 与 465 隐式 TLS, 证书用 openssl 现场生成(含 IP SAN)。断言落在信封、报文头、编码、点填充, 以及 **AUTH 是在明文还是密文通道上发的** - 五组变异全部转红:去掉 From 校验 / 去掉 465 守卫 / dotStuff 恒等 / 去掉服务商预设 / 失败也返回 delivered:true - 教训:断言"连之前就拦住"的用例不能用 sendMail —— 防线一旦被删,测试会真的 去连 smtp.qq.com 试 AUTH(收到 535),把离线断言变成依赖外网。改用纯函数 normalize() 后同样抓得到变异,且永不联网 - tests/smtp-live-test.js:有凭据就真发一封(默认发给账号自己),没凭据 [skip] ## 其它 - 启动自检把"用哪个中继、什么形态、有没有明显配错"打在日志里 - 向导页与设置页去掉"smtp 未接入"的旧文案(现在真的会发) - .env.example / 两份 compose 补 SMTP_*;示例邮箱用占位符,不写成像真人地址 - bash tests/run-all.sh → 39 个套件全绿
main 自合并基点 723852e 起 5 个提交、11 个文件(均在 client/), 与 eink 的对应实现是平行重做,且全部被 eink 逐文件超越: - client/lib/ui/viewer_page.dart eink 2229 行 / main 1517 行 - client/lib/host/viewer.dart eink 1719 行 / main 1436 行 - client/lib/main.dart eink 1559 行 / main 1368 行 - client/lib/host/lan_service.dart eink 483 行 / main 687 行(main 把 LAN 逻辑并进单文件,eink 拆到 lib/link/** 六文件) - client/lib/ui/transfer_card.dart eink 668 行 / main 525 行 main 唯一独占的资产是 8 个 docs/ 文件(含 V4.0 方案 1038 行), 已抢救并将在下一提交落到 docs/legacy-v4/。 采用 -s ours 而非逐文件 cherry-pick:目的是让 main 成为祖先, 此后任何分支从 main 拉出都不会再撞上这 11 个文件的假冲突。 逐文件复核记录见 docs/decisions/ADR-005.md。
723852e
PLAN_TABLE 的每个字段都是卖点承诺,但 export / audit 两个字段活了 一个版本周期都没接线:export 连端点都没有,audit 只按角色判。 它们不报错、不红、没有任何信号 —— 因为从来没人做过「字段有没有消费者」 这个差集。 本提交加 PLAN_WIRING_STATE 台账 + 双向差集断言: 每个字段要么有消费者(cites 指向的字符串必须在源码里真实存在), 要么登记为未接线并写明裁定出处。加新字段不登记即红。 同时修准一处注释性谎言:permissions.js 文件头原文声称发配对码需要 device_token 作为在场证明,而实测 POST /api/pairing/code 不验任何设备 凭据。注释改成如实描述,并注明 M4′ 只修认领码、配对码同款洞本期不修。 注释性谎言比代码 bug 更难发现 —— 它会让下一个读代码的人停止审查这条链路。 对应:矛盾 17 / PRD §5.6.1 / OPEN-DECISIONS 已裁定表白名单会通胀,定义册不会
一、6 处 Unicode 状态符号清零 · link.dart 日志前缀改 [WARN](纯 ASCII,可 grep、跨终端不乱码) · transfer_card 3 处、viewer_page 1 处用户可见符号改 Material Icons(size 16) ✓ → Icons.check_circle,⚠ → Icons.warning_amber,全仓语义与图标一一对应 · EinkBoard.diagnostics 由 (String, String) 扩为 (String, String, IconData?): 图标单列槽位,而不是把符号拼回文本("走内网"的判定仍只在 _lanSummary 算一次) 二、配额卡档位机器键泄漏(用户可见 P0) 原来渲染「… · 档位 free · 我的设备 2/3 台」,free/pro/team 是机器键。 拆成两行: · 我的设备 {n}/{m} 台(达上限加「 · 已达上限」),任一数字缺失则整行不渲染 · {plan_name} · 查看三档权益,服务端没给就是「未知档位」 不回落机器键、不造第四个词、不按配额反推档位(free_device_limit 支持实例级覆盖)。 三、让"缺失"可表达 · deviceQuota / ownedDeviceCount 改 int?,删掉解析层 `?? 3` / `?? 0` (否则配额永远是 3,上面"缺失则不渲染"根本不触发) · plan 去默认值 'free';新增 planName(顶层 plan_name,不是 plan.name,无默认值) · me() 里 `?? info.xxx` 保留并注明区别:那是"另一处也带了同名值",不是编造 四、防复发护栏 test/plan_text_guard_test.dart 扫 lib/ 三类形态:${…plan…} 插值、「· 档位」、Text( 之后**跨行**的 .plan。 每条都配正控制(喂真违规样本,断言确实命中)与负控制(planName 不误报)—— 只验证"没抓到"不验证"抓得到",等于恒绿断言。
⚠ 在本仓库有两种码位形态:U+26A0+U+FE0F(emoji)与 U+26A0 单独(文字)。 按肉眼删字形会把不可见的 U+FE0F 留在字符串里 —— 源码看不出异常、界面是豆腐块, 而按 emoji 区间(U+2600-27FF)匹配的护栏看不见 U+FE0F,属静默回归。 本护栏同时量两把尺子:基字符与变体选择符;判据只认字符串字面量内的码位 (注释里的 ⚠️ 合法,本仓库有 60+ 处这种提醒,不设豁免清单)。 正控制专测「只删了字形、VS16 留在原地」这一种;负控制测注释与 URL 里的 // 不误判。
图标与颜色是两层独立信号:_result 失败分支颜色是 danger(红), 图标却用属琥珀层的 warning_amber —— 两层互相取消,且与既有先例不一致 (pairing_card.dart:377 / viewer_page.dart:2013 都是 error_outline + danger)。 warning_amber 留给 suspect 层(走中继、会消耗流量之类)。 symbol_guard 补两条负控制,把为什么不剥注释钉成可执行断言: · 文档注释里的 ✅❌★ 表格不误报(不区分注释的实现会在这种行上红一大片) · 行尾注释不误报(剥 // 到行尾会把 'http://x' 后面的真违规一起吃掉)
原判据是「按行数引号奇偶、只扫字面量内」,实测 4 种写法会**静默漏检** (不是误报,是漏报 —— 不报错、不红、不留痕,只会得到一条绿): · ''' 跨行块里单独占一行的符号(逐行扫描看不见,而长文案必然跨行) · Text("…'⚠'…") 双引号外套单引号 · Text('…"⚠"…') 单引号外套双引号 · Text('${x ? '⚠' : ''}') 插值内再嵌字符串 根因:引号奇偶表达不了「两种引号类型 + 跨行状态」。一个奇偶位装不下两种状态。 改成判「在不在**注释**里」而不是「在不在字面量里」: 三态状态机(普通/字符串/注释),字符串状态跨行保持;扫**注释之外的全部字符**。 理由:UI 文案只可能来自字符串,字面量外的符号同样违规 —— 于是猜在不在字符串里 这件事可以整个省掉,只留一个状态机就能判对,且**错的方向是多报**。 选判据优先选错得响的那个:哑错误没有下一次修正机会。 残余(三处,方向全是多报,已写进文件头): 块注释不认嵌套 / raw 字符串不特殊处理转义 / 插值内嵌字符串会错位。 同时补: · 星光平面 emoji 按代理对解码判定(原表只有 BMP,等于只防用过的那几个) · 「对真文件也响」的自检:扫描本文件必须命中 —— 防止状态机卡死后对一切沉默, 否则 lib/ 的零命中是假的 · 块注释内外分别断言的用例
## 修的是什么 改之前 `gateway.js` 把 `msg.payload.owner_id || null` 直接交给 `registerDevice`, 而 payload 是攻击者可任意填的 —— **任何未登录的人**发一条 `agent:hello` 自报 owner_id 就能凭空占有一台设备,连 claim 都不用走。这比认领洞更浅:认领洞还要登录。 ## 四件事(顺序有意义) 1. **前提**:`registerDevice` 的 owner 实参改成**字面量 null**。不改这行,后面三件 都只是摩擦。归属只能由已认证的 claim 建立。 2. **码**:设备端本地生成 6 位数字,随 `agent:hello` 的 `payload.claim_code` 上报, 网关写 Redis(`claimcode:<id>`,TTL 300s,**断连即删** —— 码的语义是"设备此刻 在这条连接上活着",TTL 只是崩溃兜底)。不给码单开 HTTP 端点:端点本身没有设备 凭据,要求 device_token 会成环(令牌只发已归属设备),不要求则谁都能替任意 device_id 发码 —— 那是自证,不是在场证明。 3. **验**:claim 对**已存在**的设备行必须有码且匹配;失败只有**一个** error 码一条 文案(缺失/不匹配/过期/次数用尽四态合并,细分等于告诉攻击者爆破进度)。 验码排在配额与归属之前:排在后面会泄露"这台设备有没有主人"。 4. **审计条件化**:`auditDeviceClaimed` 用 `INSERT … SELECT … WHERE EXISTS (… owner_id IS NOT NULL)`。原先是无条件 INSERT —— 会把没发生过的认领记成已认领, 方向是以假为真,比漏记更坏且事后无法从表里分辨。 ## 顺手修掉的一个真 bug:多参数 `del` 在 node-redis v5+ 只删第一个键 `clearClaimFailures` 写成 `del(k1,k2,k3,k4)`,实测只删掉 k1,其余**原样留着且不报错** (网关用 v4,同一写法在那边是"删全部")。症状是"清零没生效":用户在一次成功认领后 仍被上一次的冷却拦住。实测证据: del('a','b','c') → after [0,1,1] del(['a','b','c']) → after [0,0,0] `tests/claim-proof-test.js` 的 B4 段以 `[429,429,…]` 抓到它。同一写法在 `shared/pairing.js` 的 verify 分支里还有一处(**既有代码**,单独一提交修)。 ## 码格式不另写一份 `\d{6}` 与位宽改用 `shared/pairing.js` 的 `PAIRING_CODE_RE` / `PAIRING_CODE_LENGTH`。 本轮新代码一度在 business 里硬编码正则,被 `pairing-security-test` 的既有断言当场抓到 (那条断言本来就是为此存在的)。 ## 测试 `tests/claim-proof-test.js` 57 项全绿,已接入 `run-all.sh`。断言的是**结果状态** (查库 / 查 Redis)而不是"请求返回成功":旧实现里"静默忽略"与"静默接受"对客户端 都是成功的,只有查状态才分得开。每条"空集 / 恒为 NULL"的断言都配了必须变红的正控制 (claim 后必须看到非 NULL / 植入一条有主无审计的行必须数出 1 / 扫描器指向旧写法必须命中)。 套件默认连**独占 Redis db 14** 并在收尾 FLUSHDB:失败计数键名不含随机后缀, 不隔离会导致"连跑第二次开局就在冷却中"(实测第二次 41 通过 / 16 失败)。 回归:配对安全 84/0、协议 34/0、权限矩阵 53/0、schema 一致性 116/0、认领 57/0(连跑两次一致)。
## 这是一处既有代码的真 bug,不是新引入的 `verify()` 里码损坏时的清理写成 `del(key, pairingDeviceKey(deviceId))`。本类由 `server/business` 实例化,那里是 node-redis v5+ 语义: del('a','b') → 只删 'a','b' 原样留着且**不报错** del(['a','b']) → 两个都删 实测(2026-11-08,`server/business/node_modules/redis`): `del('k1','k2','k3')` 之后 `exists` 得到 `[0,1,1]`;数组形式得到 `[0,0,0]`。 ## 为什么留着比删掉更坏 `pairing:device:<id>` 是"这台设备当前的码"的反向索引。它在本该失效时留着,就指向 一个已经删掉的码 —— 而 `issue()` 正是靠它找回旧码去删。留下的后果不是报错,而是 下一轮发码时删掉一个不存在的键、旧索引继续指向空气。 ## 症状为什么难查 写入方(网关)用的是 node-redis **v4**,同一份调用在那边是"删全部"。所以这是 "同一行代码在两个大版本里语义不同、编译期不报、运行时也不报"的一类问题, 唯一症状是"清理没生效"。它不是本轮新写的代码带进来的,是随着 M4′ 的认领码 同类实现被对照着发现 —— 新代码里那处(`clearClaimFailures`)已在前一提交修掉。 回归:配对安全 84/0、认领在场证明 57/0、协议 34/0。
## 三件事 **M2 升档流程**:`/api/accounts/me` 下发顶层 `plan_name`(客户端只认它、缺失即显示 「未知档位」,此前服务端零下发 → 界面被迫显示机器键 `free`);新增 `GET /api/plans`(**台账驱动投影**:`PLAN_WIRING_STATE` 里 `unwired` 的字段一律不出现在 响应里)、`POST /api/plans/requests`、`GET /api/plans/requests/mine`;管理端三条 审批路由。`PLAN_TABLE.audit` 加档位判定 → 免费档 403 `E_PLAN_REQUIRED`(矛盾 17 裁决甲), 判定排在 `authzDevice` **之后**:先问"你有没有权看这台设备",再问"你的档位够不够", 否则会把"别人的设备"答成"你档位不够"。 **M3 历史窗口**:`/history` 下发 `allowed_windows: [{key,label}]`(**对象数组**,四个中文 标签只有这一条传输路径;写成裸键等于标签无来源);按档位截断并如实标注 `truncated` / `requested_window`,`bucket_sec` 跟**生效**窗口走而不是请求窗口; 400 文案改为固定文案(不回声 `req.query.window`、不念全量白名单)。 `team.history_days` 90→30。 **M7 保留期**:`retention_days` 改为读实例配置,不再有字面量。 ## 一个真错误:`ok:true` 但档位没变 审批对**已驳回**的申请原来也返回 `ok:true, idempotent:true` —— 管理端看到成功回执, 而档位一点没变,且没有任何东西会告诉操作者他做错了。**回执与事实不一致比违反字面更坏**: 它没有下一次修正机会。现在区分两种"非 pending": - `approved → approve` 是**真幂等**(同一结果,200,不重复写审计); - `rejected → approve` 与 `approved → reject` 是**非法状态转移**,409 `E_REQUEST_NOT_PENDING`。 路由层原来把 `!r.ok` 一律映射成 404("这条申请不存在"),现在 404 与 409 分开: 否则操作者会以为挑错了行,而真实原因是那条申请已被驳回。 ## 审计事件名:改的是 AC,不是代码 SPEC AC-09 原写 `plan_request_approved`,而 `API-CONTRACT` §8 与 `openapi.yaml` 写 `admin_plan_request_approved`。裁定采用带前缀的一侧,**依据不是"另一份文档那么写"**, 而是代码里已有 **12 个** `admin_*` 审计事件(`admin_plan_changed` / `admin_kicked` / `admin_export` …):前缀让"管理端动作"这个集合可以按前缀一次扫出来;靠逐个记名字, 这个集合只能靠枚举 + 记忆,而枚举必漏。SPEC 已同步订正。 留一句方法论在那两个常量旁边:**差集只告诉你两边不一样,不告诉你哪边对。** ## 顺手清掉一份档位清单副本 `admin.js` 的 `setPlan` 硬编码 `['free','pro','team']`,改为 `Object.keys(PLAN_TABLE)`。 副本的害处不是"写错了",而是**它能跑**:将来加一档时这份列表不跟着变,新档位永远改不到, 而且不报错,只是校验说"档位不合法"。 ## 测试 新增 `tests/plan-history-tier-test.js` **99 项**,已接入 `run-all.sh`。含三集同集断言 (响应键 / 台账非 unwired 键 / OpenAPI 属性集)与 **5 组负向对照**(多返回 export / 漏 audit / 台账种假字段 / OpenAPI 少写 / 多写),负向对照必须能红才有意义。 回归:permissions 60/0、admin-api 80/0、user-lifecycle 68/0、schema 一致性 116/0、 认领在场证明 57/0、配对安全 84/0。
## 三处前置此前**全部不存在**(我实测,不是文档说) ``` shared/events.js → 不存在 deploy/migrate.sql CREATE TABLE events → 0 命中 server/ 下 "api/events" → 0 命中 ``` 所以在这之前任何"事件名单差集为空"的断言都是**恒绿的空断言**:文件不存在 → 读到空 集合 → 差集为空,而"文件不存在"与"文件里没有多余事件"在输出上是同一个东西:空。 ## 服务端 - `shared/events.js`:5 个名字的**唯一机器副本**(权威在 SPEC §6.1.1)。净化做的是 **形状白名单**而不是字段名白名单 —— SPEC 只锁了事件名、没定义逐事件 props,此时 在这里发明一套字段名等于实现方单方面定契约,然后让客户端照着写。形状约束 (键名 `^[a-z][a-z0-9_]{0,31}$` / 值只接受标量 / ≤12 键 / ≤2KB / 敏感键名直接丢) 能挡住"埋点变成第二张用户表"这条路上最现实的风险,且不假装知道产品要什么。 - `POST /api/events`:未知名与非法条目**丢弃并计 dropped**(AC-20,不是 400)、 超 20 条只受理前 20(AC-21)、**写库失败仍 202**(AC-22)。认证可选但 **带废令牌一律 401**(降级成匿名等于"发个废令牌就关认证"); `body.account_id` 自报**被忽略** —— 否则谁都能往别人账号的漏斗里灌数,而漏斗是 拿来算率的,被灌了就没有意义。 - 管理端 `/api/admin/events/summary`:只出按事件名的计数与首末时间。它的判据不在 "能查出来",而在**查不出来** —— 这个端点没有任何办法取回某一条事件的内容。 ## 客户端:必须有真实产生方,否则这张表永远是空的 这是本轮最容易"看起来交付了"的地方:端点全绿、表建好了,而**没有任何上报** → 表恒空、漏斗没有分子分母,而所有测试照样通过。**"以空为通过"的形态里这一种最难发现, 因为每一层单独看都对。** `client/lib/host/event_reporter.dart` + `main.dart` 两个真实触发点(`app_open` 在配置 就绪、首帧将出时;`first_frame_shown` 用 `addPostFrameCallback`)。三条纪律: 不阻断业务(失败吞掉)、不重试(补报时序已经错了)、有界(丢最旧的)。 `app_open` **不能**放在 `main()` 最前面 —— 那时还不知道业务地址(`businessUrl` 来自 用户配置)。所以它落在"配置已就绪、首帧将出"这一刻;语义仍是"打开了应用",且这一批 能真的发出去。 ## 建表只有一个落点 `events` 只写进 `deploy/migrate.sql`,`init.sql` 用 `\i /migrate.sql` 引用它 —— 这是本仓库既有约定(新表进 migrate,init 引用)。我最初给执行方的判据是"两个文件都 必须有"——那会把一份定义复制成两份,是错的,按约定改正。 `account_id` 用 `BIGINT REFERENCES users(id) ON DELETE SET NULL` 而不是 SPEC §6.1 原写的 `TEXT`:`users.id` 是 BIGSERIAL,TEXT 会让"按账号算率"每次都 `::bigint` 强转, 转不动时(LEGACY 自报串)只能静默留 NULL,而 NULL 与"匿名上报"在表里长得一模一样。 `ON DELETE SET NULL` 让注销后**保留计数、解除关联**(CASCADE 会让历史漏斗在注销那一刻 缩水,分母变了率就不可比)。 ## 测试 `tests/events-test.js` **29 项全绿**,已接入 `run-all.sh`。关键一条是 **A7「表里真的 长出了行」**——没有它,前面每条都能在一个"什么都没落库"的实现上全绿。 AC-22 不是写注释:把 `events` 表临时改名造一次真实写失败,断言仍 202 **且** `accepted=0` **且** 带 `write_failed` **且** 恢复后行数未变(只断 202 不够 —— 任何实现都会 202,因为 catch 住了)。 客户端 `event_reporter_test.dart` 6 项全绿,其中一条钉住"先抽干再发":发送期间 `track` 进来的新事件属于下一批,不被这一批的失败连坐(否则是"失败一次丢两批")。 回归:permissions 60/0、account 75/0、schema 116/0、admin-api 80/0、认领 57/0、 档位与历史 99/0;客户端全量 **595 通过 / 2 失败**,两条失败均为 `libpmon_core.dylib` 未编译(Rust 核心未构建,本机环境问题,与本次改动无关)。
## 修的是什么 `POST /api/pairing/code {device_id}` 原先**零鉴权地生成码并在响应体里返回**: ``` POST /api/pairing/code {"device_id":"<任何存在的设备>"} → 200 {"code":"483920","expires_at":...} ``` 只要知道一个存在的 `device_id`,**任何人不登录、不持有任何凭据**,就能拿到该设备 的配对码,再去查看端输入,换到该设备的查看授权。比认领洞更浅:认领洞还要先登录。 根因不是"端点忘了加鉴权",是**码的来源错了**。服务端凭什么相信"请求方就是那台 设备"?`device_id` 是可猜的公开标识;`device_token` 只发给已归属设备(新设备会 成环,M4′ 里已经推演过)。唯一不可伪造的是**那条已建立的连接本身**。 ## 改成什么 码由设备本地生成(`client/lib/host/pairing_code.dart`),随 `agent:hello` 的 `payload.claim_code` 上报,网关在这一刻登记为配对码(`PairingStore.registerDeviceCode`), **断连即删**(`forgetDeviceCode`),TTL 300s 只是崩溃兜底。 于是码的语义变成真话:**"设备此刻在这条连接上活着"**。屏幕上那个码因此永远是服务端 认可的那一个,不存在"屏幕显示 A、服务端认 B"。 **配对码与认领码是同一个字符串**:用户只有一个六格输入框的心智,两条链路各发一个码 只会让人念错。 `POST /api/pairing/code` 保留为 **410 `E_ENDPOINT_GONE`** 而不是删掉:404 会让人以为 路径拼错或版本不对,410 明确说"它被有意移除了,码从哪来"。 ## 客户端 `pairing_api.issueCode` 改为**幂等只读**:返回单例里的当前码,不发网络。别把它改成 "每次调用都换一个码" —— 换了却不同时重发 hello,屏幕就会显示一个服务端从未登记过的 值,用户念它必然失败,而且看起来像服务端坏了。 ## 顺带补上 M4′ 在客户端的缺口 上一轮 M4′ 只做了服务端:`grep claim_code client/lib` 命中 **0**。也就是服务端能力建好 了、测试全绿,而**真实客户端从不上报码** —— 生产上认领只能走 `isNew` 路径。同一个 "必须有真实产生方"的坑在 M5 里刚踩过一次(表恒空)。本次 `link.dart` 的 hello 已带 `claim_code`,两条链路同时有了产生方。 ## ⚠️ 一个比原洞更大的发现:84 项配对回归网跑的是 9 天前的镜像 `tests/pairing-security-test.js` 默认打 `http://127.0.0.1:4000`,那是 docker-compose 的实例。实测 `pmon-business-1` / `pmon-gateway-1` 镜像构建于 **2026-10-30**,而本轮 改动在 **2026-11-08** —— 早 9 天。所以那些"84 项全绿"只证明旧镜像行为没变,对仓库里的 代码一个字都没说。 所以本套件(`pairing-code-origin-test.js`)**自起源码实例**(business + gateway, 随机端口,源码直跑),并在 `run-all.sh` 里那一段加了大字警告:在其余套件改成自起 之前,**不要把它们的绿当作本仓代码的回归证据**。 ## 测试 `tests/pairing-code-origin-test.js` **16 项全绿**,已接入 `run-all.sh`。16 条里 13 条是 "拿不到"型(410 / 无码返回 / 猜码被拒 / 跨设备被拒 / 断连失效),3 条是"该拿到的人 拿得到"(hello 登记、verify 成功、一次性消费)—— 只验前者的话,一个把配对整体做死的 实现也能全绿。 客户端:`pairing_card_test` 6/6、全量 **595 通过 / 2 失败**(两条仍是 `libpmon_core.dylib` 未编译)。两条断言按行为变更改写而不是删除: - "发码失败不静默" → 改为"**一次请求都不发**"(失败路径已不存在,但新风险更大: 任何一次发出的取码请求都意味着码又能被第三方索取); - "服务端返回 123456" → 改为"卡片显示的码必须与上报给服务端的是同一个"。 第一次跑本套件时 E 段红了两条,原因是我自己的夹具没关掉前一条连接 —— 网关判定 "这台设备还有别的活连接",正确行为就是不删码。已修,并在测试里写清这个坑。 回归:认领 57/0、埋点 29/0、权限 60/0、schema 116/0。
## 范围与边界 **本次只改代码层**:客户端包名、Android/iOS/macOS 标识、环境变量前缀、Rust crate 名、 类名/文件名、以及全部文案与注释。**数据层标识一处未动**(卷名 `pmon_pg_data`、 `POSTGRES_USER/DB`、连接串默认值),那些在阶段 B 单独做迁移,理由见下。 统计:**114 个文件、1687 处**替换 + **8 个文件/目录重命名**。 ## 改了什么 | 类别 | 旧 | 新 | |---|---|---| | Dart 包名 | `pmon_client` | `inkwatch_client`(41 个文件的 import 同步) | | Android namespace | `com.pmon.pmon_client` | `com.inkwatch.eink` | | Android applicationId | `com.inkwatch.eink` | **保持不变** —— 它本来就已是新名 | | Kotlin 包目录 | `kotlin/com/pmon/pmon_client/` | `kotlin/com/inkwatch/eink/` | | macOS bundle | `com.pmon.pmonClient` | `com.inkwatch.eink` | | 环境变量 | `PMON_*`(12 个) | `INKWATCH_*` | | Rust crate | `pmon-core` / `pmon_core` | `inkwatch-core` / `inkwatch_core` | | 动态库 | `libpmon_core.dylib` | `libinkwatch_core.dylib` | | 类/文件 | `PmonApp` `PmonColors` `PmonChannel` `PmonCollectorService` `pmon_agent.dart` … | `Inkwatch*` / `inkwatch_*.dart` | ## 三处**必须单独说**的判断 **① applicationId 刻意没动。** `build.gradle.kts:49` 有既有说明:水墨屏专属版用 `com.inkwatch.eink`,与主干通用版**刻意不同**,为的是两版本能在同一台手机上共存 (versionCode 各自递增,同包名会互相覆盖且降级被拒装)。它已经是新名,动它反而 破坏这个设计。我改的是 `namespace`(内部分包名,只影响编译期),并把它统一到同一个 `com.inkwatch.eink`。 **② 局域网 P2P 线协议前缀一起改了**(`PMON-PUNCH:` / `PMON-PONG:` / `PMON-FRAME:` → `INKWATCH-*`)。这**不是纯文案**:两端必须同时升级,否则新旧版本在同一局域网里互不 认。判断依据是这三个前缀只用于同一用户的两台设备直连,且当前尚未对外发布;若以后 要支持跨版本,正确做法是加协议版本协商而不是回退旧前缀。 **③ 数据层标识一处未动。** `deploy/` 下 28 个文件仍是 `pmon`,因为卷名/库名/库用户 的改动会直接导致连不上库或指向新空卷(`docker-compose.all.yml:26-29` 里写着: 改名会让 compose 去找 `inkwatch_pg_data` 这套新空卷,等于把生产清库)。实测当前 数据是 **2.5GB、128 个账号、58 台设备**,不是测试库。 ## 安全网 动手前做了完整备份: `.backup-rename-20261108/pmon_before_rename.dump`(`pg_dump -Fc`,152MB), 并记录了迁移前各表行数基线,供阶段 B 逐表比对。 ## 验证 客户端全量 **595 通过 / 2 失败** —— 与改名前的数字**完全一致**,两条仍为 `libinkwatch_core.dylib` 未编译(Rust 核心未构建,环境缺口)。**数字一致**是这次 改名的核心判据:一个纯机械改名必须不改变任何测试结果,若通过数变了就说明改坏了东西。 服务端代码只动了注释与环境变量名,逻辑零改动,阶段 B 后一并回归。
## 做了什么(顺序有意义) 1. **备份**:`pg_dump -Fc` → 152MB dump,落在仓库外 `.backup-rename-20261108/`,并留了改名前后的表行数文件。 2. **停应用**(nginx/business/gateway),保留 PG/Redis。 3. `ALTER DATABASE pmon RENAME TO inkwatch` + `ALTER ROLE pmon RENAME TO inkwatch` + `ALTER ROLE inkwatch WITH PASSWORD 'inkwatch_dev_only'`。 4. **复制卷**(不是改名):`pmon_pg_data → inkwatch_pg_data`(3.807GB)、 Redis、nginx 日志三个卷。**旧卷全部保留**,作回滚点。 5. 改 compose 的卷名与环境变量默认值、`deploy/` 下全部脚本/配置、测试默认连接串、 `.env.example`、`docs/`。 6. 用新 compose 起栈,删旧容器(`pmon-*` 共 6 个,含 9 天前的 migrate 容器)。 ## 数据校验:这是本次唯一不能靠"应该没事"的部分 第一次我拿 `pg_stat_user_tables.n_live_tup` 当基线,**那是错的** —— 它是估算值, 实测 `devices` 估 58、实际 14559,`device_metrics` 估 0、实际 257 万。用它做校验 等于没有校验。 改成的办法:把 dump **恢复到一个临时库**,然后两张库**逐表精确 `count(*)` 对比** (排除 `_hyper_*` chunk,父表计数已覆盖)。结果: ``` 唯一差异:device_metrics 当前 2572652 / 备份 2572456 → 当前多 196 行 ``` 多出来而不是少了 —— 因为 dump 是 20:04:57 取的,而停服在 20:07:53,期间设备仍在 正常上报。最新数据时间戳 `12:07:53 UTC` 佐证了这一点。**196 行是正常写入,不是差异。** 逐表其它 40+ 张表行数完全一致。 校验库随后删除,容器内临时 dump 也删了。 ## 两个踩到的坑(都已记录在流程里) **① `docker exec` 不加 `-i` 时 stdin 不转发。** 我第一次用 heredoc 把四条 SQL 喂给 `psql`,结果**一条都没执行、也不报错** —— 看起来像"执行了但没生效"。改成 `-c` 逐条。 **② `ALTER ROLE` 不能重命名当前会话用户**(`session user cannot be renamed`)。 解法是建一个临时超级用户来执行,用完立刻删(已验证 `pg_roles` 里查不到它)。 注意 `DROP ROLE` 同样不能删当前用户,必须换回原身份再删。 ## 有意没做的事 - **旧卷没删**:`pmon_pg_data`(3.807GB) / `pmon_redis_data` / `pmon_nginx_logs` 都留着。 这是唯一的整体回滚点。确认新栈稳定运行一段时间后,可自行 `docker volume rm pmon_pg_data pmon_redis_data pmon_nginx_logs`。 - **`pmon_dbg1` / `pmon_claim_verify` 两个遗留库没动**:它们是历史测试/调试残留, 不是产品库。要清可以直接 `DROP DATABASE`,但删数据这种事我不代做决定。 ## 验证 新栈 5 个容器全 `healthy`,`/health` business 与 gateway 均 **200**; 库内 `users=128 devices=14559`,与迁移前一致。 服务端套件(全部自起源码实例或纯离线): 认领 **57/0**、埋点 **29/0**、配对码来源 **16/0**、权限 **60/0**、schema **116/0** —— 与改名前的数字**完全一致**。 客户端上一提交已验证 **595 通过 / 2 失败**(两条为 `libinkwatch_core.dylib` 未编译)。
No dependencies set.
The note is not visible to the blocked user.
<user> 2026-09-16 的四条要求,逐条落地并各有可执行断言: ① 横屏为默认、可切竖屏 · 新增配置 einkLandscape(默认 true);看板里的「转屏」按钮切换并落盘 · 屏幕方向由宿主锁(SystemChrome),看板只发指令、保持"纯展示"以利测试 · 列数按**可用空间宽高比**决定(宽→3 列 / 高→2 列),不写死方向 —— Flutter 只拿得到一个矩形,分屏/折叠/多窗口下方向与矩形并不一致 ② 聚焦核心数据、隐藏次要信息与冗余控件 · 在线时顶部只有「实心圆 + 设备名」:形状已表达在线, "在线"二字与"数据新鲜"都是冗余;一旦离线/疑似/未知才把状态与原因铺出来 · 底部两行说明合并为一行("最后刷新 xx" + "每 N 秒自动刷新" → "xx · 每 N 秒") ③ 对比度与刷新 · 延续零动画(静止等待图标、NoSplash、无无限动画),有源码结构断言守着 · 支撑"永不溢出":横屏/竖屏/极窄/极矮/极宽多套尺寸全部覆盖 ④ 统一字体与样式(避免字号大小不一) · PmonEinkText 收敛为 5 档(label 与 action 同号,靠字重而非字号区分); 界面里**不得写就地字号**,有源码断言(`fontSize: 数字` 即红) · 数值改为「大数值 + 小单位」:此前 `1.42 Mb/s` 整串按 40px 渲染宽 360px, 而 3 列时每格仅 207px → 统一比例被拖到 0.575,**一屏数值全被它压小** · 全屏数值共用**同一个**缩放比例(按最长那条算); 并改用**实测字形二次收敛**替代纯线性估算(渲染期文本宽度非线性, 实测在极窄格 + 极长值时溢出 3.7~9.3px);最外层留一层 FittedBox 兜底, 保证"绝不裁掉内容"(正常尺寸下它不生效,同号不受影响) · 数值加**无损紧凑化**(`310.00` → `310`):只删数学上无信息的零尾,绝不四舍五入 实测(F50 代跑): · 横屏 3 列 × 2 行、大数值 + 小单位、数值明显大于标签 —— 见 dist/水墨屏走查/03 · 竖屏**未能在 F50 干净验证**(它的 wm size/density 覆盖 + 强制旋转使 Flutter 视口 变成近正方形,于是按设计走了 3 列)。竖屏布局由 widget 测试覆盖 (536×724 → 2 列),待真机(Neo2)上机复核。 测试:客户端 321/321;其中 eink_board_test 32 项。 变异测试:把"统一缩放"退回"逐格 FittedBox"会被"渲染高度一致"那条抓住。 主干 main 不受影响 —— 本分支相对 main 只新增、未修改任何主干代码。脱敏改坏了一处断言:device-identity-test 的「大小写归一」用例 输入 deviceTag('android-ABC123')(大写,未被替换) 期望 'abc123'(小写,已被替换) → 输入与期望指向了不同的标识,必然失败。改为输入 ABC123 / 期望 abc123, 两者同指一个标识,语义(大写入、小写出)不变。 ⚠️ 教训:**字符串级脱敏必然改坏成对出现的断言**(输入与期望、正例与反例)。 脱敏后必须跑全套测试,不能只看 diff 顺眼就推。 同时把上一轮只匹配小写而漏掉的**机型编号大写变体**(25042PN24C / 24069RA21C) 一并脱敏为 MODEL-A1 / MODEL-B2 —— 型号本身是公开信息,但与设备 ID 后缀 组合起来足以指认具体设备,故一并替换。首次推送前的人工审计一次扫出六类个人数据,而且**反复漏**: · 第一轮漏了机型编号的**大写变体**(规则只写了小写) · 第二轮漏了**裸用户名**(规则只写了长形式 xxxdeMacBook-Pro-2.local) · 第三轮漏了真实账号邮箱 —— 因为臣用 head -10 截断了扫描输出, 前面十条全是测试假邮箱,真邮箱被挤到看不见 三次都是"扫全之后回看"才发现的。人工审计靠不住,固化成脚本。 判据分两档(关键设计): · FAIL:高置信(公网 IP / .cn 域名 / /Users/<名字> / SSH 私钥路径 / 私钥块 / 手机号 / 疑似真实密钥赋值) · WARN:需人判断(邮箱 —— 测试夹具与真实邮箱长得一样)。 只列不判红,避免假阳性变成噪音、进而被整体忽略。 白名单刻意**窄**:Flutter 官方中国镜像域名、明显合成的口令值、单字母域名的假邮箱。 扫描范围:工作树(被跟踪文件)+ **全部提交** + 全部提交信息 —— 只扫工作树是不够的,`git log -p` 照样能把脱敏前的原文翻出来。 本脚本**不内置任何真实值**(内置就等于把要防的东西写进仓库); 项目特定敏感串写到本机的 ~/.config/inkwatch/privacy-denylist.txt(不入库)。 用法:node tests/privacy-scan.js(全量) / --worktree-only(快,只扫工作树)改的是什么:client/android/app/build.gradle.kts 此前是 Flutter 模板原样 —— release 一直用 signingConfigs.getByName("debug"),即公开的 AOSP 测试密钥。 三条后果且都不报错: ① 任何人都能签一个同签名的包冒充升级 ② 无法上架(Play 拒绝 debug 签名) ③ 换正式密钥后,已装设备无法覆盖升级(不可逆) 怎么改: - 新增 signingConfigs.release,凭据从 client/android/key.properties 读 (该文件已在 .gitignore 中,确认 check-ignore 命中) - buildTypes.release 指向它 - task-graph 守卫:release 构建缺 key.properties 时**明确失败**并打印修复指引 刻意不学模板「回落 debug 签名」—— 那是一条「看着构建成功、其实签的是公开密钥」 的静默失败路径。本仓库多次栽在静默失败上(迁移从不执行、首次部署路径无人测), 故这里宁可让它红。 验证: - 变异:移走 key.properties → release 构建 2 秒内失败并给出指引 - 放回 → 构建成功;apksigner 确认 DN=CN=InkWatch,指纹与 keystore 一致 SHA-256 0d58f8b5749b6a60c8042f6f4333a0c847192bf37d44e56cdd4756f3970ad679 密钥:~/keystores/inkwatch-release.jks(仓库外,PKCS12/RSA2048,有效期至 2054), 凭据记录在同目录一个 600 权限的文件里。 ⚠️ 这是本仓库唯一不可再生的东西 —— 丢了就永远无法更新已发布的应用。 新增 docs/release-and-signing.md:密钥信息、key.properties 契约、出包命令、 两个包名的分工、换签名的后果与恢复步骤。为什么单独写:看板版面(列数、字号、格高)全部取决于设备的**逻辑尺寸 dp**, 而 6″/300PPI 且系统 density 由厂商设定 —— 1072×1448 在 density 2.0 下是 536×724dp, 若厂商设成 240 就是 715×965dp,版面完全不是一回事。不查就不知道。 脚本做三件事: 1. 按机型名(Neo/iReader)**精确挑设备**,避免误操作到 F50 上 2. 打印机型/Android 版本/内存/ABI + 物理与覆写尺寸、density, 并换算出逻辑 dp 与"该走几列"的预期 3. 找到设备却无法连接时,直接打印开启 USB 调试的排查步骤 用法:bash client/scripts/eink-device-probe.sh [APK路径] 首跑实测:Neo2 在 adb 不可见(见提交信息附的 USB 描述符证据), 脚本按设计输出了排查步骤。背景:Neo2 的 USB 调试开不了(已用 locationID 关联确认它只暴露掌阅自己的 255/255/0 传输接口,全系统唯一的 ADB 接口属于 F50)。而看板版面 (列数、字号、格高)**完全取决于逻辑尺寸 dp** —— 拿不到 wm size/density 就是瞎调。 于是把自检做进应用:把关键数据**直接画在屏幕上**,一张截图即可取数。 自检页内容: · 展示层自有(MediaQuery):逻辑尺寸 dp、devicePixelRatio、物理像素、方向、 由尺寸推出的看板列数 · 宿主提供(viewer_page):刷新间隔、已收帧数、本会话断点、最后刷新时刻、常驻内存 设计要点: · **静态文本表**,无动画无滚动 —— 与看板同一套墨水屏约束 · 打开时**不显示数值网格**(一屏只放一件事) · 底部按钮在「自检 / 返回」间切换,仅在宿主提供回调时出现 · 用 Expanded 等分行高,多尺寸不溢出 测试:eink_board_test 32 → 39 项(新增 7 项),客户端 321 → 328/328。本文件此前有四处范围缺陷,每一处都是"范围悄悄变小、而输出仍然是绿的": ① 受 cwd 约束。脚本内 `git ls-files` 与 `git grep <rev> -- .` 都相对当前目录, 而 run-all.sh:27 正是 `cd "$(dirname "$0")"` → 实际只覆盖 tests/** (实测 26 个文件 vs 从仓库根跑的 193 个,少 86%)。 client/ server/ shared/ docs/ deploy/ 全在盲区,而真实环境指纹恰好都在那里。 → 开跑先 `git rev-parse --show-toplevel`,所有 git 调用带 cwd=ROOT。 ② 非 ASCII 路径。`git ls-files` 默认把路径加引号转义输出 ("docs/xxx-\346\236\266.html"),照原样 readFileSync 必然打不开 → 被 catch 跳过。 本仓库两个中文名文件长期不在扫描范围内。 → 所有 git 调用带 `-c core.quotepath=false`。 ③ 未跟踪文件。只扫 `ls-files`(纯受跟踪)会漏掉"刚写好、还没 add"的新文件, 而那正是下一次提交的原料。实测踩到:一个新写的 docs/*.md 带着设备代号与 ROM 构建指纹躲过了脱敏。 → 加 `--others --exclude-standard`(忽略项仍排除,不引入噪音)。 ④ 浅克隆。`clone --depth=N` 隐含 `--single-branch`,只带默认分支。 → 扫描前确认分支齐全。 同时**写明判据边界**(这一条比上面四条更重要,因为它防的是"用错力"): 判据是"这条信息能不能把内容跟我本人 / 我的机器关联起来",不是"网上能不能查到"。 | 类别 | 处置 | |---|---| | 产品参数(机型名、型号代号、分辨率、芯片名) | **不必脱** | | 设备实例标识(device_id 的 ANDROID_ID 后缀、设备串号) | 建议脱 | | 云 / 容器实例(lhins-…、1Panel-…) | 该脱 | | 指向本人的(服务器 IP、域名、SSH 私钥路径、Linux 用户名、macOS 主机名、账号邮箱) | **必须脱** | 两条硬约束,来自本轮一次返工: · 不得把产品参数当敏感数据脱 —— 脱了以后 README 里「设备归并」案例 (归并前 5 台 / 归并后 4 台)就没法跟真机对照了,净负收益; · 不得让判据恒红 —— 通用开源项目名(searxng / meilisearch)、通用词 (reader_default)、已经是脱敏占位符的值(/home/devuser、~/pmon) 都不许进判据或 denylist。恒红会让整条护栏被无视,比漏报更糟。 按此边界调整规则集: · 新增「Linux 家目录(含用户名)」,devuser 作为既有占位符放行 · 删除「Apple 机器标识符」「证书 DN 中的地名」「真机型号/芯片标识」—— 这三条属于产品参数,留着只会恒红 · 邮箱保持 WARN:夹具与真实邮箱形状完全一样,规则分不开; 真正要拦的那个邮箱属于"形状上无特征"一类,请写进 denylist(命中即 FAIL) 另修一处工具自身的坑:`--format=%(refname:short)` 里的左括号在 `sh -c` 下是 子 shell 起点 → 语法错误 → 被 catch 吞成空串(范围提示会显示"分支 0 个")。 带 () % 的 git 参数改用 execFileSync,不拼进 shell 字符串。 验证:从仓库根与从 tests/ 运行结果完全一致;范围提示如实报出文件数与分支数。上一轮脱敏**做过头了**:把产品参数当敏感数据脱,把 README 里「设备归并」案例 (归并前 5 台 / 归并后 4 台)跟真机的对照关系毁掉了 —— 净负收益。 按最终判据("能不能把内容跟我本人 / 我的机器关联起来")重做,替换表从 31 条 收敛到 4 条,命中从 43 文件 / 400 处降到 25 文件 / 46 处: 仍要脱(指向账号资源 / 指向本人 / 设备实例标识): · 云实例 ID、容器编排实例名 · 设备 ID 的稳定后缀真值(在 main 上补齐) · 个人 handle 撤回(此前误脱,现恢复原样): · 产品参数:机型名、型号代号、屏幕分辨率、芯片名、Android 版本 · 证书 DN 里的地名 —— 它随 APK 公开分发,且文档要用它核对 apksigner 输出 · 私有 LAN IP、公共 npm 镜像源 · 通用开源项目名(searxng / meilisearch / market-sentiment / reader_default) · 已经是脱敏占位符的值(/home/devuser、~/pmon) · keystore 目录路径 —— `~/` 下不含用户名,且命令要能照抄执行 「护栏拦不住」的三类(形状上无可匹配特征)改由本机 denylist 装原始值: Linux 用户名、macOS 主机名、账号邮箱。denylist 装原始值、不装结果值 —— 装结果值会把已经改对的内容也判红。 ⚠️ 只改工作树。历史里的原文仍在,由 git filter-repo 连历史一起重写。 验证:tests/run-all.sh 全部套件(见下一条执行记录)。上一轮把自称替换成了 `<user>`,有两个副作用: ① **尖括号触发 14 个 doc-comment lint**(`unintended_html_in_doc_comment`)—— Dart 分析器把 `///` 注释里的 `<...>` 当 HTML。脱敏前 analyze 是 "No issues found",脱敏后变 14 条 info,属于**回归**。 涉及:app_config / boot_report / config_migration / main / ui/eink_board / ui/skeleton + 6 个测试文件。 ② **语义被降级**。原句是**决策记录**,改完丢了主体: - `(2026-09-16 产品负责人定的默认)` → `(<user>定的默认)` —— 从"负责人拍板"降级成"某个用户",信息量没了 - `产品负责人那台,2026-09-15 实测` → `<user>那台` —— 指代不清 - `产品负责人截图里的现象是` → `<user>截图里的现象是` —— 同上 改法:`<user>` → **「产品负责人」**(全仓 41 处 / 23 文件) · 无尖括号 → 14 个 lint 归零 · 语义比 `<user>` 清楚:明确是「拍板的人」,而不是「某个用户」 · 公开仓库里用中性角色名比昵称得体 验证:客户端 328/328、`dart analyze lib test` = No issues found。 diff 已核为**纯粹**(仅这一类替换,未混入其它改动)。 注:昵称本身不指向真实身份(网名 `某个网名` 才是可关联的,那条保留在 denylist 里), 故这里的取舍是「去掉个人色彩」而非「切断身份关联」。产品负责人指出"参数早就发过了,不该再等截图"。臣复核后确认:**参数确实够用**, 而且不必上机 —— 把两种 density 假设的逻辑尺寸喂给看板自己的代码 (widget 测试挂载),就能量出真机版面: | 假设 | 方向 | 数值字号 | 列数 | |---|---|---|---| | A 572×772(若 300=densityDpi) | 竖 / 横 | 40.0 / 37.2 dp | 2 / 3 | | B 536×724(若 300=PPI、density 2.0) | 竖 / 横 | 38.5 / 33.2 dp | 2 / 3 | 两种假设下**列数一致**、**数值字号 33~40dp**(标签的两倍以上)→ 版面合格。 ⚠️ 关键澄清(避免把测算读成真机值):**widget 测试的字形是正方形的** (每字宽 ≈ 字号),远宽于真机字体,故上表是**下限** —— 真机只会更大(长值基本能开到满档 40dp)。 结论:`densityDpi` 的精确值**不影响任何设计决策**,只用于事后核对。 设备卡里「如何确认」的措辞已从「待确认」改为「事后核对」, 并说明自检页的真正用途是内存占用与实际刷新计数。 (臣上一轮把"验证"说成了"前提",是要纠正的表述。)真机反馈:在采集端登录后,切到查看端仍显示未登录,于是又登一次。 排查结论:**不是登录两次,而是登录态没同步**。令牌早就共享 (两端都写同一个 `AppConfig.accountToken`,且两端都订阅了 `ConfigStore.updates`), 整条链路本来是通的: 采集端登录 → onAuthChanged → ConfigStore.save → _publish 广播 → ViewerPage._onExternalConfig → sessionAffectingChanges(含 accountToken)为真 → _restartWith → _startFromConfig → `_token = cfg.accountToken` ✅ 断在**最后一环**:`AccountCard` 在 `initState` 里 `_token = widget.initialToken` 读了**一次**就不管了,且没有 `didUpdateWidget`。 而主壳是 IndexedStack,两页的卡片**都长期存活** —— 父级更新了 `initialToken` 也不会重跑 `initState`(同位置、无 key → State 被复用), 卡片内部仍是旧的空 token,照样渲染登录表单。用户看到的自然就是 "那边登了、这边还得再登一次"。 修法:给 `AccountCard` 补 `didUpdateWidget`,只在 `initialToken` 变化时同步; 登出(token 变空)时一并清掉 `_info`,否则会留着上个账号的档位与设备数。 边界(刻意保守): · 邮箱**只补空、不覆盖** —— 只在输入框为空或仍等于旧值时才同步, 免得把用户正在敲的内容冲掉; · token 未变时直接返回,不做无谓重建(避免自写盘自激)。 对照:`PairingCard` 全程直接用 `widget.accountToken`,所以没有这个问题 —— 同一应用里两种写法,错的那个恰恰是"看起来更规范"的那种(把入参缓存进 State)。 验证: · 新增 4 条测试(`test/account_test.dart` ⑥ 组),客户端 328 → **332/332** · **变异测试**:移除 `didUpdateWidget` → 「别处登录/登出后卡片要跟着变」两条**报红**; 恢复即绿 —— 证明测的就是这条修复本身 · `dart analyze lib test` 干净真机反馈「开看板后底下的导航键没隐藏」。查下来是**两个成因,都要治**: ① **系统导航栏**:代码里**从未**调过 `setEnabledSystemUIMode` —— 一直显示。 修法:进看板 → `SystemUiMode.immersiveSticky`;退出 → `manual` + 全部 overlays。 · 用 `immersiveSticky` 而非 `immersive`:前者在用户误触屏幕边缘后**会自动重新隐藏**, 副屏是常驻的,不该被一次滑动永久唤出系统栏。 · 恢复用 `manual` + `SystemUiOverlay.values` 而非 `edgeToEdge`:后者要求 API 29+, 前者全版本可用,语义就是"把系统栏都还回来"。 ② **应用自己的底部导航**(采集/查看两页签):主壳是 `IndexedStack` + `bottomNavigationBar`,而后者是 Scaffold 的**独立区域** —— 看板渲染在 body 里, 它一无所知。修法:新增 `PmonEinkBoardMode`(`ValueNotifier<bool>`), 主壳用 `ValueListenableBuilder` 订阅,看板前台时不渲染导航。 · 用 ValueNotifier 而非像 `PmonEinkMode` 那样的静态 bool:这里必须**触发主壳重建**。 **判定只有一处**:`_syncBoardChrome()` 用同一个 `_eink && !_einkPaused` 同时驱动 系统栏显隐与主壳导航 —— 本项目已因"同一判据写两处"反复踩坑(指标清单、单位换算、 身份判据…),这里不再重演。 顺带修掉一个隐患:转屏校验原先用 `Future.delayed`(**不可取消**),页面销毁后 回调仍会醒来。已改成可取消的 `Timer` 并在 `dispose` 里取消。 (这是写测试时被 "Pending timers" 揪出来的 —— 测试的价值不只在断言。 `dispose` 里同时还原 `PmonEinkBoardMode` 与系统栏,免得离开这一页留下副作用。) 验证: · 新增 2 条用例(`viewer_page_test` ⑨ 组)→ 客户端 344 → **346/346** · **变异测试**:注释掉"通知主壳"那行 → 「进看板 → active 置真」**报红**;恢复即绿 · `dart analyze lib test` 干净 ⚠️ 本提交是 **feature/eink 专属**,不同步主干:`ui/eink_mode.dart` 主干上不存在, `main.dart` 的订阅逻辑也只在有水墨屏模式时才有意义。 (上一轮臣就是整文件复制到主干,把分支专属内容带了过去 → 主干编译不过。 这次明确不跨分支。) 设备事实卡补第七节,记录两个成因、取舍与遗留。2026-09-17 为了连上 Neo2 绕了四轮弯路,根因**不在设备侧**: Neo2 被 Parallels 直通进了虚拟机(`prl_vm_app` 持有它的 user client), 之后释放了又被 Microsoft Edge(Chromium WebUSB)认领。 而脚本当时只会说"未发现设备,请检查 USB 调试" —— 于是排查一直被引向 设备侧(开关、拔插、描述符),迟迟找不到主机侧的真凶。 改法:把"设备在总线上"与"根本没插上"**分成两条路**: · 在总线上 → 打印它暴露的接口类别 + **谁持有它**(user client 占用者) · 不在总线上 → 才是线/开关的问题 并明确写出 ADB 接口的判据(`255 / 66 / 1`)。 命令用的是**按设备递归取子树**(`ioreg -r -n "<名>"`), 不是全量 grep 拼字段 —— 后者会让不同节点的字段串在一起, 臣据此一度误判"Neo2 有 ADB 接口"。 ⚠️ 同时记下一个**已确认的硬事实**:iReader Neo 2(SmartOS / Android 10) 在主机侧**无任何占用者**、设备侧 USB 调试开关反复确认已开的情况下, 仍只暴露一个名为 `MTP` 的 `255/255/0` 私有接口 —— **该机型不产出 ADB 通道**,调试通道不可用。以后不要再在这上面花时间, 改用设备内「自检」页取数。 (脚本细节:`$NAME」` —— bash 会把紧跟其后的多字节字符字节并进变量名, 故一律写成 `${NAME}`。)方案落地第一步。它是两件事的**共同底座**(故先做它,不分成两遍): · 指标走内网(LAN 优先) · 大文件端到端传输(不经服务器 ⇒ 不受服务器磁盘与流量限制) ⚠️ **这一步最关键的不是功能,是安全**: 绝不能 `HttpServer.bind(InternetAddress.anyIPv4)` —— 那会同时绑到公网网卡上, 等于在公网开了一个端口。故: · 只绑 `pickLanAddress` **选中的那一个**内网地址; · 只暴露**只读、无数据**的 `/lan/ping`,未知路径一律 404; · 将来接数据/文件时用一次性令牌 —— **不拿"同网段"当授权** (那是隐蔽监控的形态,见方案 2.1 的合规红线)。 地址选择收敛在 `pickLanAddress` 一条纯函数里,因为**选错网卡的后果最难定位** (表现是"服务看着开了、对面却连不上")。规则:排除回环 / 169.254 链路本地 / IPv6 / 虚拟网卡(utun、docker、br-、veth、vmnet、tap…),**私有网段优先**。 没有可用内网地址时**返回 null 且不启动** —— 不退而求其次绑公网。 可测性上做了一处刻意取舍:入参收 `(网卡名, 地址列表)` 简单形状, 而不是直接吃 `NetworkInterface` —— **后者没有公开构造函数**, 吃了它就没法在测试里构造"VPN + Wi-Fi 同时存在"这类最容易选错的场景。 验证: · 新增 12 条测试(9 条穷举地址筛选 + 3 条服务本身),客户端 346 → **358/358** · 其中一条**真起服务 + 真发 HTTP 请求**(不是"调了 API 就算过"), 因为"绑了但对面连不上"正是本模块最容易出的问题;并断言未知路径 404 · **Mac 真机验证**:网卡 en0 192.168.0.85 / en7 192.168.0.12 → 选中前者; 服务起在 192.168.0.85:49504;/lan/ping → 200 {"ok":true,"v":1};未知路径 → 404 · `dart analyze lib test` 干净 接线:主壳 initState 启动、dispose 停止;启动失败**不影响主流程** (局域网只是"更快",不是"必需"),只在看板自检页如实显示状态 —— **"没开"和"开了但对面连不上"必须能分辨**。 下一步:查看端拿到地址后先试内网、失败回落云端;再做文件传输。把第二步的探测结果露出来 —— 否则"走内网还是走公网"只是代码里的一个变量, 用户看不见,也就没法判断到底有没有生效(本项目一贯要求可验证)。 **三种状态必须分得开**: · 对端未上报(可能不在同一局域网) · 已上报但探不通(会列出试过哪些端点) · 可达(显示具体是哪个端点) 混成一句"不可用",用户就没法判断是"我这边的服务没起来"还是"对端根本没报"。 ⚠️ 诚实边界:探测结果**不是实时推送的**,在下一次节流刷新时反映到界面(最多 10 秒)。 这是刻意的取舍 —— 为一个诊断行加一条推送通道要多一处要维护的接口, 而看板本来就有节流刷新,够用。 客户端 361/361、analyze 干净。按项目纪律「**凡多层持同一份清单,必须有真实形态数据端到端穿透**」补上 —— 这条链路横跨四处:客户端上报 → 网关校验 → 网关存会话 → 随快照下发 → 客户端接收。 单测只能覆盖"网关校验"那一处,而本项目最贵的一类 bug 恰恰是**跨层静默丢数据** (历史上白名单过严把 capabilities 全剥掉,谁都没发现)。 跑法(本脚本自带环境,不碰生产): 起临时 redis + 本地网关(无 PG 走 Redis 授权回退)→ 手动写一条 auth 授权 → 跑 ★ 实测结果(**证明安全规则在链路里真的生效**,不只是"函数会拦"): 上报 5 条(2 条合法 + 公网地址 / 主机名 / 云元数据地址各 1 条) → 快照里只有 ["192.168.1.5:49504","10.0.0.7:1234"] → 三类非法值**在真实链路里被拦掉** 再次全部非法上报 → 旧端点**被清掉**(快照不再带该字段), 避免查看端拿着上一轮的过期地址去连 过程中顺带确认了两件既有机制(都不是本次改动引入的): · 订阅有**授权校验**,匿名订阅会被 NOT_PAIRED 拒 —— 头一次跑就撞上了, 这反而说明授权守得住;本地无 PG 时走 `auth:<deviceId>` 的 Redis 回退; · 无 PG 时网关是 stateless,不落库。 另:生产网关已同步部署并核对 —— `sha256sum` 与本地**一致**, 即生产跑的就是这份被 E2E 验证过的代码。方案文档里的 ③,也是产品负责人的**原始诉求**:文件不落服务端、 端到端传输、支持超出服务器磁盘容量的大文件。 实现落在第一步做出的那个"只绑内网的小服务"上 —— 这正是把底座先做出来的回报。 ## 端点 | 端点 | 作用 | |---|---| | `offerFile(path)` | 登记一个待传出的文件,返回带**一次性令牌**的句柄 | | `GET /file/<token>` | **流式**返回,支持 `Range` 断点续传 | | `revoke(token)` | 传完即撤销 | 文件从发送端磁盘**直接流到**接收端磁盘,服务端一分钟都不参与 —— 所以既不受服务器 45G 可用磁盘的限制,也不烧流量包。 ## 安全(这一块比功能重要) - **令牌即授权,不拿"同网段"当授权** —— 那等于把物理接近当成授权依据, 正是隐蔽监控的形态(方案 2.1 的合规红线)。令牌由上层显式发放,与配对码同一思路。 - **令牌不存在/过期一律 404,刻意不用 403**:403 会泄漏"这个令牌曾经存在", 等于给爆破一个反馈信号。 - **路径不来自请求**(由登记时的本地路径决定)→ 结构上就没有路径穿越。 - **`stop()` 清空登记**:停止服务 = 不再共享任何文件。否则下次 start 会带着 上一轮的令牌复活 —— 令牌不该比它所属的那次服务活得更久。 (这条是被测试逼出来的:用例间单例状态残留,一查才发现语义本身就不对。) ## 校验:把"传完了"和"内容对"分开 `LanDownloadResult.verified` 是**返回值的一部分**,不是一行日志。 大文件最常出的问题是"字节数对得上、内容不对"(断点拼接错位、对端文件被换过)。 对端没给哈希时**不算验过** —— "没坏"与"验过了"是两件事,不能拿后者冒充前者。 哈希走**流式**(`sha256.bind(file.openRead())`),大文件不整个读进内存 —— 那正是本模块要解决的问题本身。 ## 验证 - 新增 7 条用例(端到端 sha256 一致、令牌错 404、撤销后 404、 Range 206 + 分片内容正确、越界 416、中文文件名 URI 编码、登记不存在的文件报错) → 客户端 361 → **368/368** - **真机实测**(本机真起服务、真传文件、真校验): 源文件 64.0 MB 登记(含流式 sha256)502 ms 传输 64.0 MB / 601 ms ≈ **106 MB/s** 端到端校验 ✓ sha256 一致,字节数一致 → 1GB 也就十来秒,**"超出服务器磁盘的大文件"这条诉求成立** ## 依赖 新增 `crypto`(Dart 官方、纯 Dart、极小):大文件传输没有端到端校验是不合格的, 这个依赖值得。未引入任何原生插件,安装包体积基本不受影响。产品负责人要"设备间传文件"免去手工粘贴领取码。这需要一条信令通道。 本提交只做**服务端侧**(协议事件 + 网关转发 + 端到端验证), 客户端接线留作下一步 —— 一次改太多、又验不透,是本项目最贵的一类错误的温床。 ## 协议:新增 3 个上行 + 2 个下行事件 | 事件 | 方向 | 作用 | |---|---|---| | `agent:offer` | 上行 | 采集端发布"有个文件待领" → 广播给该设备的订阅者 | | `viewer:offer` | 上行 | 查看端发布 → 定向送到目标设备的采集端 | | `viewer:unsubscribe` | 上行 | **退订** —— 局域网直连时改走内网,必须能退掉云端订阅 | | `server:offer` | 下行 | "有人要给你一个文件,凭这些信息去取" | | `server:unsubscribed` | 下行 | 退订回执 | ## ★ 三条设计取舍 **① 服务端刻意不解析"领取码"的格式。** 载荷里把 `lan_endpoints`(数组)与 `token` **分开传**:端点走 `normalizeEndpoints` 校验(只收私有网段 —— 与 hello 里**同一条防线**,不因 "这次是 offer"而放宽),而那个码是客户端之间的约定,服务端多懂一层 就多一处要跟着改的地方。 **② `viewer:offer` 必须先过授权。** `isAuthorized(target, accountId)` 复用同一套判据。少了这一步, "发文件"就成了**绕过授权往别人设备推东西**的口子。 **③ 退订只摘 Redis 订阅,不动 socket 房间。** 房间是**账号级**(`viewer:<accountId>`,一个账号可能多端在线), 而订阅是**设备级**。把房间退掉会连带断掉该账号看别的设备的连接 —— 那是另一回事。转发路径本就按 `getSubscribers(deviceId)` 查,摘订阅即生效。 ## 验证:端到端穿透(`tests/tools/lan-signal-e2e.js`) 真起 socket 走完整链路,**8/8 通过**: · `agent:offer` → 订阅者收到,端点/文件名/大小原样到达 · **公网端点在 offer 这条路上同样被拦**(没绕过同一条防线) · `viewer:offer` → 目标设备的采集端收到 · `viewer:unsubscribe` → 收到回执,且**此后不再收到该设备的帧** (这一条是"省流量"能不能成立的前提 —— 退不干净就是空话) 另:跑 `lan-signal-e2e` 时发现订阅本身有授权校验(匿名订阅被 `NOT_PAIRED` 拒), 这在设计之内,脚本里如实写明。服务端侧上一步已就绪。这一步把两端接上: 选完文件**自动通知对端**,对端那边直接出现待领提示,不用再转发那条码。 ## 两条路并存,而不是二选一 免粘贴通了就走它;**不通就自动退回"把码发给对方"**。 刻意不是二选一 —— 那会让"不在同一局域网"直接变成功能不可用。 界面把这两种结果**分开说**:能通知对端时不给用户增加任何动作; 没通知成时明确要求"请把这条码发给对方"。 ## 共用一份解析(单一真相源) `parseIncomingOffer` 放在 `lan_service.dart`,**两端共用** —— 采集端与查看端收到的是同一个事件形状,各写一份解析迟早会漂移 (本项目为此吃过多次亏)。 ## ★ 一处必须补的坑:`didUpdateWidget` 宿主(采集端页/查看端页)的会话对象是**异步创建**的:卡片先建、`_agent`/ `_session` 后到。若只在 `initState` 订阅,这个通道会被**永久漏掉** —— 症状是"免粘贴时灵时不灵",而且极难定位。 **这与本项目刚修过的 `AccountCard` 是同一类问题**(入参缓存必须配 `didUpdateWidget`), 所以这次是**主动**加上并写测试守住的,而不是等它变成故障。 ## 避免重复实现 手工粘贴与免粘贴**共用同一个下载实现**(`_download`)—— 两条入口、一套逻辑。 分开写迟早出现"一条路径修了、另一条没修"。选端点也复用 `pickReachableLanEndpoint`(对端可能报多个,先挑真的可达的那个)。 ## 验证 - 卡片测试 5 → **9 条**,新增:收到待领文件显示"谁给的/什么/多大"、 「忽略」后不再纠缠、**流入参后到也必须订得上**(守 didUpdateWidget)、 没有免粘贴通道时粘贴那条路仍可用(降级而非不可用) - 客户端 379 → **383/383**,`analyze` 干净 - Android release 构建通过:`com.inkwatch.eink` v18 / 19.8MB - 出包 `dist/inkwatch-eink-1.1.8.apk` ⚠️ 服务端**无需再部署**(信令在上一步已上线)。与主干同一份实现(产品级)。同步前逐个核对基线:分支已有上几轮的改动, 本轮差异是纯新增;viewer_page 的看板自检页因两分支差异大,用锚点精确插入。 要点见主干上那条提交,关键三条: · **两套数据源必须互斥** —— 两条都在跑意味着云端流量照烧, 而那正是本功能要省的东西; · **三道闸防重复**:_lanMode 时忽略云端帧 / 轮询按 seq 去重 / 两条路共用同一个 _ingestMetricsPayload; · **不等退订回执就切** —— 在途帧由第一道闸丢掉,等回执只慢一拍。 验证:客户端 383 → **387/387**,analyze 干净,Android release 构建通过。 ⚠️ 切换时序仍无自动化覆盖,真机形态验证待两台设备同局域网实测。新增解析用例,重点守两条最容易漏的: * **只有中继(没有内网端点)也必须能解析** —— 三层里的最后一层, 解析不了就等于没有,而不在同一局域网也能传全靠它; * **三条路一条都没有 → 不解析**,即不发一条哪个都走不了的 offer。 另守:走直连必须有领取令牌;中继 id/token 缺一视为没有中继(不当半个对象用)。 客户端 387 → 392/392。## 背景:三层降级上线后做真机验证 产品负责人要求实测,臣写了探针用**客户端自己的代码**走三层 (另写一份测的只是"我以为的逻辑")。**第一层局域网直连当场全绿**, **第三层中继连续三次失败** —— 过程与结论如下。 ## 修掉的三个真 bug(都值得记) **① 失败的中继会占满并发额度** 一次失败(对端没点收下)会让空壳会话活到总 TTL(10 分钟), 期间再开中继永远是"请等前一条结束"——**而那条根本没人传**。 → 加 `sweepUnpaired()`:`open` 时先回收"两端都没连、且过了等待窗口(3 分钟)"的空壳。 **② HEAD 预检被 Express 的 `app.get` 抢走** `app.get('/relay/:id')` **会自动处理 HEAD 请求**,且注册在 `app.head` 之前 —— 于是 HEAD 落进"下载端"逻辑,拿**上传令牌**去验**下载令牌**,必然 404。 症状是"发送端永远等不到对端已就绪",而日志只有一串 E_BAD_TOKEN,**看不出是 HEAD 引起的**。 **③ 客户端 close 顺序导致死锁** 原写法"先 `await req.close()` 拿响应头、再写 body" —— 但 Dart 的 `close()` 要等 body 完整才把请求发出去,于是它等一个永远不会来的响应,**请求根本没出门**。 ⚠️ **服务端用 curl 手测全绿**(curl 是先发 body),**只有真客户端能暴露它**。 ## 简化设计(前两次都在打补丁,这次换形态) 取消"发送端先探对端在不在"那整套预检 —— 它对,但脆。 改成 **`attachUploader` 对端没到就挂着等**,接收端一到就叫醒它。 "**谁先到都行**"本来才是中继的自然语义,客户端因此什么都不用协调: 直接写完 body 再 close 即可。 ## ⚠️ 但仍然没打通 当前症状:**接收端(GET 侧)的连接被重置**,服务端日志为 `[relay] 中断: 接收端断开`。臣**三次尝试后停下**,不再盲试 —— 手上有明确下一步(查 GET 侧为什么先断:嫌疑在 `HttpClient.idleTimeout` 默认 15 秒,与"挂着等对端"的时长冲突),但**没有验证过的结论, 所以不写成"已修复"**。 ## 现在的真实状态 - ✅ **第一层:局域网直连** —— 真机验证通过(4MB,sha256 一致) - ❌ **第三层:中继** —— **未打通**,用户走到这层会看到"中继传输中断" - ⚠️ **第二层:公网直连** —— 逻辑完整,但无真实公网可达地址可测 - 服务端 `relay-test` 18/18、客户端 392/392、analyze 干净(**它们覆盖不到这个缺陷**) ## 教训 **"服务端用 curl 测过"不等于"客户端能用"** —— 这次两个 bug(③ 与 ②) 都是只在真客户端形态下才现形的。**端到端探针必须用真代码走真链路**, 手测只能证明服务端自己没错。onMetrics 每帧原 4 次顺序往返(pushFrame → touchPresence EXISTS → markOnline → getSubscribers),合并为 pushFrameAndFanout 单 multi; touchPresence 的 EXISTS 与写入并入同一 multi(2→1)。语义逐项对齐: presence 只写 {ts}、SMEMBERS 原样返回、任一步抛错整体失败。 pushFrame / markOnline 两个再无调用方的旧入口按"同一件事不留两个 入口"的纪律删除。 三处全表扫描(Array.from(this.sessions...))改索引: _agentByDevice / _viewersByAccount 存同一 session 对象引用(单一真相源 仍是 sessions 表),hello/subscribe 登记、disconnect 摘除; _findAgentByDevice 带 !s.gone 竞态防护。首帧标记 Set<字符串> → Map<账号, Set<设备>>,回收从 O(全表) 降为 O(该账号设备数),计数账目平。 正确性修复(均经变异测试自证:删掉即红、恢复即绿): · relay sweep 双类判定:传输中按 lastActivityAt(每 chunk 刷新), 修「慢传被误删致每账号并发 1 额度统计破」;挂死传输仍有兜底; · LoginThrottle 加 FIFO 硬上限 + hit() 命中移队尾(淘汰序 FIFO→LRU), 修「一次性 key 喷洒把热点 key 计数冲掉、限流被绕一截」; · 角色翻转(协议违规:双角色挤一条连接)对称摘另一侧索引, 修「索引留死 session 引用、反复违规无界增长」; · relay 限速定时器覆盖前先取消,修「与背压 drain 交错时限速被突破一瞬」。 getOwnerId 加缓存(只缓存非 null —— 缓存 null 会造出"设备刚注册 2s 看不了"的拒绝窗口;owner_id 三个写点均不改非空值,已逐一实证); claimDevice 成功后广播失效(纵深防御)。 实测(2000 帧突发 ×3 查看端,5+5 轮,tests/perf-bench.js): 墙钟 0.42s→0.21s、吞吐 ~4800→~9350fps(+95%)、p50 8ms→2ms、 p99 17ms→5ms、丢帧 0;稳态 68fps/订阅/offer 无差异。tests/perf-bench.js:四场景基准(metrics 定速/突发/内存/服务端计数器、 churn 订阅风暴、offer 定向时延、scan 索引微基准),--json 机器可读。 存在理由:性能优化必须"前后同一把尺",不接受"应该更快了"。 本地四容器栈的镜像可能比仓库旧(曾吃过 09-15 镜像的亏:测的根本不是 当前代码)——工具注释里写明测前先 up -d --build 并用 docker exec grep 验证代码版本。本次基准就是这么发现容器缺 viewer:unsubscribe 的。 docs/perf-baseline-{before,after}-2026-10-14.json:5+5 轮完整存档。 结论:旧代码突发呈双峰(~4800/~9350fps 两档),新代码全快档; 中位墙钟 0.42s→0.21s、p50 8ms→2ms、p99 17ms→5ms、丢帧 0。 run-all.sh 接入三个离线套件(会话索引 50 / 登录限流 22 / 中继 30), 计数标签随实际断言数修正。一个 pmon-app-1 容器跑 postgres + redis + gateway + business (supervisord 各自守护、各自重启),外加跑完即退的 migrate。 合容器不合进程:两个 node 进程仍是独立进程,SIGTERM 直达原生优雅 关闭处理器,全部既有安全测试原样有效。 关键不变量(均有测试看守): · compose project 仍是 pmon、数据卷仍是 pmon_pg_data/pmon_redis_data → 与四容器路径随时互切、数据不动(卷名锚点,改名=清库); · 端口全部绑 127.0.0.1(云防火墙全端口放通,0.0.0.0=公网直露); · init.sql「空数据目录才初始化」语义复用官方 entrypoint,不重写; · 启动门 start-{gateway,business}.sh 复刻 depends_on service_healthy, 防全新卷首启时 node 抢在 PG 初始化前 FATAL 停摆; · 安全开关两个进程同读一份环境,一处传全。 dockerfile-coverage-test.js 扩展:「all」镜像双入口逐断 require 图、 安全开关通路按 compose 文件分别断言(含服务名映射)、新增第 6 段 端口回环断言(IP 前缀可选——裸映射 "3000:3000" 恰是绑 0.0.0.0 的 最危险写法,第一版正则漏了它,审计加固)。 代价如实记入 runbook 7.3:进程隔离弱于四容器、重启四进程齐动、 business 启动参数比四容器多堆上限(D-1)。用户定:除 README / LICENSE / .env.example 外,文档类文件只在本地保存, 除非项目运行离不开才提交。docs/ 全部 11 个文件(架构方案、专项设计、 腾讯云运维篇、改名记录、性能基准 JSON、两份旧名历史存档)无一是运行 依赖(引用全是注释与文档间链接),整体移出。 本地存档:~/WorkBuddy/水墨屏工具/inkwatch-docs-local/(412K)。 同时清掉 patch_relay_v2.py —— 一次性字符串手术脚本(给旧 relay 修复 用),现在跑只会损坏 relay.js,属运行无关物。 连带修正(不留悬空引用): · README:目录树去 docs/、单容器取舍改为内联、perf-bench 数字改本地存档 · client/README.md:三处 docs 引用去路径留实质 · eink_board.dart / config_migration.dart:注释去路径留实质 · docker-compose{,.all,.prod}.yml:三处"详见 docs/…"改为内联关键纪律 · privacy-scan.js:quotepath 判据的例句随中文名文件消失而改写,判据保留 · .gitignore:加 docs/ 与 patch_*.py 段(政策机械固化,防再误提交), 顺手去掉尾部重复的 .git-rewrite 条目 验证:隐私扫描 207 文件零命中;镜像覆盖 43;Dart 392 全过; 全仓库 docs/ 悬空引用为 0。P3 留下的边界("P2P 是能力而非在用通道")本轮闭合。 · P2pSession(并入 link/p2p_driver.dart):打洞成功后**保留 socket**—— NAT 映射绑在本地端口上,关掉就废、重开等于重打一次洞。帧协议单包: PMON-FRAME-REQ:<nonce> → PMON-FRAME:<nonce>:<json>,payload 与云端 server:metrics 同形(查看端两套数据源共用一套解析)。nonce 必须回显 (迟到/串包丢弃);单包上限 8KB,超了宁可不回也不发分片包。 · 采集端:收到带候选的 offer → 开数据面 → startServing(() => _latestForLan) (与局域网直连同一份数据,不新造缓存)。 · 查看端:收到带候选的 offer → 开数据面 → 2 秒节拍拉帧 → seq 去重后走 同一条摄取路径 → hub 记 LinkMode.p2p;连续 3 次无响应即关闭回落。 · LinkHub 加 observeP2pProbe(P2P 的分数进选路,打通与否如实记录)。 ★ 两个坑(都已修,且值得记): 1. RawDatagramSocket 是单订阅流——"打洞一个监听、数据面换一个监听"会抛 "Stream has already been listened to",异常被 catch 吞成 null,表现 为"打洞无声失败"。只能一个监听器活到底、按前缀分派。 2. 测试辅助函数把对端候选端口写死,参数只改了本地端口 → 只有第一组 恰好对上,其余全挂。辅助函数必须全参数化。 测试:Dart 451 全过(+会话 6 项);变异自证(响应 nonce 写死 → 拉帧用例 精确变红);run-all 25 通过 / 2 失败(仅剩既有 Rust 事件集漂移)。 限制(诚实):候选目前随文件 offer 交换,故指标走 P2P 发生在该对设备 发过一次文件之后。要"订阅即可 P2P"需把候选上报挪进 agent:hello/快照, 属协议扩展,下一轮做。改 name: pmon → inkwatch,让容器名与项目名一致。 ★ 关键防呆:name 同时是**卷名派生源**,故 volumes 段显式锁定 pg_data: {name: pmon_pg_data} / redis_data: {name: pmon_redis_data}。 不锁的话 compose 会去找 inkwatch_pg_data 这套新空卷 = 清库。 实测:切换后卷仍只有 pmon_pg_data / pmon_redis_data(无 inkwatch_*), devices=6 不变,migrate Exited(0),gateway/business 200。 镜像同步改名 pmon-all → inkwatch-all(docker tag 复用,不重建)。对外只发布一个口:3500 = 容器内 nginx 统一入口 /api/ → business(4000) 其余 → gateway(3000)(socket.io / health / relay) · deploy/nginx-all.conf(新):容器内入口配置。关键细节: - /healthz 本地直返(区分"入口挂了"与"后端挂了") - ★ X-Forwarded-For **透传**而非追加:若用 $proxy_add_x_forwarded_for, XFF 会变成 "client, 127.0.0.1",而 business 的 TRUST_PROXY_HOPS=1 取到的就是 127.0.0.1 —— 所有客户端在配对码限流看来是同一个人。 故用 map:上游有 XFF 就原样透传(hops 语义不变),无才补 remote_addr。 - relay 必须 proxy_request_buffering off + proxy_buffering off(否则 上传体被缓冲到临时文件,"不落盘"是假的) · Dockerfile.all:装 nginx、整份替换 nginx.conf、EXPOSE 只留 3500 · supervisord:加 [program:nginx](QUIT 优雅退出) · healthcheck-all:加第 5 项(入口 /healthz),入口挂=全站挂,必须一起判 · compose.all:ports 只留 "127.0.0.1:3500:3500" · openresty-pmon-ip.conf:由"按路径分反代 3000/4000"简化为**单一 upstream 3500** ——"哪个路径给哪个进程"只在容器内定义一次,接入层不再有第二份分流清单 · dockerfile-coverage 第 6 段:断言由"≥3 条端口"改为"必须有 3500 且 **不得发布 3000/4000/5432/6379**"(比原来更严,防绕开统一入口) 实测(现网):docker port 仅 3500;五进程 RUNNING;healthy; 3500 上 /healthz ok、/health 通、socket.io 握手拿到 sid、relay 往返 2048 字节全等; 经 80(OpenResty→3500)再验 relay 128 字节全等。 变异自证:删端口 → 精确 2 红;恢复 → 45 全过。按评估结论(性能无压力 p99 5-11ms/CPU 0.09%,真风险是"挂了没人知道") 做的三处低成本加固,不改架构: · PG 日志前缀 + 慢查询(migrate.sql / init.sql,ALTER SYSTEM 幂等): - log_line_prefix = '[postgres] %m [%p] ':单容器五进程共用一个日志流, PG 原来无标识,排查要靠猜;现在与 [gateway]/[business]/[supervisor] 对齐 - log_checkpoints = off:每 5 分钟两条 checkpoint 是纯噪音 - log_min_duration_statement = 200:**慢查询直接落日志**,不依赖 pg_stat_statements(免装扩展、免重启),是排查"这次为何慢"最省事的抓手 走 ALTER SYSTEM 而非改 supervisord command:%m/%p 里的 % 会被 supervisord 的 %(ENV_x)s 插值解析,绕开这类坑。 · healthcheck-all.sh:set -e 改为**逐项 chk + ✓/✗ 输出**。 原版一挂就退出,docker inspect 的 Health.Log 里只有空输出—— unhealthy 时看不出是哪一项,等于把排查成本转嫁出去。 · deploy/watchdog.sh(新):每 2 分钟探 /healthz + /health, **只在状态变化时**写日志(DOWN/RECOVERED),可选 INKWATCH_ALERT_CMD 外部通知。装法:/usr/local/bin + /etc/cron.d/inkwatch-watchdog。 坑:手动以普通用户跑会 Permission denied → 预建 /var/log 与 /var/lib 下的文件并 chown 给运维用户(cron 以 root 跑不受影响)。 坑:curl 连接失败时 -w 已打印 000 且退出码非 0,再 `|| echo 000` 会拼成 "000000" → 改为"输出为空才兜底"。 实测: - PG 参数生效(show 三项确认)、日志里 checkpoint 噪音消失、 business/gateway/supervisord 行清晰可辨 - 健康检查输出五项 ✓ - 看门狗:正常 exit 0 / 故障 exit 1 并记 DOWN / 持续故障不重复写 / 恢复记 RECOVERED;crond 在跑,cron.d 已装 - schema-consistency 100 项(2 个既有 Rust 事件漂移)、 dockerfile-coverage 45/0、privacy-scan 零命中生产压测抓到的真缺陷(**统一入口引入**,四容器时代无此层): 现象:经 3500 传 1MB 中继,只收到 130699 字节就挂死;直打 3000 完整。 根因:容器内 nginx 配了 upstream keepalive 池,却在非 WebSocket 请求上发 `Connection: close`(map 的默认分支写成 close)。两者语义冲突——连接被 标记关闭却要归还到池里,导致上游响应流在 ~128KB(正好是 nginx 的 proxy_buffer 量级)处停住。 修法:map 的 `''` 分支改为空字符串(保持 keepalive 复用),WebSocket 仍 upgrade。 这是 WebSocket + keepalive 并存的标准写法。 修复后:1MB 经 3500 完整传输 0.433s(与直打 3000 一致)。 同时新增 tests/bench-srv.py(生产压测,三维度:HTTP 吞吐 / socket.io 握手 / 中继传输),用共享容器网络命名空间跑,只读不写库。 压测结果(现网 2 核 3.5G): · HTTP /health:1000 请求 / 1.21s = **827 QPS**,p50 22ms / p99 66ms · socket.io 握手:200 次 / 0.15s = **1369 QPS**,p50 6.5ms · 中继 1MB:3.05 MB/s(限速目标 8MB/s 的 38%) · 中继 10MB:**异常**,见下 · 容器 CPU 峰值 **113%**(2 核吃满 1 核以上),内存 170MB ⚠️ 遗留缺陷(已确认、未修,需专门一轮): 中继传 **>约 2.6MB** 时吞吐骤降到 60-180KB/s(curl 对照一致,非压测脚本问题)。 已排除三个假设:容器内 nginx(本轮已修 128KB 卡点)、限速常量(放大 128× 仍慢)、 背压 pause(摘除仍慢)。剩余嫌疑在 gateway 的 pipe/写入模型。 这是**既有缺陷**(直打 3000 同样复现,四容器时代即存在),非本轮引入。背景:生产压测确认中继 >1MB 时吞吐崩塌(2MB 传 20s 只到 139KB)。 缺陷**本轮未修好**,如实记录尝试过的五条路都失败(详见 relay.js 注释与 runbook): ① 放大限速 128× → 仍慢;② 摘背压 → 仍慢;③ 背压改 once('drain') → 仍慢;④ 完全去掉限速 → 更差(139KB 卡死);⑤ 改 pipeline() → done() 依赖的 onDrain 被删导致 /relay/open 崩,已回滚。 已排除:容器内 nginx(另修 128KB 卡点)、body-parser(PUT 路由无)、 TCP/内核(容器内纯 HTTP 4MB 响应仅 497ms)。 因此在根因修好前收紧上限: · MAX_ONCE_BYTES: 2 GiB → **1 MiB**(实测稳定边界:1MiB 337ms 完整, 2MiB 卡)。可用 RELAY_MAX_BYTES 覆盖,修好缺陷后调大即可、不必改代码。 · 报错文案改 **humanBytes() 自适应单位**(原来写死 "GiB",上限一变就错)。 文案里给出路:"较大的文件请用局域网直连"(LAN 直连不走中继、不受此限)。 · compose/.env.example 透传 RELAY_MAX_BYTES。 · relay-route-e2e 的 E_TOO_LARGE 断言**解耦单位**(匹配「上限」二字, 不再绑死 GiB —— 否则改上限就假红)。 现网验证:恰好 1MiB → 完整传输 337ms;1MiB+1 → 409 + 人话;2MiB → 409; 健康检查五项全过。★ 这是**客户端连不上**的真凶,也是我上一轮报告里说"仍然待办"的那条。 根因:1Panel 的 WAF 在 http 级注入 access_by_lua_file /usr/local/openresty/1pwaf/waf.lua 且**只放行登记在 1Panel 站点列表里的域名**。pmon 的接入层是手工落盘的 conf (不在 1Panel 站点列表),于是所有走 upstream 的请求一律 403; 而 /healthz 是本地 return 200,**反而正常** —— 极易误判成"服务是好的"。 修法:在 pmon 的 server 块里写 `access_by_lua_block { }` 覆盖 http 级指令。 只对本站跳过 WAF,rbook/searxng 实测不受影响(502/301 原样)。 ★ 途中踩的两个坑(都已固化进脚本,防止再犯): 1. **仓库模板的 server_name 是脱敏占位** 203.0.113.10。直接 scp 覆盖生产 → Host 不匹配任何 server → 落到 1Panel 的 default_server → 又被 403, 且 /healthz 仍 200,看起来像"生效了"。我第一次就是这么被骗的。 2. 只替换 server_name 不够:注释里还有一处占位(客户端指向的地址文案)。 新增 deploy/apply-openresty.sh: · 全文替换脱敏 IP(可用 --dry-run 预览) · **自检生成文件里不得残留占位 IP**(残留就报错退出,不写生产) · 自动 nginx -t + reload + 回环自检(打印 /healthz 与 /health 的状态码) 现网验证(从另一台外部机器 <EXTERNAL_HOST>): /health → 200,socket.io 握手 → 拿到 sid + upgrades:["websocket"] rbook → 502(其自身后端未起)、searxng → 301(均未受影响)体检发现护栏自身有三处缺陷。护栏是后续所有改动的前提,故先修它。 1) schema-consistency-test.js 第 13 段被误包进 if (fail > 0)({ 在 643 行、} 在 668 行), 全绿路径下那 7 条断言从不执行、脚本照样返回 0;而汇总行又打印在它之前, 即使执行也不计入统计 —— 护栏看起来在工作,实际没有。 改为:第 13 段无条件执行 + 汇总移到文件末尾 + 退出码按失败数返回。 实测 100 通过/2 失败 → 107 通过/0 失败(多出的 5 条正是此前从未跑过的)。 变异自证:把 candidates.dart 的 kMaxCandidates 8 改成 9,本套件 exit=1 并报 「上限值一致」;改回即恢复绿。修复前此变异根本测不出来。 2) Rust 与 JS 事件清单漂移:transport.rs 上行 4/下行 6,protocol.js 上行 7/下行 8。 Envelope::validate() 会拒绝表外事件报 E_BAD_TYPE,属潜伏故障。 补齐 5 个事件并把长度常量改为 4→7、6→8,同步 transport.rs 里写死数量的单测。 P2P 入口已下线但代码保留,故事件表按完整集合补齐;代码注释已记明: 将来若物理删除 P2P 代码,必须同步删掉这三个事件,否则就是给死代码陪绑。 3) theme_guard_test.dart 的 _uiFiles 漏登记 transfer_card.dart(630 行) 与 skeleton.dart(111 行),共 741 行界面代码不受护栏保护。已补登记。 两文件本身无硬编码色值,登记后直接绿。 新增 tests/guard-structure-test.js(36 项):汇总行必须晚于最后一个断言 + node --check。 刻意不采用"process.exit 只许一次"(实测 12 个文件各有 2-5 次,前置依赖缺失时早退正当) 与"汇总行缩进为 0"(本仓有脚本主体包在 main() 里,属正常结构)。 变异自证:造一个复刻上述形态的探针,守卫报「汇总@17 最后断言@20」并 exit=1。 验证:run-all.sh 28 套件 27 通过/1 失败(唯一红为隐私扫描的存量公网 IP,另行处理); cargo test --release 59/0;flutter test 451/0。 顺带订正 run-all.sh 头部过时计数(指标一致性 99→107、Dart 416→451), 并注明计数以实跑为准 —— 静态数 test( 会漏掉 for 循环内动态生成的用例。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。## 缺陷 `gateway._seqWindow` 是进程内 Map。网关重启窗口即清空,而客户端 session_id 不变、 会重发最多 120 帧补传 → 全部被判成新 seq → 重复入库 → 历史回溯的均值被慢慢拉偏, 且**不报任何错**(只翻库才看得见 seq 重复)。实测复现:10 帧正常入库后重启网关、 同会话重发同 10 帧,`device_metrics` 由 10 行变成 **20 行**(修复后仍为 10 行)。 ## 为什么不能放 DB 层(实测已排除,不再试) device_metrics 在 TimescaleDB 分支上是 hypertable(按 ts 分区),PG 要求分区表上的 唯一索引必须包含分区键,实测报: ERROR: cannot create a unique index without the column "ts" (used in partitioning) 而 ts 由服务端改写(同一帧补传时 ts 不同),放进唯一键就丧失幂等语义。 详见 deploy/init.sql 中 idx_metrics_device_seq 上方的注释。 ## 改法(不新增任何 Redis 往返) - store.pushFrameAndFanout(deviceId, frame, dedup):签名加可选第三参,null 时行为与 改动前完全一致;去重命令**追加在原来那一个 multi 末尾**,返回值改为 { subscribers, isNewSeq },并改用显式索引(原 res.length-1 会指错项)。 - gateway.onMetrics:按 `seqwin:<device>:<session_id>` 传 dedup。键里带 session_id 是刻意的 —— 新会话 = 新键,天然实现"换进程就重置窗口"。 - gateway._hydrateSeqWindow:内存窗口不存在时(网关刚重启/设备首次出现)用 zRange 从 Redis 读回最近 N 个 seq;在 onHello 里 await,**每设备只查一次**。 - 判据优先级:Redis 的 isNewSeq 优先(跨重启权威),内存窗口为兜底(零往返, Redis 不可用时用它挡住同进程重复)。 - 裁剪窗口与续 TTL 按 32 帧节流(首帧必发,保证键生下来就带 TTL)。理由见实测: 三条命令每帧都发时突发 p99 由 6ms 升到 13~15ms(Redis 单线程,突发约 1 万 multi/秒, 瓶颈是命令条数);节流后每帧 8 条命令,p99 回到改前水平。判重正确性不受影响。 ## 失败语义:与配对码**相反**,别"统一" 配对码守的是安全边界(判定不了就关门,失败关闭);去重守的是数据正确性 (判定不了就**放行入库**,宁可偶发重复一行,不可整段历史丢数据)。 故 pushFrameAndFanout 抛错时 catch 住、`_dedupDegraded++`、warn 日志、 把帧照常入队落库,并在 /health 暴露 `dedup_degraded`。代码注释里写明了这个区别。 ## 顺带发现并处理(请复核) 无 session_id 的老客户端(1.0.8 及更早)**刻意不落 Redis 窗口**: 没有会话标识就无法把窗口划到会话作用域,若按 `nosession` 建持久窗口, 老客户端进程重启后 seq 从 1 重来 → 全部命中旧窗口 → 被判重复丢弃, 正是当年"永久哑火"事故(这次上界是窗口 TTL,但一小时无数据同样是丢数据)。 实证:去掉这条守卫后连跑两次 tests/persistence-restart.js,第二次 "落库行数与上报帧数一致" 变成「期望 30 实得 0」。现行客户端 (client/lib/host/link.dart 的 _sessionId 每进程生成)不受影响。 ## 新增套件 tests/dedup-restart-test.js(22 项,已接入 run-all.sh) ① 重启后同会话重发同批 seq → 行数不变(E1 修复目标) ② 新会话从 seq=1 重发 → 必须能入库(win.sid 历史事故的回归守卫) ③ Redis 不可用 → 帧仍落库 + dedup_degraded 增加 + 补齐不抛 ④ 无 session_id 不建 Redis 窗口(见上) 变异自证:把 dedup 传参改成 false(等价"没接线")→ ①② 相关 3 项全红, 恢复后 22/22 绿。 ## 验证 - tests/run-all.sh:28 通过 / 1 失败(唯一红 = 隐私扫描的公网 IP 项,本次未触碰) 其中 持久化与重启恢复 24/24、去重跨重启 22/22、Rust 60/0、Dart 469/0 - cargo test --release:60/0(连跑 3 次;run-all 首跑偶发 transport::tests::backoff_grows_... 抖动一次,见回报) - client flutter test:469/0 ## perf-bench 前后(同机同一脚本,本地栈;含 3 次交错复测) 改前(HEAD ae90726)burst p50/p99 = 1/6、4/18、5/14、1/9 ms,recv 9434~9709 改后(本次) burst p50/p99 = 1/4、1/6、1/6、2/7、3/7 ms,recv 9302~9569 steady(定速 100fps)p50 0~1ms / p99 1~3ms,两边一致;churn p50 3ms、offer p50 1ms、 heap 增量 34MB→31MB 均在噪声内。p99 中位数由 14ms 降到 6~7ms,无明显回退。## 缺陷 设备时钟被 NTP 调整(尤其回拨)时,喂给 Rust 状态机的 now 会倒退,而 transport.rs 的重连判断是**绝对值比较**: transport.rs:570 if self.reconnect_at_ms != 0 && now >= self.reconnect_at_ms `reconnect_at_ms` 是 on_disconnected 时用 now + delay 算出来的 (transport.rs:511 / 577)。墙钟一回拨,它就落在"未来"→ 条件长期不成立 → **重连挂死**:界面只有灰灯,不报错、不留痕,用户只能说"连不上了,重开就好"。 三处病灶(都已核实): · client/lib/host/link.dart:273 static int _now() => DateTime.now()... · client/lib/host/viewer.dart:761 同上 · client/lib/link/link_hub.dart:234 `_monotonicishMs() => DateTime.now()...` 而它上一行注释写着"刻意不用 DateTime.now()" —— 注释写了正确意图, 实现和函数名(monotonicish = 假单调)都是错的。 ## 改法 新增 client/lib/host/clock.dart(纯 Dart,不 import flutter,与 timing.dart 同风格): · `wallMs()` —— 墙钟,只用于线格式/落盘/日志 · `MonotonicClock` —— **真单调**:构造时取一次墙钟作基准, 之后 `originMs + Stopwatch.elapsedMilliseconds` · `processNowMs()` —— 进程默认单调读数(各处的默认注入值) · `debugOverrideProcessClock()` —— 仅测试用,验证"默认值确实走单调时钟" 刻意不是"把 DateTime.now() 包一层":那样回拨问题原样存在,等于白做。 用 Stopwatch 而非墙钟的好处是增量真单调;基准取墙钟则让读数仍贴近真实时间, 日志/排查时看得懂(不会变成"启动后第 12 毫秒")。 三处调用点改为走该时钟,且都可注入(`ClientAgent(nowMs:)` / `ViewerSession(nowMs:)` / `LinkHub(nowMs:)`,后者本来就有这个缝)。 link_hub 的假单调函数删除,注释改写为真实原因(并留下这段历史教训)。 ## 故意保留墙钟的地方(改了就是新 bug) | 位置 | 用途 | 为什么必须墙钟 | |---|---|---| | link.dart:294 | 日志行前缀 [HH:MM:SS] | 人要读"几点发生的事" | | link.dart:541 | 生成 session_id 的时间戳段 | 给人看、可排序的标识,不参与间隔计算 | | link.dart:557 | agent:hello 信封 ts | 线格式字段,服务端据此排障/落库 | | viewer.dart:1036 | viewer:subscribe 信封 ts | 同上 | | viewer.dart:1318 / 1378 | lastFrameLocalMs | 界面用 DateTime.now() 与它相减算"离线多久" | | viewer.dart:1579 | 缓存 saved_at | 下次启动要跟真实"上次保存时间"比,单调基准跨进程不可比 | ## Rust 侧持久化结论:**没有任何持久化**(换基准安全) 依据: · rust-core 全仓无 `fs::` / `File::create` / `write_all`(grep 无命中), 状态机不落盘、不跨进程传递; · `reconnect_at_ms` 只是 `Transport` 的普通字段 (transport.rs:465 定义、495/503/511/531/570/577 使用),生命周期 = FFI 句柄对象; · `Serialize/Deserialize` 只挂在 `Envelope`(transport.rs:134),不含状态机时间戳; · 故"进程内单调基准"完全安全,不需要动存档格式。 一处**既有**的轻微混用(本次未改,rust-core 不在边界内):`with_capacity` 用 Rust 自己的 `now_ms()`(系统时钟,transport.rs:487)给 HalfOpenDetector 播种, 而宿主喂的是单调值 —— 两者只差"进程启动→建句柄"的毫秒级偏移,且所有比较都走 saturating_sub,不会出现负数或大跳。若要彻底单一时间源,可让 host 在 onConnected 首次喂值时覆盖播种(建议另开一条)。 ## 新增测试 client/test/clock_test.dart(16 项) ① MonotonicClock:10 万次调用严格不减 / origin 权威(origin=0 时读数必须是个位数 毫秒级 —— 这一条能抓住"包一层 DateTime.now()"的假实现)/ 贴近墙钟 / 实例独立 / 注入可恢复 ② **真 Rust 状态机**(走 FFI,不是模拟):正常推进到点才重连(差 1ms 不触发); 回拨 1 小时连喂三拍全 Idle(缺陷本体的证据);同一实例改喂单调值立刻 ReconnectNow; 前跳 1 小时只触发 1 次 ReconnectNow(不连发,避免唤醒后连接风暴) ③ ViewerSession:注入脚本时钟 → policy 收到的 now 就是脚本值,且全部不是墙钟量级; 等 700ms 让 500ms 事件循环跑一拍 → nextAction 收到的 now 也来自时钟且单调不减 ④ 默认路径:LinkHub / ViewerSession 不传 nowMs 时确实用 processNowMs ⑤ 源码结构守卫:三处不得退回 `_now() => DateTime.now()`,不得再出现假单调函数名, clock.dart 必须真的用 Stopwatch 算增量(与 theme_guard_test 同一套纪律) ## 变异自证(逐个注入 → 变红 → 恢复 → 绿) 1) clock.dart 的 nowMs() 改回 DateTime.now()(假单调)→ 3 项红(含"origin 权威") 2) viewer.dart 忽略注入时钟、改回 DateTime.now() → 4 项红,含两条 ★ 行为断言 ("policy 收到的 now 就是脚本值" / "事件循环 now 单调不减") 3) link_hub.dart 默认时钟改回 DateTime.now() → ④ LinkHub 用例红 4) link.dart 默认时钟改回 DateTime.now()(采集端无行为观测缝)→ ⑤ 源码守卫红 恢复后:16/16 绿。 ## 测试数字 - cd client && flutter test:**485 通过 / 0 失败**(基线 469,净增 16) - cd rust-core && cargo test --release:**61 通过 / 0 失败**(未动 rust-core)## 缺陷(最后一个数据正确性缺陷,与 E1 同级) gateway.js 的 `_flushStorage()` 旧实现是「先把队列整个 splice 走,再 try/catch 只 log」: const batch = this._storageQueue.splice(0, this._storageQueue.length); try { await this.repo.insertMetricsBatch(batch); } catch (e) { this.log.error('指标批量落库失败', e.message); } catch 之后批次**已经不在队列里**:PG 抖动 / 磁盘满 / 连接池耗尽时, 一次失败丢掉**整批(最多 5000 帧)** —— 不留计数、不重试、`/health` 完全正常。 对照同文件"队列满丢最旧"那条路径(_storageDropped + 周期 warn),这条是裸的。 与"静默丢数据"同源,只是这次连丢都没记。 ## 为什么用**独立重试槽**,而不是把失败批次放回主队列 放回主队列是最直觉的写法,但有两个硬问题: ① 与"队列满丢最旧"语义打架:`_enqueueForStorage` 满时会 shift 掉**队首**, 把失败批次放回去等于让"最该被救的旧数据"变成"最该被丢的旧数据"; ② 毒丸(某帧的值始终违反约束,重试多少次都失败)会永久占据队首, 把后面所有**新数据**一起堵死 —— 一条坏帧拖垮整台设备的落库。 故改为 `this._storageRetry = { batch, attempts, lastError }` 独立存放。 ## 毒丸如何隔离 每轮 `_flushStorage()` 的顺序是:**先试重试槽一次 → 然后无论成败都照常处理主队列**。 · 旧批失败**绝不阻塞**新数据落库(这是本缺陷最关键的性质); · 不在同一次 flush 内忙等重试(会把 2 秒定时器拖住,让"暂时抖动"滚成"持续积压"); · 有界:attempts 超过 `_storageMaxRetries`(默认 3,即首次 + 3 次 ≈ 8 秒窗口) → 调 `_discardStorageBatch()` **明确丢弃**; · 重试槽已被占用时新失败的批次不挤掉它(挤掉等于"更早的数据先死"), 也不攒着(内存必须立刻有界),同样走"明确丢弃 + 计数"。 ## `_storageFailed` 的语义(与 `_storageDropped` 不是一回事) · `_storageDropped` = 队列满,主动丢**最旧**(有设计的有界策略,正常压力下的取舍) · `_storageFailed` = 写库失败、重试也救不回来而**明确丢弃**的**帧数** (>0 就意味着真丢数据,要查 PG/磁盘;`/health.storage_failed` 直接暴露) 告警节奏:首丢必报一条(带帧数、已尝试次数、错误原文、前 3 帧 `device_id@seq`, 便于事后判断丢了哪台设备的哪一段、要不要补),之后每累计 100 帧一条, 对齐 `_storageDropped` 的节奏,避免持续故障时每 2 秒刷一条把日志淹了。 `/health` 另加 `storage_retry_attempts` / `storage_retry_frames`: 运维据此区分"暂时抖动(attempts 在涨但 finally 落库)"与"PG 一直不可用"。 `drainStorage()` 改为最多 3 轮:一次 flush 只给重试槽一次机会,关闭时多给几轮, 仍救不回来的打印一行"关闭时仍有 N 帧未能落库"(不静默)。 ## 顺带删一处死代码 `_releaseSeqWindow(deviceId)` 全仓零调用(只有定义),已删除并在原位置留注释说明 为什么现在不需要它:E1 之后去重窗口的权威副本在 Redis,断连**刻意不删** (那正是"重启后补传仍能判重"的依据,靠 TTL 收敛);内存窗口只是兜底判据, 条目数由 `_seqWindowMaxDevices` 的 LRU 上限约束,不需要断连时主动回收。 ## 新增 tests/storage-failure-test.js(29 项,已接入 run-all.sh 离线段) 1. 有界重试:批次进槽不消失 → attempts 1..3 → 超限明确丢弃(6 帧计数准确)、 总尝试 4 次、丢弃告警只 1 条且含帧数/次数/错误/样本 2. 重试成功后不丢数据:前 2 次抛错、第 3 次成功 → 该批 4 帧**原样补上**(seq 完整)、 `_storageFailed` 保持 0 3. **毒丸隔离(最关键)**:含 dev-poison 的批持续失败,同时灌入新帧 → 新帧照常落库(3 → 6 帧),毒丸最终被丢弃且不计入新帧 4. 多次丢弃:累计帧数 5 / 批数 2 准确,告警仍只 1 条(未到 100 帧阈值不刷屏) 5. 回归:正常路径 `storage_failed` 恒 0、队列满 `storage_dropped` 语义不变、 `drainStorage()` 把重试槽那批也刷净 6. 真进程 `/health`:`storage_failed=0`、`storage_retry_attempts=0`、 `storage_retry_frames=0` 且 `storage_dropped` 仍在(PG/Redis 不可达时明确跳过) ## 变异自证 把 `_flushStorage` 的失败分支改回旧实现(只 log、批次消失)→ **16 项红**(第 1 段 7 项全红、第 3 段"进槽/最终丢弃"2 项红、第 4 段 3 项红、 `drainStorage` 1 项红);恢复后 29/29 绿。 注:第 3 段那两条 ★"新帧照常落库"在变异下仍绿 —— 因为旧实现是"立刻丢", 同样不阻塞;它们守的是"将来别为了重试而阻塞新数据",与本缺陷的计数/重试断言互补。 ## 三侧数字 - GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败** (30 个套件;唯一红仍是隐私扫描,本次未触碰) - cargo test --release:**61 通过 / 0** - cd client && flutter test:**485 通过 / 0**## 改判内容(team-lead 复核结论) 上一版(58f82a6)在"重试槽已被占用"时把新失败批**直接丢弃**,只让最早那一批 享有重试特权。幂运算一下就看出问题:PG 抖动 10 秒(2 秒一轮 → 5 轮)时, 轮 1 批 A 进槽(有救),轮 2/3/4 的批 B/C/D 全丢,轮 5 槽 A 超限丢、批 E 也丢 —— **10 秒抖动只救回一批**,等于把"每批都丢"降级成"每 2 秒丢一批", 而短暂抖动恰恰是重试最有效的场景。故改为**合并进槽**。 ## 实现 - 槽的数据形态不变(`{ batch, attempts, lastError }`),但 `batch` 现在是 **多轮合并累积**的帧数组:失败时一律 `concat`,不再"占用即丢弃"。 - **有界性靠槽容量,不靠丢新批**:合并后若超过 `_storageQueueMax`(默认 5000, 与主队列同一上界)→ **截断**,保留**较早**的帧(先保已经等了更久的数据), 超出部分走 `_discardStorageBatch(..., '超出重试槽上限 N 帧')`:计入 `_storageFailed` + 告警,**明确丢弃不静默**。于是重试槽内存上界 = 主队列上界, 重试不会让内存膨胀。 - `attempts` 语义改为**槽的重试轮次**(每轮 flush +1,首个失败轮记为 1), 不再是"某一批的尝试次数"。超过 `_storageMaxRetries` → 整槽丢弃。 - 顺序不变(上一版写对的地方保留):**先试槽 → 无论成败都照常处理主队列**, 旧批失败绝不阻塞新数据;不在同一次 flush 内忙等重试。 ## 附带发现:默认重试窗也要放宽(这一条请复核) 改完合并后我用临时量具实测"PG 抖动 10 秒"(5 轮 × 4 帧 = 20 帧,第 6 轮恢复): - 默认 `storageMaxRetries = 3` → 只救回 **8/20** 帧(第 4 轮 ≈8 秒整槽被丢,丢 12 帧) - 因为合并后 attempts 记的是**轮次**,上限 3 意味着"只覆盖 4 轮 ≈ 8 秒", 与"要覆盖一次真实抖动"的意图不匹配。 故默认值改为 **9 轮 ≈ 20 秒窗口**(2 秒/轮 × (1+9))。放宽不增加内存:槽容量由 `_storageQueueMax` 硬约束,窗口长短只影响"抖动期间攒多久"。改后同一场景 **20/20 全部救回、`storage_failed` = 0**。上限机制本身仍由测试 1(显式传 `storageMaxRetries: 3`)钉住。 三个版本在同一量具下的实测对照: | 版本 | 落库 | storage_failed | |---|---|---| | 最初(只 log) | 0/20 | 字段不存在(静默) | | 58f82a6(槽占用即丢) | 4/20 | 16 | | 本次(合并 + 20 秒窗) | **20/20** | **0** | ## 测试(tests/storage-failure-test.js:29 → 41 项,全绿) 新增: - **3b 跨 flush 合并**:3 个周期各失败 3/4/2 帧 → 槽内 **9 帧**、`attempts` = **3** (不是 9);第 4 轮 PG 恢复 → **9 帧全部落库**、seq 1..9 顺序完整、`_storageFailed` = 0 - **3c 合并仍受容量约束**:槽上限 10,连续失败凑 15 帧 → 槽里只留 10 帧、 另 5 帧计入 `_storageFailed`、**只告警一次**、保留的是较早帧(1..10) - **3d 抖动 10 秒(默认窗口)**:5 轮 20 帧全攒槽、恢复后 20 帧一次落库 原有 5 条全绿(含"失败不阻塞新数据"毒丸隔离、重试成功不丢数据、计数/告警节奏、 队列满丢最旧语义不变、`drainStorage` 刷净)。 另:把 1/3/4 段的重试上限**显式**传 3,让"上限机制"与"抖动窗口"两件事在测试里 各自独立可读。 ## 变异自证 把失败分支改回旧实现(只 `log.error`、批次消失)→ **41 项里 27 项红**: 第 1 段 8 项全红、第 2 段 2 项、第 3 段 2 项、**3b 4 项、3c 5 项、3d 2 项**、 第 4 段 3 项、`drainStorage` 1 项;恢复后 41/41 绿。 (顺带加固了 3b/3c 的空槽判定为 `?.`,让回归时"报红"而不是抛异常中断整个套件。) ## 三侧数字 - GATEWAY_PORT=3107 bash tests/run-all.sh:**29 通过 / 1 失败**(30 套件;唯一红仍是隐私扫描,未触碰) - cargo test --release:**61 / 0** - cd client && flutter test:**485 / 0**team-lead 批准 9 轮窗口的前提是"注释里写明依据",否则后人看到 9 会以为 是随手写的数。补清三点: · 9 轮 ≈ 18~20 秒(flush 周期 2 秒),覆盖 PG 重启 / 连接池耗尽后恢复的典型时长; · 放宽**不增内存**:上界由槽容量 _storageQueueMax(5000 帧)硬约束, 窗口只决定"抖动期间攒多久",两件事必须解耦、不要合在一起调; · 改回 3 会让 tests/storage-failure-test.js 的 3d 段("PG 抖动 10 秒必须全救回") 立刻变红,并直接说明"10 秒抖动救不回"。 纯注释改动,无行为变化。验证:storage-failure-test 41/41 绿; 10 秒抖动量具仍为 20/20 落库、storage_failed=0。对应架构评审 2.5 节。此前 `bin/pmon_agent.dart` + `bin/pmon_install.dart` (install/uninstall/status/restart/verify)都已就绪,但 **GUI 一个入口都没有**, 用户得自己敲命令 —— 等于这个能力不存在。 ## 这一笔包含什么(GUI 侧) - 新增 `lib/host/resident_service.dart`:GUI 与 `pmon-install` 的**唯一接缝**。 只做三件事:**找到 CLI → 跑它 → 把 `status --json` 解析成类型**。 · 不在 GUI 里做 launchctl/plist:沙箱 GUI 写不了 `~/Library/LaunchAgents/`, 自启产物内容由 Rust 生成、落盘由非沙箱 CLI 执行;GUI 再写一套就是第二套自启逻辑。 · 不在 GUI 里自己 stat 文件/pgrep 进程:判据只有 CLI 那一处。 · `ResidentState` 三态(在跑 / 装了没起来 / 未安装)+ unknown(问不到)。 unknown **不猜**:字段缺、执行失败、输出不是 JSON 都如实说"看不清"并带原因。 · CLI 定位按三级回落:装好的 `installBinDir/pmon-install` → 开发树 `build/pmon-install` → `dart run bin/pmon_install.dart`;都没有就报 "先 dart compile exe ...",**不假装已安装**。 - 新增 `lib/ui/resident_card.dart`:卡片本体。 · 三态色点 + 一句话状态;"装了但没起来"必须说清判据(自启未加载 / 进程没起) 并给日志路径 —— 这一态最容易被误解成"装过就好了"。 · 按钮:安装并启动(未装)/ 重新安装并启动(装了没起)/ 卸载(已装)。 · **失败回显 CLI 原文**(stderr 尾部):P0-6 已让 installer 返回非零并打印 哪条命令失败,卡片把那几行原样显示(可复制),不用"安装失败"盖掉。 · 5 秒低频轮询(状态在外部进程里,事件拿不到;5 秒够"改完能看见")。 - `lib/main.dart`:采集页 dashboard 挂载,**仅桌面端** (`core.canRunInBackground && !AndroidForeground.isAndroid` —— Android 的常驻 是前台服务,语义不同)。 · `onResidentStarted` → 停掉 GUI 自己的采集:两个进程同时上报会把两条 seq 流 混进同一 device_id,服务端按会话去重判不清 → 丢帧或重复帧,且不报错 (与 `_start()` 里 AgentLock 那条同源)。 - `test/theme_guard_test.dart`:登记 `lib/ui/resident_card.dart` (新增 UI 文件必须进 `_uiFiles`,这条吃过亏)。 ## 测试(新增 21 项) - `test/resident_service_test.dart`(13):解析四类判据 / 三态判定只在一处 / 字段缺失不崩不猜 / **失败带具体原因** / 输出非 JSON → unknown / 真机噪声(`dart run` 把 "Running build hooks..." 拼在 JSON 同一行)仍能解析 / CLI 路径从 `installBinDir` 派生 + 三级回落 + 找不到给明确指引。 - `test/resident_card_test.dart`(8):未安装 / 在跑(含 pid、且不再给"安装"按钮)/ 装了没起来(说清判据 + 日志路径)/ 状态未知如实说 / **失败原因原样可见** / 安装成功才通知宿主停采集 / 安装失败不停采集 / 卸载回未安装。 ## 验证 - `flutter test`:**540 通过 / 0 失败**(本笔新增 21 项) - `dart analyze lib bin`:**No issues found!**(不用 `flutter analyze`:本机中文路径会让它崩) ## 与并行任务的边界(重要) `pmon-install status --json` 那一半(CLI 侧 `statusSnapshot()` + `--json`) 落在 `client/bin/pmon_install.dart`,而该文件正被 **software-engineer-2** 的 路径收敛任务改写中(未提交)。为不把他的在途改动卷进我的提交,本笔只含 GUI 侧; CLI 侧与 `test/installer_status_json_test.dart` 紧随其提交后单独落一笔。`test/installer_status_json_test.dart`(4 项)。它 import `bin/pmon_install.dart` **在同一进程里**跑一遍 `statusSnapshot()`,再把结果 `jsonEncode → jsonDecode` 喂给 GUI 侧的 `ResidentStatus.fromJson`,断言四类判据(自启注册态 / 进程 / 配置 / 日志)两端字段名对得上。 ## 为什么必须有这条 `pmon-install status --json` 与 `ResidentStatus.fromJson` 是**同一条契约的两端**。 任一端改字段名,另一端不会报错,只会静默退化成"状态未知"——卡片永远显示 "看不清",而日志里没有任何异常。这与本项目当年"指标字段四处各写一套、交集只剩 3 个字段、静默丢数"是同一类故障(那次是靠 schema-consistency-test 才抓住的)。 故这里不测"CLI 能不能跑通"(真机验),只测**两端字段是否还对齐**。 ## 断言要点 - 四类判据齐全(缺一 GUI 就判不出三态); - `jsonEncode(snapshot) → fromJson` 后 state 落在合法三态内(若变成 unknown, 说明字段名漂移了); - `config.path` / `log.path` / `installed.agent_path` 三个路径字段非空 (这三条正是 engineer-2 路径收敛与本卡的交汇点); - `status --json` 返回 0(只读命令永不失败退出); - 人话输出与 JSON **共用同一份快照**(同一条断言保证"判据只有一处")。 ## ⚠️ 已知的串联顺序(必须连着落地) 本笔单独存在时会**红**:GUI 侧读的 `statusSnapshot()` 落在 `client/bin/pmon_install.dart`,而该文件正被 software-engineer-2 的路径收敛 任务改写(未提交,且两人的改动都集中在 `_status()` 同一处,非交互环境下 `git add -p` 用不了),按 team-lead 的决定由他一次性合并提交(commit message 里 会注明那段由我实现)。他那一笔落地后本测试即绿。 若有人 bisect 到本笔,看到的是"契约测试先行、实现紧随",不是回归。 验证:working tree(含未提交的 `statusSnapshot`)上 `flutter test test/installer_status_json_test.dart` → 4/4 通过; `dart run bin/pmon_install.dart status --json` 真机输出四类判据齐全。 ## 更正(数字,勿再照抄旧账) `698b680` 的 message 写「新增 21 项(13 + 8)」,**实际 B 的 GUI 侧为 22 项** (`resident_service_test.dart` **14** + `resident_card_test.dart` 8 —— 中途补了 「`dart run` 把 `Running build hooks...` 拼进 JSON 同一行」那条用例,从 13 变 14)。 `698b680` 未 amend,以保持"当时验证的是什么"可复核;此处补记以保证 `git log` 自洽。 逐文件实测:`link_mode_test` 8 / `transfer_card_test` 14(原 9 + 新 5)/ `resident_service_test` 14 / `resident_card_test` 8 / `installer_status_json_test` 4, 合计新增 39;`540 = 485(基线)+ 39(本任务)+ 16(并行任务 `app_paths_test`)`。## 缺陷(合并前四处口径互相矛盾) - `install_paths.dart:27` 安装根目录**硬编码 macOS 路径**,零平台分支 - 同文件 `:33` 可执行文件名不带 `.exe`(Windows 上不可执行) - `app_config.dart:245-260` 反而有 macOS/Windows/其它三分支 - 动态库文件名另有 3 份(`core_lib.dart:17-21`、`pmon_install.dart:89` 二分支、 `:287` 三分支);plist 路径 `pmon_install.dart:184`/`:271` 各写一遍 后果不是"不好看",是**装得进、读不到**:Windows 上 agent 落到 `%USERPROFILE%\Library\Application Support\pmon_client\bin`,配置却写进 `%USERPROFILE%\AppData\Roaming\pmon_client`,两边都不报错。 ## 合并后的真相源:`client/lib/host/app_paths.dart` 两条**正交**维度都做成显式参数(不是靠 `if` 补): 1. **平台** —— `PmonPlatform` 参数化,且**路径语法随参数走**(`p.windows`/`p.posix`), 于是 Windows 口径能在 macOS 上被真测到(测真机形态,不是 posix 伪装)。 2. **进程形态** —— `PmonProc{installer, gui, guiSandboxed, injected}`。 Android 的 `getFilesDir()` 注入被**收进**这个参数,不再是并列的第二套机制。 消费方全部改为委托,不再复制任何路径字面量: `install_paths.dart`(保留全部既有 getter 名,bin/ 无需改调用点)、 `app_config.dart`(`ConfigStore._dir` 只剩一句 `dataDirFor`)、 `core_lib.dart`(动态库名取 `coreLibNameFor`)、 `pmon_install.dart`(`coreLibName` + `launchAgentPlistPath`)。 ## 沙箱维度:怎么处理的(架构文档的四段代码没覆盖,这里补上) 先核实事实:`client/macos/Runner/{Release,DebugProfile}.entitlements` 里 `com.apple.security.app-sandbox = true` —— **本项目 GUI 确实是沙箱的**, bundle id = `com.pmon.pmonClient`。所以这不是假想维度。 处理方式(不嗅探、不加注入通道): - 沙箱只体现为"家目录取值不同",不是另一套目录规则; `dataDirFor(..., proc: guiSandboxed)` 从**真实家目录**插入容器前缀, 与沙箱 GUI 拿自己 HOME(容器路径)算出的值**恒等**。 - 该等式写成了测试(三种形态各一条): macOS 非沙箱 → `安装目录 == GUI 数据目录`; macOS 沙箱 → `guiDataDirForInstallerView(h) == dataDirFor(容器家目录, gui)`; Windows/Linux → 配置目录同源(Linux 安装目录走 XDG 是有意的,见注释)。 - bundle id 有自动比对:测试读 `macos/Runner/Configs/AppInfo.xcconfig` 逐字核对。 ## 顺带修掉两个"假判断"(都在同一个文件,且直接决定 e2e 能不能说真话) 1. **`Future<int> main` 把退出码吞掉**:本机 SDK 实测 `int main` / `Future<int> main` 返回 3,进程退出码都是 **0**。即上一轮「安装器不再撒谎、返回非零」的修复 **到不了进程边界** —— 屏幕打印"安装失败",shell / CI 仍拿到 0。 改为 `Future<void> main` + `exitCode = await runCli(argv)`。 2. **`_agentPids` 只按 `pmon-agent --collector` 匹配**:会把**别人家**的采集端算进来 (本机就有一份 pid 1737 在跑),于是 install 假成功、uninstall 假失败。 改为按落位后的**绝对路径**匹配(`RegExp.escape` 防路径里的 `.` 当通配符)。 ## 新增 `tests/installer-clean-e2e.js`(26 项,已接入 run-all) 临时 HOME 隔离,从零走:落位 bin+dylib → 写配置 → plist → status 四项判据 → 卸载摘干净。要点: - 各平台期望路径在测试里**独立写死**(不 import Dart 口径)—— 否则口径写错时测试会"自洽地通过",等于没测。 - 特殊断言:**屏幕上说「安装失败」时退出码必须非零**(就是上面那个 P0)。 - `launchctl bootstrap` 在本环境必然失败(`Bootstrap failed: 5: I/O error`): 落位/配置/plist **无条件断言**,自启注册一项明确打印 ⚠ 并记 skip(附原因), 其它任何非零退出判失败。缺 dart / 缺 Rust 产物 / 缺采集端时 ⚠ 跳过并返回 0 (不静默放行、也不假红);跳过分支不打印汇总行,免得结构守卫把"没跑"看成"全绿"。 ## 验证(改前 → 改后) - `cargo test --release`:61/0 → **61/0** - `flutter test`:485/0 → **540/0**(含他人新增;本次新增 `app_paths_test.dart` 22 项) - `dart analyze lib bin`:**No issues found!** - `bash tests/run-all.sh`:30 套件 29/1 → **31 套件 30/1**(唯一红仍是隐私扫描,按要求未碰) - `node tests/installer-clean-e2e.js`:**26 通过 / 0 失败 / 1 跳过**(跳过=launchd 环境受限) - `guard-structure-test.js`:42/0(新增套件已通过"汇总晚于断言"守卫) ## 未做(清单外,只报告) - **macOS 沙箱下"配置读不到"仍未接线**:GUI 是沙箱的,读容器; 而安装器(非沙箱)与 launchd 拉起的采集端读真实家目录。路径层已能算出 两者位置(`guiDataDir`),但"配置究竟写给谁、要不要双写、GUI 怎么拿到"是 配置交接的决策,不属于本次路径合并,未擅自改。 - `pmon_agent.dart` 同样是 `Future<int> main`(退出码同样会被吞),不在本次边界内。 - 默认 label `com.pmon.collector` 在 `pmon_install.dart` 与他人新文件 `resident_service.dart` 各有一份(不是路径,未动)。端到端验收抓到的真缺陷(不是理论问题,本机可复现)。 ## 血案形态 开发机上 `client/build/pmon-install` 是**几天前编译的旧二进制**,它不认识 `--json`,照旧打印人话 + 一行**配置 JSON**: === 配置 === {"server_url":"http://127.0.0.1:3000","device_id":"mac-autostart-test"} 而 `ResidentService` 原先的解析是「从下往上取第一个能解析成 Map 的行」—— 于是它抓到了**配置 JSON**,`ResidentStatus.fromJson` 得到: state=notInstalled、configPath=""、logPath=""、agentPath="" 卡片会一本正经地显示「尚未安装」—— **用户明明装过、而且在跑**。 这正是"形状对、内容错":接口对得上、值是错的,且不报错、不留痕。 ## 修法(两条,缺一不可) 1. **形状校验**:新增 `ResidentService.looksLikeSnapshot()` —— 只接受同时含 `autostart` 与 `process` 两段的 JSON。配置 JSON 缺这两段,直接不认。 2. **候选链逐级回落**:`resolveCliCandidates()` 返回全部候选(装好的 → `build/pmon-install` → `dart run bin/pmon_install.dart`),`status()` 逐个试, **只采用形状合规的那一个**;全都不合规时返回 unknown,并给出可操作原因 ("这个 pmon-install 不支持 `status --json`,请重新编译",附输出前 120 字)。 这样开发机上"编译产物过期"不再静默给错状态;生产上若用户只升级了 GUI 没升级 CLI,卡片会说"CLI 太旧",而不是谎报"未安装"。 `install`/`uninstall` 仍走第一个候选(那两个动作没有"输出形状"问题,失败原因原样回显)。 ## 测试(+3,共 17 项;全仓 543/0) - `looksLikeSnapshot`:只有 config 段的 JSON **不算**状态快照(血案本体); 缺 autostart/process 两段的不算;齐了才算。 - 过期二进制的输出 → **unknown**,绝不冒充 notInstalled;文案含"不支持"与 "dart compile exe"(告诉用户该做什么)。 - 候选链逐个试:过期那个被跳过,合规那个被采用,且断言调用顺序 `[过期二进制, dart]`(顺序本身也是契约)。 ## 真机端到端(验具走 GUI 真正走的那条路 `svc.status()`) ``` 候选 CLI 链: · .../client/build/pmon-install exit=0 输出: === 自启注册 === ...(旧版,无 JSON) · dart run .../bin/pmon_install.dart exit=0 输出: {"label":"com.pmon.collector",...} --- svc.status() 解析结果 --- state=running pids=[1737] autostart: registered=true loaded=true unit=~/Library/LaunchAgents/com.pmon.collector.plist config: exists=true path=~/Library/Application Support/pmon_client/config.json log: exists=false path=/tmp/com.pmon.collector.err.log agent: exists=true path=~/Library/Application Support/pmon_client/agent ✓ 端到端通过 ``` (`status --json` 的实现仍在 `client/bin/pmon_install.dart`,随 software-engineer-2 的合并提交落地;本笔只修**消费侧**,不碰他的在途改动。) 验证:`flutter test` **543/0**;`dart analyze lib bin` **No issues found!**## 本笔包含什么 1. **进程探针三态化**(`_agentPids` → `probeAgentProcess()` / `AgentProcessProbe`) - 原实现 `Process.run('/bin/zsh', ['-c', 'pgrep -f ...'])`:**Linux 上 `/bin/zsh` 通常不存在** → `ProcessException` 直接抛穿 `install` / `uninstall` / `status`; Windows 上 `pgrep` 一定没有 → 返回空列表,于是"查不了"被显示成"未运行" (用户会去重装,而真问题是本平台没有探针)。 - 改为**不经 shell**直调 `pgrep -f <RegExp.escape 后的绝对路径> --collector`: macOS/Linux 通用,且路径含空格/引号时不再交给 shell 解析。 - 返回值带 `available`:`pgrep` 退出码 1 = 确实没匹配(唯一可信的"没在跑"), 其它非零 / `ProcessException` = **无法判定**。上层:`install` 不把"查不了" 算成失败(只 ⚠),`uninstall` 不把"查不了"说成"已停止"(只 ⚠), `status --json` 增 `process.probe_available/probe_tool/probe_error`, 人话输出显示「采集端: 无法判定(原因)」。只增字段,`running`/`pids` 语义不变, GUI 侧解析不受影响。 - **Windows 未实现**(刻意):写一条未经真机验证的 PowerShell `-like` 分支, 只会"看起来能判定",匹配一错就是又一次"用错事实下判断"。已留 `TODO(多端前置)` 并明确报"无法判定"。 2. **`tests/installer-clean-e2e.js` 新增 8 项 `status --json` 断言**(26 → 34 项) - 解析策略按 team-lead 要求与 GUI 侧一致:**从后往前**扫行、行内从后往前找 `{` 试解析 —— 因为 `dart run` 会把 `Running build hooks...` **拼在 JSON 同一行前面** (实测 `Running build hooks...Running build hooks...{"label":...}`), 按"整行是 JSON"判断会把唯一有效输出判成解析失败。 - 断言 `config.path` / `installed.agent_path` / `autostart.unit_path` 与真相源 按**临时 HOME** 推导的值逐字相等 —— 硬编码旧值不可能跟着临时 HOME 变, 故这是"路径收敛真的生效"的最强证据。 3. `statusSnapshot` 里 `log.path` 加 `TODO`:这是**唯一还没收敛**的路径 (Rust 的 `macos_launchagent_plist` 把 `StandardErrorPath` 写成同一形状, Dart 侧又写一份)。正确做法与 `allow_failure` 同款(让 `AutoStartPlan` 多返回 `stderr_log_path`),但需改 Rust 数据结构 + FFI 出口,超出"client 路径"边界,本轮未做。 ## 归属说明(team-lead 要求留痕) - `pmon_install.dart` 的 `statusSnapshot()` + `--json` 改动由**另一位工程师**实现; 因与路径收敛同处一个文件、非交互环境下无法按 hunk 拆分,实际已在上一笔 **c152ea2** 一并落地(该文件在 c152ea2 里是 151+/37-,即两人改动的合并体)。 本笔只在其上追加"探针三态化 + e2e 断言 + TODO",未改动其判据逻辑。 - 反向留痕:`51c85b0`(team-lead 记作 `3ee0630`,该 hash 在全部历史与分支中都不存在, 实际是 `51c85b0 test(常驻采集): 把「CLI 输出 ↔ GUI 解析器」两端的契约钉在一起`) 是"测试先行"那一笔 —— 它 import `installer.statusSnapshot`,而当时 CLI 里 `statusSnapshot` 出现次数为 **0**,单独 checkout 会因编译失败而红; **本笔的前置 c152ea2 使其转绿**。保留它不 revert,两侧都留痕,避免 `git bisect` 把"顺序"读成"回归"。 ## 验证(改前 → 改后) - `cargo test --release`:61/0 → **61/0** - `flutter test`:540/0 → **545/0**(含 `installer_status_json_test.dart` 4 项) - `dart analyze lib bin`:**No issues found!** - `bash tests/run-all.sh`:31 套件 30/1 → **31 套件 30/1**(唯一红仍是隐私扫描,未碰) - `node tests/installer-clean-e2e.js`:26/0 → **34 通过 / 0 失败 / 1 跳过** (跳过=launchctl `Bootstrap failed: 5` 环境受限,原因逐条打印) - `guard-structure-test.js`:42/0 ## 已核实的现场事实(供 team-lead 复核) `status --json` 在**真实 HOME** 与**临时 HOME** 下都取到正确路径,后者全部跟着临时 HOME 走,证明三个路径字段来自 `app_paths.dart` 推导而非硬编码: - 真实:`config.path=/Users/me/Library/Application Support/pmon_client/config.json`、 `installed.agent_path=.../bin/pmon-agent`、`log.path=/tmp/com.pmon.collector.err.log` - 临时:`config.path=/tmp/pmon-json-probe-xxxx/Library/Application Support/pmon_client/config.json`、`installed.agent_path` 同源变化、 且 `process.running=false`(**正版 pid 1737 不再被算进来**,路径作用域生效)## 这个洞怎么来的 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 实例"。 跨形态靠进程探测、同形态靠锁,各管一段。## 依据:本 SDK 上 main 的返回值完全不设退出码(实测矩阵) Dart SDK 3.13.3,各跑 `dart run` 与 `dart compile exe` 两种形态: | 写法 | `dart run` | `dart compile exe` | |---|---|---| | `int main() { return 3; }` | **0** | **0** | | `Future<int> main() async { return 3; }` | **0** | **0** | | `void main() { exitCode = 3; }` | 3 | 3 | 即**只有显式 `exitCode =` / `exit()` 才生效**(官方文档说 `Future<int> main` 会设,本 SDK 不成立)。 所以 `cffdc50`「安装器不再撒谎、返回非零」的代码逻辑对,但**进程边界是死的**; `pmon_agent.dart` 同样是 `Future<int> main`,后果更重:plist 用 `KeepAlive.SuccessfulExit=false`,launchd 靠「非零退出」判崩溃 —— 退出码被吞成 0 等于**采集端崩了也不会被拉起**,用户只看到历史断档,无任何报错。 ## 改动:`bin/pmon_agent.dart` `Future<void> main` + `exitCode = await runAgent(argv)`;函数体抽成 `runAgent` (返回码语义清晰、可被测试直接调)。**语义逐条守住,没有一刀切归零**: | 退出点 | 改前 | 改后 | 理由 | |---|---|---|---| | `--help` | return 0(被吞成 0) | 0 | 帮助是正常完成 | | `--print-plan` 成功 | return 0 | 0 | 只打印,正常完成 | | `--print-plan` 且核心返回 error | return 0 | **1** | 用户要的「计划」没拿到(iOS 不支持/路径非法);退出 0 会让脚本以为拿到了产物 | | 抢不到单实例锁 | return 0 | **0(刻意不变)** | 非零会被 launchd 当崩溃立刻重启 → 两进程互踢成无限重启循环,比"没起来"更糟 | | `--duration` 正常到期 | return 0 | 0 | 冒烟/CI 的正常路径 | | Ctrl-C(SIGINT) | `exit(0)` | 0 | 用户主动停;非零会让 launchd 立刻拉起("关不掉的进程") | | 常驻等待(Completer) | return 0 | 0 | 理论不可达(只有信号或 kill 能结束) | | 未捕获异常(核心库缺失等) | 无 catch → 255 | **255(不变)** | 实测 `Future<void> main` 抛异常仍是 255;崩溃必须非零,否则 launchd 不重启 | 顺带把 `--print-plan` 里硬编码的 label 换成 `kDefaultAutostartLabel` (label 唯一真相源是 `host/autostart_label.dart`,不在 bin/ 再写一份)。 ## `pmon_viewer.dart` / `relay_check.dart`:**不需要改**,它们已经是显式 `exit()` 核对结果(`grep -c "exit("`): - `pmon_viewer.dart`:**7 处** —— `exit(4)`(缺 --account / 启动失败 / 缺 --device)、 `exit(3)`(配对失败 / 未订阅成功)、`exit(0)`(--list),末尾 `exit(ok ? 0 : 2)`。 - `relay_check.dart`:**2 处** —— `exit(same ? 0 : 1)`、catch 里 `exit(1)`。 `exit()` 比 `exitCode =` **更强**(立即终止并带上码),失败语义本来就到达边界。 我实测确认并写进了新套件:`pmon_viewer` 缺 `--account` → **4**,`relay_check` 打不通 → **1**。 顺带说明:`viewer-e2e-test.js:257` 那条 `cli.code === 0` 断言**因此是有效的**(不是死断言)。 为"统一写法"去把它们改成 `exitCode =` 是纯噪音,且会削弱语义,未做。 ## 新增 `tests/cli-exit-code-e2e.js`(11 项,已接入 run-all) **一律读子进程真实 exit code**(`spawnSync`/`spawn` 的 status),**不 import 被测代码断言返回值** —— 断言返回值正是这次漏掉的东西。覆盖: - 必须 0:`--help`、`--print-plan`、**抢不到单实例锁**、`--duration` 到期(后两条用"先起一个持锁实例再起第二个"构造,temp HOME 隔离) - 必须非零:`pmon-install install --agent-bin <不存在>`、`pmon-viewer` 缺 `--account`、`relay_check` 打不通网关 ## 变异自证 1. **旧写法复现缺陷**:把 `main` 临时改回 `Future<int>`,并让 `--help` `return 7` —— 新写法实测进程退出码 **7**;旧写法实测 **0**(7 被吞)。同一份代码、只改 main 签名。 2. **测试确实会红**:临时注释掉 `pmon_install.dart` 的 `exitCode = await runCli(argv);` → `cli-exit-code-e2e` ⑤ 变红(`exit=0`),`installer-clean-e2e` 的 「说失败⇒退出码非零」同时变红;恢复后两套全绿(11/0、34/0/1)。 ## 验证 - `dart analyze lib bin`:**No issues found!** - `flutter test`:**559/0**(含他人新增) - `cargo test --release`:**61/0** - `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰) - `node tests/cli-exit-code-e2e.js`:**11 通过 / 0 失败** - `guard-structure-test.js`:**44/0**## 为什么必须补(不是防御性代码) `agentLockPath` = `ConfigStore.dir/agent.lock`,而 `ConfigStore.dir` 由 `HOME` 决定。macOS **沙箱** GUI 的 `HOME` 是应用容器,agent 由 launchd 拉起、`HOME` 是真实家目录: GUI → ~/Library/Containers/com.pmon.pmonClient/Data/…/agent.lock agent → ~/Library/Application Support/pmon_client/agent.lock **两个进程锁的是两个不同文件,谁也不挡谁** → 沙箱 GUI 里 `isHeldByOther()` 恒为 false。于是"agent 已在跑、用户又点了一次开始采集"→ 必然双写:两条 seq 流混进同一个 device_id,服务端按会话去重分不清,历史曲线出现无法解释的毛刺, **且不报错**。`da88e30` 只堵住了"装完之后"那个窗口,这条路径它堵不住。 ## 只修 macOS(不要把三个平台一起改) | 形态 | GUI 的 HOME | agent 的 HOME | 锁是否同一文件 | |---|---|---|---| | macOS 沙箱 | 应用容器 | 真实家目录 | ❌ | | macOS 非沙箱 / Windows / Linux | 同 agent | 同 agent | ✅ | 故 `probeInstalledAgent()` 在非 macOS 上直接返回 `notApplicable`,**一次都不执行 `pgrep`**(Windows 根本没有这个程序)。有一个测试专门守这条,防后人"统一成全平台"。 ## agent 路径为什么取自 CLI,而不是本地推导 沙箱 GUI 里 `installBinDirFor(macOS, homeDir)` 的 `homeDir` 就是容器路径,算出来 是"容器/…/bin/pmon-agent",用它 `pgrep` 永远匹配不到 —— 更糟的是会得到一个 看起来确定的 `notRunning`。CLI 是非沙箱进程,`status --json` 报的 `installed.agent_path` 才是 launchd 实际启动的那条(也是 installer 侧 `probeAgentProcess()` 用的同一处真相源)。 ## 判据 · `/usr/bin/pgrep -f <RegExp.escape(path + ' --collector')>` —— 绝对路径(沙箱 PATH 可能被裁剪)、转义(家目录里的 `.` 会当通配符)、带 `--collector`(与 plist 的 `ProgramArguments` 对齐,与 installer 同一套口径) · exit 0 → running;**exit 1 → notRunning(唯一可信的"没在跑")**; 其它非零 / `ProcessException` → `undetermined`(不能当成"没有") · `undetermined` → **不放行**:双写是污染数据,漏采只是缺数据 · 锁命中优先(它能挡住探针看不出的"两个 GUI 实例"),且命中后不再多花一次 CLI 往返 ## 一个如实记录的边界 `status()` 拿不到快照(打包后的 .app 且从未装过 CLI)时 `agentPath` 为空、无从 探测 → **放行**并留 `resident_probe_skipped` 日志。理由:若这也拦,等于让没装过 常驻采集的机器彻底无法采集(确定的永久性回归),换来挡住"装过之后又把 CLI 删了" 这个很偏的边界。这一条我在回报里也点了名。 ## 验证 - `flutter test` → **572/0**(新增 13 条) - `dart analyze lib bin` → No issues found - 真机(非沙箱调用方,agent pid 1737 在跑): `cli 报的 agent_path=…/pmon_client/bin/pmon-agent` → `探针结论=running` → `合成结论=后台常驻采集已在运行(进程探测确认),本界面仅查看(不会重复采集)` - 变异自证(三次,均已恢复): · 去掉 main.dart 的 macOS 分支 → 源码守卫 ⑤ 报红 · `exitCode != 0` 一律当 notRunning → 「其它非零 → undetermined」报红 · 去掉"非 macOS 不调探针"守卫 → 「Windows/Linux/Android 一次都不执行」报红## 为什么单为一条日志加测试 `_guiCollectorBlockReason()` 里只有一处"不拦"的例外:`status --json` 拿不到快照 (打包成 .app 且从未装过 CLI)时 `agentPath` 为空、无从探测,于是放行。理由成立 (否则没装过常驻采集的机器彻底不能采集),但它是**唯一一处靠日志才能复盘的判断**: 将来有人问"这次为什么没拦住" → 没有日志就只能猜, 而猜出来的原因通常比没有原因更糟。 故加两条: 1. **落盘验证**:临时目录注入 `StartupLog.dir`,写一条 `resident_probe_skipped`, 读回 JSONL,断言 `e` 与 `f.reason / f.state / f.detail` 原样保留 —— 特别是 `detail` 不能被脱敏逻辑吞掉(那条是排障的唯一线索)。 2. **源码守卫**:`main.dart` 里必须留着这句 `StartupLog.event('resident_probe_skipped'`, 防"嫌麻烦删掉"。 ## 管道本来就在工作(实机证据,不是推断) 沙箱容器里的 `startup_log.jsonl` 现有 `boot ×3 / restore_done ×2 / started ×2`, 且 `main()` 第一段就 `StartupLog.dir = ConfigStore.dir`(先于任何 `_start`)。 也就是说这条链路是通的,新事件只是加了一个名字。 ## 验证 - `flutter test` → **574/0** - `dart analyze lib bin` → No issues found - 变异自证:把事件名改成 `resident_probe_SKIPPED_MUTATION` → ⑥ 的源码守卫报红,恢复即绿dart compile exe已被 Dart 3.13 打断,构建方式收敛到 scripts/build_cli.sh 一处 5a909f14fdlog.path未收敛的 TODO 补成可执行的正解形态 d0d2d717a4## 问题:我们把用户送到了一条死路 `resident_service.dart` 有三处**面向用户**的提示让用户敲 `dart compile exe bin/pmon_install.dart -o build/pmon-install`: - 找不到 CLI 时(`status()` 的 detail) - CLI 太旧、不认识 `--json` 时(`_statusOnce` 的 detail) - 安装/卸载前找不到 CLI 时(`_action` 的 message) 而 Dart 3.13 起该命令遇到 build hooks(依赖链里的 `objective_c`)**直接失败**, 用户照做后看到的是 `'dart compile' does not support build hooks` —— 比没有提示更糟,因为**他会以为是自己环境坏了**。 ## 改法:只指真相源,不抄命令 三处统一成:`client/scripts/build_cli.sh`(产物 `build/pmon-install`)。 构建入口的真相源是那个脚本(engineer-2 已把 README 与两个 bin 的注释/错误提示 一并改指它),这里不抄一份参数序列 —— 抄一份就多一处会漂移的副本。 ## 加了一条守卫,防它再长回来 `⑧ 构建命令只许指向能跑通的那条`: 1. 源码守卫:`resident_service.dart` 里不许再出现那个精确形态 (`dart compile exe bin/pmon_install.dart` —— 它只会出现在"让用户照着敲" 的位置),且必须含 `client/scripts/build_cli.sh` 2. 行为断言:`status()` 在"找不到 CLI"时给出的 detail 必须含 `build_cli.sh`; 装过 CLI 的机器跳过该用例(不假绿) ## 顺带确认:回落链不再被触发 新构建产物(`build/pmon-install`,6.2 MB,2026-10-27 23:46)实测**认识 `--json`**: 候选: …/client/build/pmon-install ← 第一候选直接答话 候选: dart run …/bin/pmon_install.dart ← 备用,未用到 state=running pids=[1737] `resolveCliCandidates()` 一个字符没改 —— 回落链从"今天的主路径"退回它本来的 身份(防御性代码),这正是不该把它写成主路径的理由。 ## 验证 - `flutter test` → **576/0**、`dart analyze lib bin` → No issues found - 真机 `status --json` → `state=running pids=[1737]`,三路径与 `serverUrl/deviceId` 解析正常 ## 备案(未做) `lib/` 里还有约 10 处文档注释用"能 `dart compile exe`"来解释"为什么这个模块 必须纯 Dart、不 import Flutter"。约束本身仍然成立(`dart build cli` 同样要求), 只是叫法过时。属文档口径整理,不在本笔。## 改判:`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`(把"缺字段"当没证据)→ 「拿到在跑证据 → 停采集」报红## 为什么必须补这句(team-lead 拍板采纳) 三个平台的 `install_commands`(真相源 `rust-core/src/keepalive.rs`)语义不同: | 平台 | 安装命令 | 装完是否立即在跑 | |---|---|---| | macOS | `launchctl bootstrap` + plist `RunAtLoad=true` | 是 | | Linux | `systemctl --user enable --now`(`--now`) | 是 | | Windows | `reg add`(HKCU Run 项) + `schtasks /SC ONLOGON` | **否,只在下次登录时起** | 于是 Windows 上「install 返回 0」**只承诺"已注册",从不承诺"已在跑"**。 可上游(GUI 常驻卡)曾把它读成"已启动",进而停掉自己的采集 —— 两个都没在采、界面还很平静。这行字把「启动」从**承诺**降级成**事实描述**。 ## 改动(`client/bin/pmon_install.dart`) 1. 新增 `launchSemanticsNoteFor(PmonPlatform)`(平台作参数的纯函数): · Windows → `仅在下次登录时启动` · 其它 → `安装命令本应已使进程立即启动,但本平台无法探测其是否在运行` 2. `install` **成功路径**打印 `说明:已注册 · <上面那句>`。 位置刻意放在 `hardFailures` 判定**之后**:安装失败时不该说"已注册"。 触发条件是"本平台有没有探针"(平台属性),与是否 `--no-start` 无关。 3. `status --json` 的 `autostart` 增 `note` 字段(探针可用时为 null; 不可用时为该句)—— 因为 Windows 上这行字是**唯一**的状态说明, GUI 读的是 JSON,不能只留在人话输出里。人话 `status` 也打印同一句。 ⚠️ **这是"平台 → 启动语义"知识的第二份**(第一份在 Rust 的 `install_commands`)。 不能改成嗅探命令字符串(本项目明令禁止判 `cmd.contains('bootout')` 那类)。 正解与 `stderr_log_path` 同款:让 `AutoStartPlan` 返回 `starts_immediately: bool`, Dart 只读不算。**本轮未做**(要改 Rust 结构 + FFI 出口),已写进注释, 并集中在**这一个函数**里,不允许出现第三份。 ## 测试 - 新增 `client/test/installer_launch_note_test.dart`(5 项):本机是 macOS, 跑不到 Windows 分支,故把平台做成参数逐条断言 —— 与 `app_paths_test.dart` 同一手法。 含反面对照(Windows 那句不得出现"已启动";macOS/Linux 那句不得出现"登录"), 以及两条**形状护栏**:读 `keepalive.rs` 断言 Windows 分支确实有 `/SC ONLOGON` 且没有 `systemctl`/`launchctl`、Linux 分支确实有 `enable --now`。 (写这条时踩了一次"抓错对象":`Platform::Windows =>` 在 `as_str`/`autostart_mechanism` 里也出现,第一版抓到显示名导致假红,已限定在 `plan_autostart` 内、 边界同时认 `=> {` 与 `=> Ok(AutoStartPlan {`。) - `installer-clean-e2e.js` 38 项(+1):JSON `autostart.note` **两条分支都断言** —— 探针可用必须为 null(不给假说明),不可用时必须非空且含"启动"。 - 变异自证:临时让 `probeAgentProcess()` 恒返回 unavailable → `status --json` 得到 `probe_available=false` + `note='安装命令本应已使进程立即启动…'`, 人话 `status` 出现 `说明: …`,e2e 走"非空"分支仍全绿;恢复后回到 null 分支。 ("install 真正成功 + 探针不可用"这个组合在本机**无法构造**:`launchctl bootstrap` 在此环境恒失败(I/O error 5),install 永远走不到成功分支 —— 如实说明, 不假装验过。install 侧与人话/JSON 侧调用的是同一个函数,字符串由单测覆盖。) ## 顺带修掉一个我自己引入的偶发失败(必须修掉,不能留着) `cli-exit-code-e2e.js` 原用 `127.0.0.1:3107` 当"打不通的网关", 但实测**8 次里有 2 次 3107 上真的有人在听**(relay 往返成功、`relay_check` 退出 0), "打不通 ⇒ 退出码非零"这条被静默反转 —— 它先是在 run-all 里红了一次我才发现。 改为**端口 1**(特权端口,正常环境永不监听),并在注释里写明"别换成看起来空闲的端口"。 换端口后连跑 10 次 0 失败。 ## 验证 - `dart analyze lib bin`:**No issues found!** - `flutter test`:**582/0**(含本笔新增 5 项) - `cargo test --release`:**61/0** - `bash tests/run-all.sh`:**32 套件 31 通过 / 1 失败**(唯一红仍是隐私扫描,未碰) - `node tests/installer-clean-e2e.js`:**38/0/1 跳过** - `node tests/cli-exit-code-e2e.js`:**11/0**(连跑 10 次稳定) - `guard-structure-test.js`:**44/0** ## 归属备案 `tests/run-all.sh` 里我把套件计数 37→38 的改动,被他人提交 `205e862` 一并带走了(同文件、`git add` 粒度拆不开,与我上次遇到的情况对称)。 内容已在 HEAD,无丢失;此处留痕避免日后考古困惑。三处都是实测截图发现的,不是"看着可能有问题": 1. 总览「当前在线」用 LIMIT 500 采样算法。库里 1.3 万台设备时取到的是物理 顺序最前一批,真正在线的可能一台都不在里面,页面显示"0",而 0 会被读成 确定结论(旁边"采样 500 台"的小字没人看)。改为 SCAN 数 `presence:*` 键个数,与设备表规模无关;Redis 不可用仍返回 null 而非 0。 2. 审计「明细」列的 JSON 没有断行机会(无空格),`white-space: normal` 找不到断点 → 整串作为不可断词把表格撑宽,再被 `.card{overflow:hidden}` 从右边缘硬裁,既无省略号也看不出被截了。改为包一层带 max-width 的 div (`table-layout:auto` 下 td 的 max-width 不可靠)+ 省略号 + title 悬停全文。 3. 存储表的行数把估算值和精确 count 混在一列,分不清真假。估算值灰化, 精确值带「精确」徽标,表头去掉"(估算)"。 顺带修 countPresence 插进来时把 mountAdmin 的 JSDoc 顶开了的注释错位。一、6 处 Unicode 状态符号清零 · link.dart 日志前缀改 [WARN](纯 ASCII,可 grep、跨终端不乱码) · transfer_card 3 处、viewer_page 1 处用户可见符号改 Material Icons(size 16) ✓ → Icons.check_circle,⚠ → Icons.warning_amber,全仓语义与图标一一对应 · EinkBoard.diagnostics 由 (String, String) 扩为 (String, String, IconData?): 图标单列槽位,而不是把符号拼回文本("走内网"的判定仍只在 _lanSummary 算一次) 二、配额卡档位机器键泄漏(用户可见 P0) 原来渲染「… · 档位 free · 我的设备 2/3 台」,free/pro/team 是机器键。 拆成两行: · 我的设备 {n}/{m} 台(达上限加「 · 已达上限」),任一数字缺失则整行不渲染 · {plan_name} · 查看三档权益,服务端没给就是「未知档位」 不回落机器键、不造第四个词、不按配额反推档位(free_device_limit 支持实例级覆盖)。 三、让"缺失"可表达 · deviceQuota / ownedDeviceCount 改 int?,删掉解析层 `?? 3` / `?? 0` (否则配额永远是 3,上面"缺失则不渲染"根本不触发) · plan 去默认值 'free';新增 planName(顶层 plan_name,不是 plan.name,无默认值) · me() 里 `?? info.xxx` 保留并注明区别:那是"另一处也带了同名值",不是编造 四、防复发护栏 test/plan_text_guard_test.dart 扫 lib/ 三类形态:${…plan…} 插值、「· 档位」、Text( 之后**跨行**的 .plan。 每条都配正控制(喂真违规样本,断言确实命中)与负控制(planName 不误报)—— 只验证"没抓到"不验证"抓得到",等于恒绿断言。⚠ 在本仓库有两种码位形态:U+26A0+U+FE0F(emoji)与 U+26A0 单独(文字)。 按肉眼删字形会把不可见的 U+FE0F 留在字符串里 —— 源码看不出异常、界面是豆腐块, 而按 emoji 区间(U+2600-27FF)匹配的护栏看不见 U+FE0F,属静默回归。 本护栏同时量两把尺子:基字符与变体选择符;判据只认字符串字面量内的码位 (注释里的 ⚠️ 合法,本仓库有 60+ 处这种提醒,不设豁免清单)。 正控制专测「只删了字形、VS16 留在原地」这一种;负控制测注释与 URL 里的 // 不误判。原判据是「按行数引号奇偶、只扫字面量内」,实测 4 种写法会**静默漏检** (不是误报,是漏报 —— 不报错、不红、不留痕,只会得到一条绿): · ''' 跨行块里单独占一行的符号(逐行扫描看不见,而长文案必然跨行) · Text("…'⚠'…") 双引号外套单引号 · Text('…"⚠"…') 单引号外套双引号 · Text('${x ? '⚠' : ''}') 插值内再嵌字符串 根因:引号奇偶表达不了「两种引号类型 + 跨行状态」。一个奇偶位装不下两种状态。 改成判「在不在**注释**里」而不是「在不在字面量里」: 三态状态机(普通/字符串/注释),字符串状态跨行保持;扫**注释之外的全部字符**。 理由:UI 文案只可能来自字符串,字面量外的符号同样违规 —— 于是猜在不在字符串里 这件事可以整个省掉,只留一个状态机就能判对,且**错的方向是多报**。 选判据优先选错得响的那个:哑错误没有下一次修正机会。 残余(三处,方向全是多报,已写进文件头): 块注释不认嵌套 / raw 字符串不特殊处理转义 / 插值内嵌字符串会错位。 同时补: · 星光平面 emoji 按代理对解码判定(原表只有 BMP,等于只防用过的那几个) · 「对真文件也响」的自检:扫描本文件必须命中 —— 防止状态机卡死后对一切沉默, 否则 lib/ 的零命中是假的 · 块注释内外分别断言的用例## 修的是什么 改之前 `gateway.js` 把 `msg.payload.owner_id || null` 直接交给 `registerDevice`, 而 payload 是攻击者可任意填的 —— **任何未登录的人**发一条 `agent:hello` 自报 owner_id 就能凭空占有一台设备,连 claim 都不用走。这比认领洞更浅:认领洞还要登录。 ## 四件事(顺序有意义) 1. **前提**:`registerDevice` 的 owner 实参改成**字面量 null**。不改这行,后面三件 都只是摩擦。归属只能由已认证的 claim 建立。 2. **码**:设备端本地生成 6 位数字,随 `agent:hello` 的 `payload.claim_code` 上报, 网关写 Redis(`claimcode:<id>`,TTL 300s,**断连即删** —— 码的语义是"设备此刻 在这条连接上活着",TTL 只是崩溃兜底)。不给码单开 HTTP 端点:端点本身没有设备 凭据,要求 device_token 会成环(令牌只发已归属设备),不要求则谁都能替任意 device_id 发码 —— 那是自证,不是在场证明。 3. **验**:claim 对**已存在**的设备行必须有码且匹配;失败只有**一个** error 码一条 文案(缺失/不匹配/过期/次数用尽四态合并,细分等于告诉攻击者爆破进度)。 验码排在配额与归属之前:排在后面会泄露"这台设备有没有主人"。 4. **审计条件化**:`auditDeviceClaimed` 用 `INSERT … SELECT … WHERE EXISTS (… owner_id IS NOT NULL)`。原先是无条件 INSERT —— 会把没发生过的认领记成已认领, 方向是以假为真,比漏记更坏且事后无法从表里分辨。 ## 顺手修掉的一个真 bug:多参数 `del` 在 node-redis v5+ 只删第一个键 `clearClaimFailures` 写成 `del(k1,k2,k3,k4)`,实测只删掉 k1,其余**原样留着且不报错** (网关用 v4,同一写法在那边是"删全部")。症状是"清零没生效":用户在一次成功认领后 仍被上一次的冷却拦住。实测证据: del('a','b','c') → after [0,1,1] del(['a','b','c']) → after [0,0,0] `tests/claim-proof-test.js` 的 B4 段以 `[429,429,…]` 抓到它。同一写法在 `shared/pairing.js` 的 verify 分支里还有一处(**既有代码**,单独一提交修)。 ## 码格式不另写一份 `\d{6}` 与位宽改用 `shared/pairing.js` 的 `PAIRING_CODE_RE` / `PAIRING_CODE_LENGTH`。 本轮新代码一度在 business 里硬编码正则,被 `pairing-security-test` 的既有断言当场抓到 (那条断言本来就是为此存在的)。 ## 测试 `tests/claim-proof-test.js` 57 项全绿,已接入 `run-all.sh`。断言的是**结果状态** (查库 / 查 Redis)而不是"请求返回成功":旧实现里"静默忽略"与"静默接受"对客户端 都是成功的,只有查状态才分得开。每条"空集 / 恒为 NULL"的断言都配了必须变红的正控制 (claim 后必须看到非 NULL / 植入一条有主无审计的行必须数出 1 / 扫描器指向旧写法必须命中)。 套件默认连**独占 Redis db 14** 并在收尾 FLUSHDB:失败计数键名不含随机后缀, 不隔离会导致"连跑第二次开局就在冷却中"(实测第二次 41 通过 / 16 失败)。 回归:配对安全 84/0、协议 34/0、权限矩阵 53/0、schema 一致性 116/0、认领 57/0(连跑两次一致)。## 这是一处既有代码的真 bug,不是新引入的 `verify()` 里码损坏时的清理写成 `del(key, pairingDeviceKey(deviceId))`。本类由 `server/business` 实例化,那里是 node-redis v5+ 语义: del('a','b') → 只删 'a','b' 原样留着且**不报错** del(['a','b']) → 两个都删 实测(2026-11-08,`server/business/node_modules/redis`): `del('k1','k2','k3')` 之后 `exists` 得到 `[0,1,1]`;数组形式得到 `[0,0,0]`。 ## 为什么留着比删掉更坏 `pairing:device:<id>` 是"这台设备当前的码"的反向索引。它在本该失效时留着,就指向 一个已经删掉的码 —— 而 `issue()` 正是靠它找回旧码去删。留下的后果不是报错,而是 下一轮发码时删掉一个不存在的键、旧索引继续指向空气。 ## 症状为什么难查 写入方(网关)用的是 node-redis **v4**,同一份调用在那边是"删全部"。所以这是 "同一行代码在两个大版本里语义不同、编译期不报、运行时也不报"的一类问题, 唯一症状是"清理没生效"。它不是本轮新写的代码带进来的,是随着 M4′ 的认领码 同类实现被对照着发现 —— 新代码里那处(`clearClaimFailures`)已在前一提交修掉。 回归:配对安全 84/0、认领在场证明 57/0、协议 34/0。## 三件事 **M2 升档流程**:`/api/accounts/me` 下发顶层 `plan_name`(客户端只认它、缺失即显示 「未知档位」,此前服务端零下发 → 界面被迫显示机器键 `free`);新增 `GET /api/plans`(**台账驱动投影**:`PLAN_WIRING_STATE` 里 `unwired` 的字段一律不出现在 响应里)、`POST /api/plans/requests`、`GET /api/plans/requests/mine`;管理端三条 审批路由。`PLAN_TABLE.audit` 加档位判定 → 免费档 403 `E_PLAN_REQUIRED`(矛盾 17 裁决甲), 判定排在 `authzDevice` **之后**:先问"你有没有权看这台设备",再问"你的档位够不够", 否则会把"别人的设备"答成"你档位不够"。 **M3 历史窗口**:`/history` 下发 `allowed_windows: [{key,label}]`(**对象数组**,四个中文 标签只有这一条传输路径;写成裸键等于标签无来源);按档位截断并如实标注 `truncated` / `requested_window`,`bucket_sec` 跟**生效**窗口走而不是请求窗口; 400 文案改为固定文案(不回声 `req.query.window`、不念全量白名单)。 `team.history_days` 90→30。 **M7 保留期**:`retention_days` 改为读实例配置,不再有字面量。 ## 一个真错误:`ok:true` 但档位没变 审批对**已驳回**的申请原来也返回 `ok:true, idempotent:true` —— 管理端看到成功回执, 而档位一点没变,且没有任何东西会告诉操作者他做错了。**回执与事实不一致比违反字面更坏**: 它没有下一次修正机会。现在区分两种"非 pending": - `approved → approve` 是**真幂等**(同一结果,200,不重复写审计); - `rejected → approve` 与 `approved → reject` 是**非法状态转移**,409 `E_REQUEST_NOT_PENDING`。 路由层原来把 `!r.ok` 一律映射成 404("这条申请不存在"),现在 404 与 409 分开: 否则操作者会以为挑错了行,而真实原因是那条申请已被驳回。 ## 审计事件名:改的是 AC,不是代码 SPEC AC-09 原写 `plan_request_approved`,而 `API-CONTRACT` §8 与 `openapi.yaml` 写 `admin_plan_request_approved`。裁定采用带前缀的一侧,**依据不是"另一份文档那么写"**, 而是代码里已有 **12 个** `admin_*` 审计事件(`admin_plan_changed` / `admin_kicked` / `admin_export` …):前缀让"管理端动作"这个集合可以按前缀一次扫出来;靠逐个记名字, 这个集合只能靠枚举 + 记忆,而枚举必漏。SPEC 已同步订正。 留一句方法论在那两个常量旁边:**差集只告诉你两边不一样,不告诉你哪边对。** ## 顺手清掉一份档位清单副本 `admin.js` 的 `setPlan` 硬编码 `['free','pro','team']`,改为 `Object.keys(PLAN_TABLE)`。 副本的害处不是"写错了",而是**它能跑**:将来加一档时这份列表不跟着变,新档位永远改不到, 而且不报错,只是校验说"档位不合法"。 ## 测试 新增 `tests/plan-history-tier-test.js` **99 项**,已接入 `run-all.sh`。含三集同集断言 (响应键 / 台账非 unwired 键 / OpenAPI 属性集)与 **5 组负向对照**(多返回 export / 漏 audit / 台账种假字段 / OpenAPI 少写 / 多写),负向对照必须能红才有意义。 回归:permissions 60/0、admin-api 80/0、user-lifecycle 68/0、schema 一致性 116/0、 认领在场证明 57/0、配对安全 84/0。## 三处前置此前**全部不存在**(我实测,不是文档说) ``` shared/events.js → 不存在 deploy/migrate.sql CREATE TABLE events → 0 命中 server/ 下 "api/events" → 0 命中 ``` 所以在这之前任何"事件名单差集为空"的断言都是**恒绿的空断言**:文件不存在 → 读到空 集合 → 差集为空,而"文件不存在"与"文件里没有多余事件"在输出上是同一个东西:空。 ## 服务端 - `shared/events.js`:5 个名字的**唯一机器副本**(权威在 SPEC §6.1.1)。净化做的是 **形状白名单**而不是字段名白名单 —— SPEC 只锁了事件名、没定义逐事件 props,此时 在这里发明一套字段名等于实现方单方面定契约,然后让客户端照着写。形状约束 (键名 `^[a-z][a-z0-9_]{0,31}$` / 值只接受标量 / ≤12 键 / ≤2KB / 敏感键名直接丢) 能挡住"埋点变成第二张用户表"这条路上最现实的风险,且不假装知道产品要什么。 - `POST /api/events`:未知名与非法条目**丢弃并计 dropped**(AC-20,不是 400)、 超 20 条只受理前 20(AC-21)、**写库失败仍 202**(AC-22)。认证可选但 **带废令牌一律 401**(降级成匿名等于"发个废令牌就关认证"); `body.account_id` 自报**被忽略** —— 否则谁都能往别人账号的漏斗里灌数,而漏斗是 拿来算率的,被灌了就没有意义。 - 管理端 `/api/admin/events/summary`:只出按事件名的计数与首末时间。它的判据不在 "能查出来",而在**查不出来** —— 这个端点没有任何办法取回某一条事件的内容。 ## 客户端:必须有真实产生方,否则这张表永远是空的 这是本轮最容易"看起来交付了"的地方:端点全绿、表建好了,而**没有任何上报** → 表恒空、漏斗没有分子分母,而所有测试照样通过。**"以空为通过"的形态里这一种最难发现, 因为每一层单独看都对。** `client/lib/host/event_reporter.dart` + `main.dart` 两个真实触发点(`app_open` 在配置 就绪、首帧将出时;`first_frame_shown` 用 `addPostFrameCallback`)。三条纪律: 不阻断业务(失败吞掉)、不重试(补报时序已经错了)、有界(丢最旧的)。 `app_open` **不能**放在 `main()` 最前面 —— 那时还不知道业务地址(`businessUrl` 来自 用户配置)。所以它落在"配置已就绪、首帧将出"这一刻;语义仍是"打开了应用",且这一批 能真的发出去。 ## 建表只有一个落点 `events` 只写进 `deploy/migrate.sql`,`init.sql` 用 `\i /migrate.sql` 引用它 —— 这是本仓库既有约定(新表进 migrate,init 引用)。我最初给执行方的判据是"两个文件都 必须有"——那会把一份定义复制成两份,是错的,按约定改正。 `account_id` 用 `BIGINT REFERENCES users(id) ON DELETE SET NULL` 而不是 SPEC §6.1 原写的 `TEXT`:`users.id` 是 BIGSERIAL,TEXT 会让"按账号算率"每次都 `::bigint` 强转, 转不动时(LEGACY 自报串)只能静默留 NULL,而 NULL 与"匿名上报"在表里长得一模一样。 `ON DELETE SET NULL` 让注销后**保留计数、解除关联**(CASCADE 会让历史漏斗在注销那一刻 缩水,分母变了率就不可比)。 ## 测试 `tests/events-test.js` **29 项全绿**,已接入 `run-all.sh`。关键一条是 **A7「表里真的 长出了行」**——没有它,前面每条都能在一个"什么都没落库"的实现上全绿。 AC-22 不是写注释:把 `events` 表临时改名造一次真实写失败,断言仍 202 **且** `accepted=0` **且** 带 `write_failed` **且** 恢复后行数未变(只断 202 不够 —— 任何实现都会 202,因为 catch 住了)。 客户端 `event_reporter_test.dart` 6 项全绿,其中一条钉住"先抽干再发":发送期间 `track` 进来的新事件属于下一批,不被这一批的失败连坐(否则是"失败一次丢两批")。 回归:permissions 60/0、account 75/0、schema 116/0、admin-api 80/0、认领 57/0、 档位与历史 99/0;客户端全量 **595 通过 / 2 失败**,两条失败均为 `libpmon_core.dylib` 未编译(Rust 核心未构建,本机环境问题,与本次改动无关)。## 修的是什么 `POST /api/pairing/code {device_id}` 原先**零鉴权地生成码并在响应体里返回**: ``` POST /api/pairing/code {"device_id":"<任何存在的设备>"} → 200 {"code":"483920","expires_at":...} ``` 只要知道一个存在的 `device_id`,**任何人不登录、不持有任何凭据**,就能拿到该设备 的配对码,再去查看端输入,换到该设备的查看授权。比认领洞更浅:认领洞还要先登录。 根因不是"端点忘了加鉴权",是**码的来源错了**。服务端凭什么相信"请求方就是那台 设备"?`device_id` 是可猜的公开标识;`device_token` 只发给已归属设备(新设备会 成环,M4′ 里已经推演过)。唯一不可伪造的是**那条已建立的连接本身**。 ## 改成什么 码由设备本地生成(`client/lib/host/pairing_code.dart`),随 `agent:hello` 的 `payload.claim_code` 上报,网关在这一刻登记为配对码(`PairingStore.registerDeviceCode`), **断连即删**(`forgetDeviceCode`),TTL 300s 只是崩溃兜底。 于是码的语义变成真话:**"设备此刻在这条连接上活着"**。屏幕上那个码因此永远是服务端 认可的那一个,不存在"屏幕显示 A、服务端认 B"。 **配对码与认领码是同一个字符串**:用户只有一个六格输入框的心智,两条链路各发一个码 只会让人念错。 `POST /api/pairing/code` 保留为 **410 `E_ENDPOINT_GONE`** 而不是删掉:404 会让人以为 路径拼错或版本不对,410 明确说"它被有意移除了,码从哪来"。 ## 客户端 `pairing_api.issueCode` 改为**幂等只读**:返回单例里的当前码,不发网络。别把它改成 "每次调用都换一个码" —— 换了却不同时重发 hello,屏幕就会显示一个服务端从未登记过的 值,用户念它必然失败,而且看起来像服务端坏了。 ## 顺带补上 M4′ 在客户端的缺口 上一轮 M4′ 只做了服务端:`grep claim_code client/lib` 命中 **0**。也就是服务端能力建好 了、测试全绿,而**真实客户端从不上报码** —— 生产上认领只能走 `isNew` 路径。同一个 "必须有真实产生方"的坑在 M5 里刚踩过一次(表恒空)。本次 `link.dart` 的 hello 已带 `claim_code`,两条链路同时有了产生方。 ## ⚠️ 一个比原洞更大的发现:84 项配对回归网跑的是 9 天前的镜像 `tests/pairing-security-test.js` 默认打 `http://127.0.0.1:4000`,那是 docker-compose 的实例。实测 `pmon-business-1` / `pmon-gateway-1` 镜像构建于 **2026-10-30**,而本轮 改动在 **2026-11-08** —— 早 9 天。所以那些"84 项全绿"只证明旧镜像行为没变,对仓库里的 代码一个字都没说。 所以本套件(`pairing-code-origin-test.js`)**自起源码实例**(business + gateway, 随机端口,源码直跑),并在 `run-all.sh` 里那一段加了大字警告:在其余套件改成自起 之前,**不要把它们的绿当作本仓代码的回归证据**。 ## 测试 `tests/pairing-code-origin-test.js` **16 项全绿**,已接入 `run-all.sh`。16 条里 13 条是 "拿不到"型(410 / 无码返回 / 猜码被拒 / 跨设备被拒 / 断连失效),3 条是"该拿到的人 拿得到"(hello 登记、verify 成功、一次性消费)—— 只验前者的话,一个把配对整体做死的 实现也能全绿。 客户端:`pairing_card_test` 6/6、全量 **595 通过 / 2 失败**(两条仍是 `libpmon_core.dylib` 未编译)。两条断言按行为变更改写而不是删除: - "发码失败不静默" → 改为"**一次请求都不发**"(失败路径已不存在,但新风险更大: 任何一次发出的取码请求都意味着码又能被第三方索取); - "服务端返回 123456" → 改为"卡片显示的码必须与上报给服务端的是同一个"。 第一次跑本套件时 E 段红了两条,原因是我自己的夹具没关掉前一条连接 —— 网关判定 "这台设备还有别的活连接",正确行为就是不删码。已修,并在测试里写清这个坑。 回归:认领 57/0、埋点 29/0、权限 60/0、schema 116/0。