jkooe
8903871919
feat(launcher): R10 客户端自愈——device_id 被降级为 null 时换新 id(免 PIN 闭环)
## 缺陷(QA 观察 2,用户可感知的那一半)
服务端已把「500 + 永久丢设备」消灭了,但客户端从不读响应里的 `device_id`:
`agent.ts` 的心跳只取 `result.data.serverTime`。后果链:
1. 两台机器共用 settings.json → 撞 `idx_devices_uuid`;
2. 服务端把后到的那台降级为 `device_id = null`(不 500,在线状态保住)✅;
3. 客户端毫无反应 → 下一轮继续上报**同一个被占用的 id** → 永远 `null`;
4. 本机证书永远进不了对端 Sunshine 目录 → **每次串流都要手输 PIN,无自愈路径**。
免 PIN 是本项目核心目标,所以这条必须补。
## 修法
- `core/config.ts` 新增 `regenerateDeviceId()`:**无条件**换一个新 id 并持久化。
与 `ensureDeviceId()`(保证有 id、绝不覆盖)**职责严格分开** —— 给后者加
`force` 也能 work,但等于在唯一一个「不该覆盖」的入口上开一个能覆盖的开关。
- `agent.ts`:
- `AgentDeps` 新增 `regenerateDeviceId?()` 与 `readDeviceId?()`;
- 内部 `deviceId` 由「构造时快照」改为可变字段,心跳请求体用它;
- 心跳成功后调 `healDeviceIdIfSuppressed(result.data?.device_id)`;
- 判据严格 `=== null`:`undefined` 表示老服务端根本不回该字段,与「被降级」无关,
误触发会导致「每轮换一个 id、永远撞不完」;
- 换完立即持久化,下一轮心跳(≤30s)生效;不做忙等重试,新 id 若又撞则下一轮自然再换;
- 生成器未注入 / 返回同值 / 抛异常时**都不打挂心跳**(就地捕获 + warn)。
- `certs.ts`:`CertServiceDeps.deviceId` 允许传 `() => string`,每次调用现取。
否则换 id 后 `skipped_self` 仍用旧 id 比对,会把**自己的**证书当 peer 写进本机
Sunshine 目录,留下一个永远「受管」的自我证书文件(prune 清不掉)。
- `main.ts`:`currentDeviceId` 改为可变局部变量,向下传工厂函数而非快照。
## 测试(launcher 493 → 502)
`test/agent.test.ts` 新增 6 例:
1. `响应 device_id === null → 生成新 id 并持久化,且与旧 id 不同`
2. `下一轮心跳上报的是新 id(旧 id 不再出现在请求体里)`(断言第二次调用入参)
3. `响应是正常 id → 绝不触发重新生成(防「每轮换一个 id」的抖动)`
4. `响应没有 device_id 字段(老服务端兼容)→ 同样不触发重新生成`
5. `未注入 regenerateDeviceId → 只记 warn,不抛异常、心跳仍算成功`
6. `生成器返回与旧值相同的 id → 不切换(否则就是「假自愈」)`
`test/config.test.ts` 新增 3 例:真的写回设置文件、换完后 `ensureDeviceId` 返回新值、
原本无 id 时也能换。
## 顺带|观察 1 的口径统一(一行)
`insertDevicePresence` 第 ③ 层由无条件 `null` 改为
`racedAfterRetry.device_id ?? null`(保留原值),与 `existing` 分支
`deviceId ?? fallbackId` 的口径一致;避免将来多 worker 共享 DB 时把并发请求刚写入的
合法 id 抹掉一个 tick。
注释里补上 QA 指出的事实:**①③ 在单进程下不可达**(`node:sqlite` 是同步 API,
handler 内无 `await`,两次查询之间插不进别的请求)—— 这正好解释了旧代码为什么
**必然** 500(旧 catch 只有「重查同名」一条路,而它恒走不到)。保留为多 worker 预留。
## 文档
- `docs/troubleshooting.md` §16:新增「两台机器共用同一 `device_id` ⇒ 一个心跳周期内
自动恢复」的首条处置(含两条 warn 的原文与判据),并把
`本机 device_id 已被他人占用,已换新 id` 加入「现象」(注明是自愈动作不是故障)。
- `docs/api.md` R10 段:补「客户端必须读这个 `null` 并换 id,否则降级等于永久无 id」,
含 `=== null` 与 `undefined` 的区别。
- README 数字同步。
## 记录澄清
QA 指出 `30fc269` 的 message 把 `QA-R10f/g/h/i` 记成「新增」,实际是 QA 写在工作区、
我提交时一并收录。归属记错一格,在此说明;断言与代码无误,不改历史。
## 一并入库
`server/test/qa-r10-concurrency.test.mjs`(QA 第 3 轮并发对抗用例 6 例)。
验证:server 82/82(覆盖率 90.18%,smoke 通过);launcher 502/502
(typecheck / eslint / prettier 全绿,覆盖率 91.89%);gitleaks protect --staged 干净。
2026-10-26 03:02:43 +08:00
..
2026-10-26 00:10:01 +08:00
2026-10-26 00:10:01 +08:00
2026-10-26 00:10:01 +08:00
2026-10-26 02:49:28 +08:00
2026-10-26 00:10:01 +08:00
2026-10-26 02:07:02 +08:00
2026-10-26 00:10:01 +08:00
2026-10-26 00:10:01 +08:00
2026-10-26 03:02:43 +08:00
2026-10-26 02:49:28 +08:00
2026-10-26 01:59:35 +08:00