chore(deploy)★★: 固定端口监听扩展的失效防护 + SSE 投递审计脚本
## 一次真实事故(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)。
This commit is contained in:
75
deploy/verify-ext-port-taken.mjs
Normal file
75
deploy/verify-ext-port-taken.mjs
Normal file
@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 验证「端口已被占用」这条路径 —— 也就是**事故本身那一刻**的状态。
|
||||
*
|
||||
* 为什么单独一个文件:之前我把它塞在一条 `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);
|
||||
Reference in New Issue
Block a user