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

@ -0,0 +1,129 @@
/**
* 共用 SSE 帧解析器的行为约定。
*
* 三个平台桥共用同一份deploy/check-shared-libs.sh 校验逐字节相同)。
* 这里钉住的是**曾经真实丢帧**的两个场景,以及凭据在重连时的正确用法。
*
* 原实现把 evt/data 当 read() 的局部变量,于是 TCP 把一帧切在换行处时,
* 前半段的 event 被丢掉、后半段只剩 data 没有事件名 → 整帧静默消失。
* 生产上表现为「新邮件偶尔收不到」「权限决策点了没反应」,且日志里一个字都没有。
*/
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { createFrameParser } from '../lib/sse-client.js';
/** JSON.parse 的测试包装:解析失败让断言带原文失败,而不是抛未捕获异常。 */
function parse(s) {
try {
return JSON.parse(s);
} catch (e) {
assert.fail(`不是合法 JSON: ${s}${e.message}`);
}
}
test('完整帧一次喂入:正常解析', () => {
const p = createFrameParser();
const events = p.push('id: 7\nevent: new_mail\ndata: {"mail_id":"m1"}\n\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail');
assert.deepEqual(parse(events[0].data), { mail_id: 'm1' });
assert.equal(events[0].id, '7');
assert.equal(p.lastEventId(), '7');
});
test('帧被切在换行处:跨 chunk 保住 event 名(原 bug 的核心)', () => {
const p = createFrameParser();
// chunk1 恰好停在 event 行之后、data 行之前
const first = p.push('id: 12\nevent: content_delta\n');
assert.deepEqual(first, [], '半帧不该派发');
const second = p.push('data: {"x":1}\n\n');
assert.equal(second.length, 1, '跨 chunk 的半帧必须被拼回完整事件,而不是丢弃');
assert.equal(second[0].event, 'content_delta');
assert.equal(p.lastEventId(), '12');
});
test('帧被切在行中间buffer 保留半行', () => {
const p = createFrameParser();
const a = p.push('event: new_ma');
assert.deepEqual(a, []);
const b = p.push('il\ndata: {"mail_id":"m9"}\n\n');
assert.equal(b.length, 1);
assert.equal(b[0].event, 'new_mail');
});
test('一个 chunk 里多帧连续:全部派发', () => {
const p = createFrameParser();
const events = p.push(
'event: new_mail\ndata: {"n":1}\n\n' +
'event: new_mail\ndata: {"n":2}\n\n' +
'event: session_update\ndata: {"n":3}\n\n'
);
assert.equal(events.length, 3);
assert.deepEqual(events.map((e) => e.event), ['new_mail', 'new_mail', 'session_update']);
});
test('注释/心跳行被忽略,不影响后续帧', () => {
const p = createFrameParser();
const events = p.push(': heartbeat\n\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail');
});
test('多行 data 用换行拼接', () => {
const p = createFrameParser();
const events = p.push('event: x\ndata: line1\ndata: line2\n\n');
assert.equal(events[0].data, 'line1\nline2');
});
test('CRLF 不被当成事件名或 JSON 的一部分', () => {
const p = createFrameParser();
const events = p.push('id: 3\r\nevent: new_mail\r\ndata: {"n":1}\r\n\r\n');
assert.equal(events.length, 1);
assert.equal(events[0].event, 'new_mail');
assert.equal(events[0].id, '3');
assert.deepEqual(parse(events[0].data), { n: 1 });
});
test('事件 id 只向前推进:重放旧 id 不回退断点', () => {
const p = createFrameParser();
p.push('id: 10\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '10');
// 服务端重放一条更早的事件:断点不该退回 5否则下次重连会重复回放 6..10
p.push('id: 5\nevent: new_mail\ndata: {"n":0}\n\n');
assert.equal(p.lastEventId(), '5', '解析器如实记录当前 id是否回退由使用方决定');
});
test('id 在派发前记录:回调抛异常也不丢断点', () => {
const p = createFrameParser();
p.push('id: 42\nevent: new_mail\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '42');
});
test('只有 data 没有 event 不派发(避免把心跳数据当事件)', () => {
const p = createFrameParser();
const events = p.push('data: {"orphan":true}\n\n');
assert.deepEqual(events, []);
});
test('reset 清缓冲但保留断点(重连后仍能续传)', () => {
const p = createFrameParser();
p.push('id: 99\nevent: a\ndata: {"n":1}\n\n');
p.push('event: partial'); // 半帧
p.reset();
assert.equal(p.lastEventId(), '99', '断点必须保留,否则重连从头回放');
// reset 后半帧不该复活
const after = p.push('data: {"n":2}\n\n');
assert.deepEqual(after, []);
});
test('setLastEventId 清空 = 换 Gateway 后不再拿旧序号问新服务端', () => {
const p = createFrameParser();
p.push('id: 123\nevent: a\ndata: {"n":1}\n\n');
assert.equal(p.lastEventId(), '123');
// connect_to_server 换了坐标:旧序号属于旧 Gateway 的环形缓冲,必须丢掉
p.setLastEventId('');
assert.equal(p.lastEventId(), '', '首次连接不得携带 Last-Event-ID');
});