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

@ -70,12 +70,15 @@ const WORKER_PATH = fileURLToPath(new URL('./worker.mjs', import.meta.url));
* @param {(url: string, key: string) => void} deps.onReconfigure
* @param {number} [deps.maxWorkers]
* @param {number} [deps.workerMaxMs] worker 硬超时:卡死的进程必须能被回收
* @param {number} [deps.maxAttempts] 同一封邮件的最大尝试次数(含首次)。
* worker 未回报 `done` 就退出崩溃、SIGKILL、OOM时按 1s/2s/… 有界重投;
* 超过上限就放弃并留日志 —— 无界重投会把一封必定失败的邮件变成永久活锁。
* @param {string} [deps.workerPath] 只为测试存在:换成不装 pi SDK 的桩 worker
* 让调度不变量(并发上限、同会话串行、硬超时)能在毫秒级验证。
*/
export function createWorkerPool({
log, config, onReconfigure,
maxWorkers = 3, workerMaxMs = 600_000, workerPath = WORKER_PATH,
maxWorkers = 3, workerMaxMs = 600_000, maxAttempts = 3, workerPath = WORKER_PATH,
}) {
/** 正在跑的 workermailSessionKey -> {child, mailID, startedAt, timer} */
const running = new Map();
@ -108,9 +111,9 @@ export function createWorkerPool({
*/
const keyOf = (data) => data?.session_id || `mail:${data?.mail_id || Math.random()}`;
function submit(kind, data) {
function submit(kind, data, attempt = 1) {
if (stopped) return;
queue.push({ kind, data, key: keyOf(data) });
queue.push({ kind, data, key: keyOf(data), attempt });
pump();
}
@ -144,7 +147,10 @@ export function createWorkerPool({
}, workerMaxMs);
if (typeof timer.unref === 'function') timer.unref();
const entry = { child, mailID: job.data?.mail_id || '', key: job.key, startedAt: Date.now(), timer };
const entry = {
child, mailID: job.data?.mail_id || '', key: job.key,
startedAt: Date.now(), timer, settled: false,
};
running.set(job.key, entry);
child.on('message', (msg) => onWorkerMessage(entry, msg));
@ -153,7 +159,36 @@ export function createWorkerPool({
clearTimeout(timer);
running.delete(job.key);
for (const [rk, k] of permissionRoutes) if (k === job.key) permissionRoutes.delete(rk);
if (code !== 0) {
// 没收到 `done` 就退出 = 这封邮件**从未处理完**。
//
// 这是生产上真实存在的静默丢信路径worker 被 SIGKILL硬超时
// OOM、或自己崩溃时`done` 永远不会到达,主进程只看到 exit code。
// 原来这里只记一行日志就 pump() —— 发件人看到信发出去了,
// 而那条会话再也不会有人回。
//
// 重投而不是直接由主进程回信worker 崩溃可能是内存/上游瞬时故障,
// 重启一个进程真能跑通。有界maxAttempts是因为「必定失败」的邮件
// 无界重投会变成永久活锁,而日志里只有一行看不出是同一封在原地打转。
if (!entry.settled && !stopped) {
const attempt = job.attempt || 1;
if (attempt < maxAttempts) {
const delay = attempt * 1000;
log(`worker ${child.pid}mail ${entry.mailID})未回报 done 就退出`
+ `code=${code} signal=${signal || '-'}${delay / 1000}s 后`
+ `${attempt + 1}/${maxAttempts} 次重投`);
const retry = setTimeout(() => {
if (stopped) return;
queue.push({ ...job, attempt: attempt + 1 });
pump();
}, delay);
if (typeof retry.unref === 'function') retry.unref();
// 退避期间不 pump否则同一会话会被立刻重投退避形同虚设
return;
}
log(`worker ${child.pid}mail ${entry.mailID})重投 ${maxAttempts} 次仍未完成,放弃`
+ `code=${code} signal=${signal || '-'}`);
} else if (code !== 0) {
log(`worker ${child.pid}mail ${entry.mailID})异常退出 code=${code} signal=${signal || '-'}`);
}
pump();
@ -214,6 +249,9 @@ export function createWorkerPool({
onReconfigure?.(msg.url, msg.agentKey);
return;
case 'done':
// 标记「这封真的处理完了」exit 处理器据此区分「正常收尾」
// 与「未回报就崩溃」(后者要重投)。
entry.settled = true;
if (!msg.ok) log(`投递 ${entry.mailID} 失败: ${msg.error}`);
return;
default: