## 一次真实事故(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)。
81 lines
3.4 KiB
JavaScript
81 lines
3.4 KiB
JavaScript
/**
|
||
* 直接加载 pi-a2a / pi-acp 扩展(像 pi 用 jiti 那样),验证**根进程路径**:
|
||
* 端口空闲 ⇒ 真的绑定成功并能应答。
|
||
*
|
||
* 为什么需要它:sessiond 是启动时加载扩展的,它内存里仍是**旧代码**,
|
||
* 所以本轮改动在父进程路径上没有被真实跑过。而父进程正是「端口空闲」
|
||
* 那条分支 —— 不测就等于没验。
|
||
*/
|
||
import { createRequire } from "node:module";
|
||
|
||
const require = createRequire(import.meta.url);
|
||
const AGENT = "/root/.pi/agent";
|
||
const jitiMod = require(`${AGENT}/npm/node_modules/jiti/lib/jiti.mjs`);
|
||
const createJiti = jitiMod.createJiti ?? jitiMod.default ?? jitiMod;
|
||
|
||
const target = process.argv[2]; // pi-a2a | pi-acp
|
||
const port = process.argv[3];
|
||
const which = target === "pi-acp" ? "__piAcpServer" : "__piA2aServer";
|
||
const currentKey = target === "pi-acp" ? "__piAcpCurrent" : "__piA2aCurrent";
|
||
|
||
// 选一个空闲端口:让扩展的端口探测走「未被占用」分支
|
||
process.env.PI_A2A_PORT = port;
|
||
process.env.PI_ACP_PORT = port;
|
||
delete process.env.PI_SUBAGENT_CHILD; // 明确不是子进程
|
||
delete process.env.PI_SUBAGENT_PARENT_DEPTH;
|
||
|
||
const file = `${AGENT}/extensions/${target}/index.ts`;
|
||
const jiti = createJiti(import.meta.url, { interopDefault: true, fsCache: false, moduleCache: false });
|
||
// jiti.import 已把 default 解开;不要再 .default 取一次(那是 undefined)
|
||
const factory = (await jiti.import(file)).default;
|
||
|
||
// 最小 ExtensionAPI 桩:扩展工厂只需要它来登记工具/事件
|
||
const registered = [];
|
||
const pi = {
|
||
registerTool: (t) => registered.push(t.name),
|
||
on: () => {},
|
||
sendUserMessage: () => {},
|
||
sendMessage: () => {},
|
||
};
|
||
|
||
factory(pi);
|
||
|
||
// ensureServer 现在是 async,等它把端口绑定做完
|
||
await new Promise((r) => setTimeout(r, 700));
|
||
|
||
const srv = globalThis[which];
|
||
if (!srv) {
|
||
console.log(`RESULT=NO_SERVER ${target}(未创建 server —— 端口被占用路径或子进程路径)`);
|
||
process.exit(2);
|
||
}
|
||
const addr = srv.address();
|
||
if (!addr) {
|
||
console.log(`RESULT=NOT_LISTENING ${target}(server 已建但未绑定 —— 不该发生)`);
|
||
process.exit(3);
|
||
}
|
||
|
||
// 真发一个请求,确认服务真的能用。
|
||
// ⚠️ 两个扩展的应答形状**故意不同**,不能用同一个断言:
|
||
// pi-a2a 实现了 A2A Agent Card(GET / 返回 {name:"Pi",...})
|
||
// pi-acp 没有 card 端点;它对无 method 的请求走 400,对已知 method 才应答。
|
||
// 第一版我对两者都断言 body.name === "Pi",pi-acp 报 BAD_CARD ——
|
||
// 查证后是**测试的假设错了**,不是代码错(acp 本来就没这个端点)。
|
||
const res = await fetch(`http://127.0.0.1:${addr.port}/`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify(target === "pi-acp"
|
||
? { jsonrpc: "2.0", id: 1, method: "session/cancel", params: { sessionId: "x" } }
|
||
: { jsonrpc: "2.0", id: 1, method: "noop" }),
|
||
});
|
||
const body = await res.json();
|
||
const okShape = target === "pi-acp"
|
||
? Boolean(body.result && "sessionId" in body.result)
|
||
: Boolean(body.result && body.result.message);
|
||
if (!okShape) {
|
||
console.log(`RESULT=BAD_RESPONSE ${target} ${JSON.stringify(body).slice(0, 200)}`);
|
||
process.exit(4);
|
||
}
|
||
console.log(`RESULT=LISTENING ${target} port=${addr.port} http=${res.status} shape=ok`);
|
||
console.log(`TOOLS=${registered.join(",")}`);
|
||
srv.close();
|
||
process.exit(0); |