fix(bridges): SSE 跨分片保帧 + Last-Event-ID;pi worker 有界重投;systemd 故障上报;清理误提交二进制

三个平台桥原本各自手写 SSE 解析,有两个共同的静默丢事件缺陷:
  1. evt/data 是每次 read() 的局部变量 —— TCP 把一帧
     'event: x\ndata: {...}\n\n' 切在换行处时,前半段的 event 名被丢掉、
     后半段只剩 data,整帧静默丢弃。表现为「新邮件偶尔收不到」
     「权限决策点了没反应」,日志里一个字都没有。
  2. 重连不带 Last-Event-ID —— 断线期间的事件留在服务端 per-agent 环形
     缓冲里永远回放不出来(pi 与 homeagent 已正确使用,DSH/opencode 没有)。

修法:抽出共用 lib/sse-client.js(三桥逐字节同源,check-shared-libs 校验),
把「跨 chunk 保帧状态」与「Last-Event-ID 断点续传」写对一次。pi 桥的
gateway.mjs 也改为复用同一实现(保留 reconfigure 时清断点的语义)。

pi worker 丢任务:worker 未回报 done 就退出(SIGKILL/OOM/崩溃)时,
主进程原来只记一行日志就 pump() —— 那封邮件永远没有回音。改为按
1s/2s 退避有界重投(默认 3 次),到上限记「放弃」并可观测。

systemd 故障上报:四个宿主服务接入 service-failure-notify.mjs 的
ExecStopPost/--report 与 ExecStartPost/--flush。进程内 uncaughtException
捕获不了 SIGKILL/OOM,只能由 systemd 统一覆盖。正常 stop/restart 不发信。

仓库卫生:server/server(24MB 构建产物,f9d757b 误提交)移出版本库。

测试:opencode 302 / dsh 335 / pi 391 全绿(新增 12 例 SSE 帧解析 +
2 例 worker 重投);Go 全量通过;四平台重启后在线且无错误。
This commit is contained in:
2026-09-11 10:27:41 +08:00
parent f9d757b5e5
commit c401eb2da2
17 changed files with 1093 additions and 175 deletions

View File

@ -53,6 +53,10 @@ process.on('message', (msg) => {
process.send({ type: 'permission_pending', relayKey: msg.data.__pending });
return; // 等决策,见下面的分支
}
if (msg.data?.__crash) {
// 未回报 done 就退出:验证主进程会重投(而不是静默丢信)
process.exit(1);
}
// 每 40ms 报一次心跳:并发的判据必须是「两个进程真的同时在干活」,
// 而不是「running map 里有两个条目」—— fork 返回后立即就有两个条目了。
const beat = setInterval(() => process.send({ type: 'log', line: 'TICK ' + msg.data.mail_id }), 40);
@ -89,6 +93,7 @@ function makePool(opts = {}) {
onReconfigure: opts.onReconfigure || (() => {}),
maxWorkers: opts.maxWorkers ?? 2,
workerMaxMs: opts.workerMaxMs ?? 5000,
maxAttempts: opts.maxAttempts,
workerPath: opts.workerPath || STUB,
});
return { pool, lines };
@ -387,3 +392,29 @@ process.send({ type: 'ready' });
assert.deepEqual(got, { url: 'http://new:9999', key: 'k2' },
'worker 里 connect_to_server 换的坐标必须回到主进程 —— worker 马上就退了,改在它自己身上等于没改');
});
test('worker 未回报 done 就退出:有界重投而不是静默丢信', async () => {
// maxAttempts=2首次 + 一次重投,然后放弃。
// 这封邮件必定崩溃,重投就是在验证「有界」——不然它会变成永久活锁。
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 2 });
pool.submit('mail', { mail_id: 'crashy', session_id: 'CRASH', __crash: true });
const gaveUp = await until(() => lines.some((l) => l.includes('放弃')), 6000);
pool.stop();
assert.ok(gaveUp, `重投到上限后应记下「放弃」,实际:\n${lines.join('\n')}`);
assert.equal(jobs(lines).length, 2,
`应当尝试 2 次(首次 + 1 次重投),实际 ${jobs(lines).length}`);
assert.ok(lines.some((l) => l.includes('未回报 done 就退出')),
'必须明说是「未回报 done 就退出」——否则看到 exit code 会误以为是普通崩溃');
});
test('重投上限之下不会无限重投maxAttempts=1 就是不重投)', async () => {
const { pool, lines } = makePool({ maxWorkers: 1, maxAttempts: 1 });
pool.submit('mail', { mail_id: 'once', session_id: 'ONCE', __crash: true });
await until(() => lines.some((l) => l.includes('放弃')), 4000);
await sleep(300); // 再等一会儿,确认没有额外重投
pool.stop();
assert.equal(jobs(lines).length, 1, 'maxAttempts=1 时只跑一次');
});