## 一次真实事故(2026-10-03)
pi-web 会话里**所有**子 agent 都启动失败,报错
Error: listen EADDRINUSE: address already in use 127.0.0.1:14010
这个扩展把端口写死,于是「端口被占」直接变成**全部启动失败**,
没有任何降级路径。这次提交把那条路径变成可测的。
- `check-listen-portability.mjs`(360 行):主判据
- `mutate-listen-portability.py`(124 行):变异体 —— 确认判据抓得住
- `verify-ext-parent-path.mjs` / `verify-ext-port-taken.mjs`:
两条独立路径。**"端口已被占用"那一条之前从没真正跑完过** ——
它原先被塞在一条 `node -e` 里,进程在「await 端口探测」那步被 30s
超时掐断,而它恰恰是唯一会决定「子进程会不会被打死」的分支。
## scripts/sse-delivery-audit.sh(298 行)
从网关 access log 判定「SSE 那批邮件到底投没投出去」,**只读**。
为什么必须进版本库而不是 `.tmp/`:`.tmp/` 在 .gitignore 里,
写在 `.tmp/stress-test.sh` 的判据下一轮就没了 ——
而 `docs/DEBTS.json` 里 `stress-sse-delivered-but-silent-no-assertion`
那笔债会长期挂着。**判据和账本必须同寿命。**
## .gitignore
补 `/tmp/`:blob 阈值测试会生成 13MB 级 .bin
(`client/electron/test/tmp/att-threshold-test/`),可再生,不该进版本库
(本机实测 25MB)。
75 lines
2.8 KiB
JavaScript
75 lines
2.8 KiB
JavaScript
/**
|
||
* 验证「端口已被占用」这条路径 —— 也就是**事故本身那一刻**的状态。
|
||
*
|
||
* 为什么单独一个文件:之前我把它塞在一条 `node -e` 里,进程在
|
||
* 「await 端口探测」那步被 30s 超时掐断(Command aborted),
|
||
* 于是这条路径**从来没真正跑完过**。而它恰恰是唯一会决定
|
||
* 「子进程会不会被打死」的分支。
|
||
*
|
||
* 两层断言:
|
||
* 1. 进程存活到结束(事故根因:不加 on("error") 时 Node 抛未捕获异常)
|
||
* 2. 该进程没有抢占已占用的端口(语义正确性:让给真正的占用者)
|
||
*/
|
||
import { createServer } from "node:http";
|
||
import { createRequire } from "node:module";
|
||
|
||
const require = createRequire(import.meta.url);
|
||
const AGENT = "/root/.pi/agent";
|
||
const target = process.argv[2] ?? "pi-a2a";
|
||
const PORT = Number(process.argv[3] ?? 14779);
|
||
const srvKey = target === "pi-acp" ? "__piAcpServer" : "__piA2aServer";
|
||
|
||
// 先占住端口,模拟「父进程已经监听」
|
||
const blocker = createServer(() => {});
|
||
await new Promise((r) => blocker.listen(PORT, "127.0.0.1", r));
|
||
console.log(`SETUP: 端口 ${PORT} 已被本进程占用(模拟父进程)`);
|
||
|
||
process.env.PI_A2A_PORT = String(PORT);
|
||
process.env.PI_ACP_PORT = String(PORT);
|
||
delete process.env.PI_SUBAGENT_CHILD; // 走「非子进程但端口被占」这条分支
|
||
delete process.env.PI_SUBAGENT_PARENT_DEPTH;
|
||
|
||
const jiti = require(`${AGENT}/npm/node_modules/jiti/lib/jiti.mjs`).createJiti(import.meta.url, {
|
||
interopDefault: true,
|
||
fsCache: false,
|
||
moduleCache: false,
|
||
});
|
||
const factory = (await jiti.import(`${AGENT}/extensions/${target}/index.ts`)).default;
|
||
|
||
const tools = [];
|
||
factory({ registerTool: (t) => tools.push(t.name), on: () => {}, sendUserMessage: () => {} });
|
||
|
||
// 扩展的端口探测是异步的(300ms 超时),给它时间
|
||
await new Promise((r) => setTimeout(r, 1200));
|
||
|
||
const srv = globalThis[srvKey];
|
||
const problems = [];
|
||
|
||
if (srv && srv.listening) {
|
||
problems.push(`抢占了已占用端口 ${PORT}(语义错误)`);
|
||
}
|
||
if (srv && srv.address()) {
|
||
problems.push("仍报告了一个 bound address(应为空)");
|
||
}
|
||
if (tools.length === 0) {
|
||
problems.push("工具未注册 —— 扩展功能被降级掉了(出站工具必须仍可用)");
|
||
}
|
||
|
||
// 断言 1:占位者仍能应答(没被踢掉)
|
||
const alive = await new Promise((r) => {
|
||
const req = blocker.listening;
|
||
r(req);
|
||
});
|
||
if (!alive) problems.push("占用者不再监听");
|
||
|
||
console.log(`OBSERVED: ${target} 创建了 server=${srv !== undefined}, 工具=[${tools.join(",")}]`);
|
||
|
||
if (problems.length) {
|
||
console.log(`RESULT=FAIL ${problems.join("; ")}`);
|
||
blocker.close();
|
||
process.exit(1);
|
||
}
|
||
|
||
console.log(`RESULT=PASS 进程存活且未抢占已占用端口;出站工具仍注册`);
|
||
blocker.close();
|
||
process.exit(0); |