Files
MailUI4Agents/deploy/verify-ext-port-taken.mjs
JianFeeeee be9bfd3cd1 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)。
2026-10-05 09:26:59 +08:00

75 lines
2.8 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 验证「端口已被占用」这条路径 —— 也就是**事故本身那一刻**的状态。
*
* 为什么单独一个文件:之前我把它塞在一条 `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);