【Codex with ChatGPT 源码学习⑧】深入 CLI、Daemon 与 Doctor:从进程自愈到并发启动的真实故障
系列定位:本文是对社区开源项目 XiaoDuoYa/codex-with-chatgpt 的独立技术研究,不是 OpenAI 官方教程,也不代表原项目维护者的立场。前七章已经分析架构、Skill、浏览器、MCP、OAuth、状态机及安全审计;本章开始研究系统如何真正启动、管理、诊断和恢复。
版本与证据边界:源码链接对应 2026-10-28 核对的公开
main分支;实测对象运行时报告 Bridge 版本0.1.3,测试环境为 Windows PowerShell + Node.jsv24.21.0。测试时没有保留完整 Git commit SHA,故不能断言该实验与后续任何特定 commit 完全一致。读者应自行记录git rev-parse HEAD以固定复现基线。标记含义:
[源码确认]指直接能从所链接函数看到的行为;[实测]指隔离环境实际运行并留下输出的结果;[分析]指依据事实的技术解释或仍需确认的时序;[建议]指原仓库尚未证实实现的增强设计。文章中的示例路径、进程 ID 与工作区名称经过匿名化,不公开任何本地管理令牌。安全提示:本文包含本地进程与故障注入实验,仅在你拥有权限的隔离测试目录使用。不要把原始 Runtime JSON、授权凭据、配对码或可能包含敏感信息的日志直接公开;不要针对未核实身份的 PID 执行强制终止。
推荐标签:Codex AI Agent Node.js TypeScript 进程管理 故障恢复
摘要:Agent 系统不止要“能启动”,还要知道什么时候复用、什么时候拒绝重启、什么时候允许自修复。本文沿 c2c start → ensureBridge() → findBridgeObservation() → startBridge() 追踪 C2C 的 CLI、后台进程、Runtime State、HTTP Health Probe 与 Doctor 机制,并结合 Windows 隔离实验验证启动、顺序复用、优雅关闭、自动修复、异常三态和端口回退。最后,我们报告一次真实的双 CLI 并发启动实验:同一 Workspace 出现两个成功监听的 Bridge,其中一个在正常 stop 后残留。它让我们看清顺序复用与并发唯一性保证是两回事。
1. 一个问题:为什么 c2c start 返回后,Bridge 仍在运行?
前面几章我们习惯从逻辑架构理解 C2C:Codex 发送控制消息,ChatGPT 规划审查,本地 MCP Bridge 暴露受限工具。但只要开始长期使用,就会遇到另一组问题:
- 用户关闭终端,Bridge 为什么还能继续工作?
- 两次运行
start,会创建两个进程吗? /health超时是不是意味着服务已死亡?- 如果启动端口被占用,为什么 CLI 还能返回成功?
- Doctor 报告错误时,哪些可以自动修复,哪些应该拒绝操作?
- 两个 CLI 几乎同时启动同一个工程,会发生什么?
这几个问题属于运行时管理(runtime management)与进程生命周期(process lifecycle),比单纯调用 app.listen() 更接近真实产品的工程挑战。
本章的主线非常清晰:
用户执行 c2c start
↓
CLI 确认 Workspace
↓
查找这个 Workspace 的 Runtime State
↓
探测既有 Bridge
├─ healthy:复用
├─ stopped:可尝试启动
└─ unknown:拒绝盲目重复启动
↓
创建后台 serve 进程并等待健康
↓
通过 Doctor 进行多层诊断与条件修复
注意:这是正常的顺序调用路径。后面我们会看到,即使每个单独请求都遵守上述规则,多个请求并发时仍可能发生竞争。
2. 源码导航:五个文件分别负责什么?
建议先打开原始仓库,而不是凭本文的精简代码片段猜测完整实现。
| 文件 | 主要入口 | 研究问题 |
|---|---|---|
bin/c2c.js | JS 启动器 | 如何选择已构建 JS 与 TS 开发入口? |
src/cli/index.ts | start、serve、status、stop、doctor | CLI 如何解析命令、决定动作? |
src/process/daemon.ts | ensureBridge()、stopBridge() | 如何创建、复用和停止后台进程? |
src/bridge/runtime.ts | findBridgeObservation()、probeBridge() | Runtime State 与服务健康是什么关系? |
src/bridge/server.ts | startBridge()、listen()、/health | 真正的 Express Bridge 如何启动? |
还会用到 src/config/paths.ts 和 src/config/sandbox-allow.ts 来理解状态目录和 Codex 沙箱白名单。
2.1 不要把 CLI 进程和 Bridge 进程混为一谈
图中 CLI 是短生命周期的管理程序;serve 创建的 Bridge 才是可在后台持续运行的服务。两者共享工作区身份和本地 Runtime State,但不是同一个进程。
3. start、serve、status、doctor 到底有什么区别?
看 src/cli/index.ts,会发现不同命令有不同角色。
| 命令 | 主要作用 | 会改变运行状态吗? |
|---|---|---|
c2c start | 确保 Bridge 可用,按选项处理 Tunnel | 可能启动 Bridge |
c2c serve | 由后台子进程调用的内部前台入口 | 创建真正的 HTTP 服务 |
c2c status | 读取服务观测结果及已授权的 Admin 信息 | 以查询为主 |
c2c doctor --no-fix | 输出环境与服务诊断报告 | 不走主动修复分支,但不宜视为严格零副作用承诺 |
c2c doctor | 诊断并在符合条件时尝试修复 | 可能写配置、启动 Bridge 或恢复 Tunnel |
c2c stop | 请求关闭对应 Workspace 的 Bridge | 是 |
serve 在项目 CLI 中是隐藏的内部命令;正常用户主要接触 start、status、doctor 等管理入口。源码确认:src/cli/index.ts
3.1 CLI 启动器的 tsx 陷阱
源码优先运行已经构建的 dist/cli/index.js;没有构建产物时,回退为通过 tsx/esm 加载 TypeScript 源码。源码确认:bin/c2c.js
因此,在源码刚克隆、依赖未安装或开发依赖缺失时,可能遇到:
ERR_MODULE_NOT_FOUND: Cannot find package 'tsx'
这不是 ChatGPT 模型问题,也不直接说明网络或 OAuth 故障。应先按仓库锁文件安装依赖、构建,再执行 CLI:
corepack pnpm install --frozen-lockfile
corepack pnpm typecheck
corepack pnpm build
node .\bin\c2c.js --help
反过来,如果 src/ 已修改但 dist/ 没有重建,程序可能运行旧编译产物。源码版本与实际执行版本也需要一致性检查。
4. Runtime State:一份定位记录,不是健康证明
查看 src/bridge/runtime.ts。该模块定义了 RuntimeState,主要包含:
interface RuntimeState {
service: string;
version: string;
workspaceId: string;
workspaceRoot: string;
pid: number;
port: number;
adminToken: string;
publicUrl: string | null;
startedAt: string;
}
这是一个包含敏感凭据的记录。它保存在用户的 C2C 状态目录,而不是放在被分析的工程仓库内。每个 Workspace 的状态文件使用类似 runtime/<workspace-id>.json 的路径。源码确认:runtimeFile()
不能把 Runtime State 当作健康证明,原因至少有四个:
- 记录的 PID 可能已退出,甚至后来被其他进程复用。
- 端口可能已被别的应用重新占用。
- 进程可能仍存在,却因为死锁或初始化故障而无法响应 HTTP。
- HTTP 服务可以响应,但可能属于另一 Workspace。
所以要区分三个观测层:
| 层次 | 提供什么证据 | 不能证明什么 |
|---|---|---|
| Runtime State | 上一次记录的 PID、端口、工作区 | 目前进程仍健康 |
| OS 进程探测 | 某 PID 存在或不存在 | 该 PID 一定是原 Bridge |
| HTTP Health Probe | 某端口有符合条件的服务响应 | 它与保存的原实例身份完全一致 |
4.1 为什么健康响应并不等于完整实例身份?
当前 /health 返回服务名、版本、Workspace ID 和 status: ok,但没有独立的每次启动 Instance ID。源码确认:server.ts
findBridgeObservation() 接收返回的健康信息,确认 Workspace ID 匹配后就判为 healthy,而该分支没有再次核对响应服务的 PID 是否与 Runtime State 的 PID 一致。因此:这是一种针对服务与工作区的健康判断,不是严格的进程实例所有权证明。
这并非已经证明了 PID 复用攻击,只是对当前判断条件的边界分析。
5. findBridgeObservation():为什么要设计三态?
这是整个运行管理层最值得精读的函数之一。源码确认:runtime.ts
其关键逻辑可以压缩为:
const runtime = readRuntimeState(workspaceId);
if (!runtime) return { state: "stopped", reason: "runtime_missing" };
const health = await probeBridge(runtime.port);
if (health && health.workspaceId === workspaceId) {
return { state: "healthy", runtime };
}
if (health) return { state: "unknown", reason: "workspace_mismatch" };
const pid = observePid(runtime.pid);
if (pid === "missing") return { state: "stopped", reason: "pid_missing" };
return { state: "unknown", reason: pid === "unknown" ? "pid_unknown" : "probe_failed" };
上面的代码是逻辑精简摘录,省略了原始 TypeScript 联合类型中的部分字段和辅助函数;请以链接中的源码为准。
5.1 五种典型观测
| 现象 | 判断结果 | 原因 |
|---|---|---|
| 无 Runtime State | stopped / runtime_missing | 没有可用的记录 |
| 健康响应且 Workspace 匹配 | healthy | 可以按当前规则复用 |
| 返回的是另一 Workspace | unknown / workspace_mismatch | 不能操作不属于当前工程的服务 |
| HTTP 探测失败、PID 确定不存在 | stopped / pid_missing | 已确认记录中的进程不在 |
| HTTP 探测失败、PID 仍存在 | unknown / probe_failed | 不能把不可达当作已死亡 |
最重要的设计取舍:C2C 并非只用 isRunning: boolean,而是允许“状态不确定”。这能防止在服务短暂不可达时直接创建第二个实例。
但还必须认识到,stopped / runtime_missing 本身也不意味着计算机上绝对没有同一工程的孤立服务;它只是当前实现的分类策略。
5.2 probeBridge() 检查的是什么?
该函数请求 http://127.0.0.1:<port>/health,默认超时约 2 秒;HTTP 成功后还检查服务标识。源码确认:probeBridge()
probeBridge() 成功、process.kill(pid, 0) 返回“存在”,都是局部证据。不能用“探测成功”直接推出“未来一定持续健康”。
6. ensureBridge():复用、创建与拒绝启动
src/process/daemon.ts 的核心判断非常短:
const observation = await findBridgeObservation(workspace.id);
if (observation.state === "healthy") {
return { runtime: observation.runtime, spawned: false };
}
if (observation.state === "unknown") {
throw new Error("Bridge state is uncertain");
}
// stopped 才进入创建新子进程的路径
其中第二个分支非常值得借鉴:当无法确定已有 Bridge 是否仍在运行时,优先拒绝创建新实例。
6.1 创建后台进程
创建进程时,核心参数包括:
const child = spawn(cmd, args, {
detached: true,
stdio: ["ignore", out, out],
env: { ...process.env },
windowsHide: true
});
child.unref();
完整代码在 ensureBridge();这里的 cmd、args 来自 cliEntry(),真正参数还包含 serve --workspace 和可选端口。
逐项理解:
spawn():创建真正承载 Bridge 的 Node.js 子进程。detached: true:使进程采用脱离父进程的运行方式,不等于 systemd/Windows Service 自动守护。stdio: ["ignore", out, out]:stdin 不连接终端,stdout/stderr 进入同一日志文件。windowsHide: true:Windows 上隐藏新控制台窗口,不是隐藏进程。unref():避免子进程引用单独阻止 CLI 退出;不保证子进程永久存活。env: { ...process.env }:子进程继承启动环境,环境变量也需进行最小化与敏感数据审查。
6.2 为什么启动后还要等约 20 秒?
ensureBridge() 在创建进程后,以约 300 毫秒间隔探测健康,并设置 20 秒等待窗口;只有找到了健康的 Runtime,才返回结果。源码确认:daemon.ts
不要把这三个事件混为一谈:进程创建成功 ≠ HTTP 端口监听成功 ≠ 工作区身份和服务健康探测通过。
同样,健康等待超时只能证明 CLI 没在期限内确认成功,不能单凭超时认定刚刚创建的进程已经退出。启动失败后仍应检查是否有残留进程。
7. 日志:后台服务可观测性的第一道保障
Daemon 打开一个追加日志,形式类似:
<state-dir>/logs/bridge-<workspace-id>.out.log
Bridge 内部 Logger 还可能产生独立结构化日志 bridge.log。这是两种不同用途的日志:一种承接 stdout/stderr,另一种记录服务内部事件。源码:daemon.ts · Logger
日志对于后台进程诊断很重要,但它同时引出两个工程要求:
- 安全性:日志可能记录错误对象、路径或临时凭据,不能因为文件使用
0600就认为绝不会泄露;Windows 还需要注意实际 ACL。 - 可观测性:仅通过某条日志出现与否,不一定能可靠重建所有启动与退出事件;并发日志共享时尤其如此。
后面的并发实验就展示了:不正确的文件共享模式会导致日志读取失败,而错误的进程命令行字符串匹配还会让人误以为“没有残留进程”。
8. stopBridge():优雅关闭和兜底信号
根据 stopBridge(),关闭流程先读取 Runtime State,尝试确认该端口的健康响应属于目标 Workspace。
如果判断匹配,则向本地管理接口发送带 Admin Token 的 /admin/shutdown;如果请求失败或不能确认健康,代码还有使用 Runtime State PID 发送 SIGTERM 的兜底路径。
/admin/shutdown 首先返回 shuttingDown: true,随后安排执行真正的异步关闭。Bridge 的 shutdown() 会停止 Tunnel、关闭 HTTP Server 并清理对应 Workspace Runtime State。源码确认:server.ts
重要边界:收到成功 HTTP 响应,不等于已独立确认 OS 进程完全退出;而兜底使用记录中的 PID 时,如果状态文件已经过期,还需要考虑 PID 被复用的身份风险。不能把这些潜在窗口写成已成功利用的漏洞。
9. Doctor:诊断成功和环境全部通过是两回事
c2c doctor 的定位是诊断并在条件满足时尝试修复,它不是简单的 ping 包装。源码确认:src/cli/index.ts
9.1 典型诊断层次
| 诊断域 | 检查什么 | 应如何理解 |
|---|---|---|
| Node | Node.js 版本 | 运行条件是否满足 |
| Sandbox | C2C 状态目录是否在 Codex 可写根列表 | Codex 侧的配置,不等于 Windows 文件系统权限 |
| Workspace | 工程根是否有效 | 不代表 Bridge 已启动 |
| Bridge | healthy / stopped / unknown | 决定复用、尝试修复或拒绝重启 |
| MCP | 无 Bearer 的 /mcp 请求是否返回 401 | 验证拒绝未授权请求的基础路径 |
| OAuth | 与基础 401 认证检查相结合 | 不等于完成全部 OAuth 流程 |
| Tunnel | 本地模式或既有公网连接的状态 | 仅在有相应历史/配置时可能涉及恢复 |
这里有个容易被误读的成功信号:HTTP 401 有时恰好是 Doctor 期待的正确结果。因为它表示未授权请求没有直接进入 MCP 工具调用。它不能证明 PKCE、Token 刷新和真实工具执行全部通过。
9.2 --no-fix 和默认 Doctor 的区别
| 情形 | doctor --no-fix | doctor |
|---|---|---|
| Sandbox 未在白名单 | 只报告 | 尝试写入 Codex 配置 |
| Bridge 已停止 | 报告未运行 | 视条件尝试启动 |
Bridge 状态 unknown | 报告 | 仍拒绝盲目启动 |
| 曾建立的 Tunnel 失效 | 诊断为主 | 满足条件时尝试恢复 |
| 诊断存在未通过项 | 可能返回非零退出码 | 修复后重新报告 |
--no-fix 的含义是关闭主动修复分支,不能简单宣传为严格零副作用的全系统审计开关。完整影响还取决于具体版本和后续状态记录逻辑。
9.3 Doctor 的退出码是为自动化准备的
例如 Doctor 可以正常输出结构化 JSON,但 sandbox.ok=false、bridge.ok=false,最终退出码仍为 1。这不代表 JSON 无效或程序崩溃,而是说明诊断成功完成,环境尚不满足整体通过标准。
本章测试中,--no-fix 的报告显示 Sandbox 未授权、Bridge 未运行、修复记录为空;开启修复后,修复记录包含 Sandbox 白名单更新和 Bridge 启动,最终退出码变为 0。[实测]
9.4 Sandbox 白名单究竟允许什么?
Doctor 报告的 Sandbox 白名单通常是 Codex 的 config.toml 中对 C2C 状态目录的可写允许范围,不是说每个测试 Workspace 必须加入 Windows 安全白名单。源码:sandbox-allow.ts
直接在本机 PowerShell 启动 Bridge,即使 Doctor 的 sandbox.ok=false,服务也可能正常运行;如果改由受限的 Codex 执行环境管理状态文件,则需要正确配置允许范围。将它们混为一谈,会造成错误诊断。
10. 一组可复现的正常生命周期实验
首先从正常路径入手,避免一上来就制造并发故障。以下是公开教学命令,使用临时目录,不要求 ChatGPT 账户或 Cloudflare Tunnel。
# 在已克隆并构建的 C2C 仓库根目录
$w = Join-Path $env:TEMP "c2c-ch08-public-lab"
New-Item -ItemType Directory -Path $w -Force | Out-Null
# 先观察服务状态
node .\bin\c2c.js status -w $w --json
node .\bin\c2c.js doctor -w $w --no-fix --json
# 仅启动本地 Bridge,不请求 Tunnel
node .\bin\c2c.js start -w $w --json
node .\bin\c2c.js status -w $w --json
# 第二次启动:观察 PID、Port、startedAt 是否复用
node .\bin\c2c.js start -w $w --json
node .\bin\c2c.js status -w $w --json
# 正常停止
node .\bin\c2c.js stop -w $w
node .\bin\c2c.js status -w $w --json
这些命令只应该针对新的隔离 Workspace。
doctor --no-fix不进行主动修复,但仍应审查目标环境;doctor默认模式会产生真实配置变化,因此不要无准备地在日常工程中运行。
10.1 一次已完成的正常实验结果
[实测] 在 Windows + Node.js v24.21.0 + Bridge 0.1.3 环境中,观察到:
| 操作 | 证据 | 结论 |
|---|---|---|
首次 start | 返回 ok=true,status.running=true | 启动成功 |
再次 start | PID、端口、startedAt 均未改变 | 顺序复用成功 |
doctor --no-fix | 未启动停止中的 Bridge,repairs=[] | 诊断路径正常 |
隔离 doctor | 自动补充 Sandbox 白名单,启动 Bridge,退出码 0 | 指定修复路径通过 |
正常 stop | 服务不再运行,原 PID 消失,端口不再监听 | 正常关闭通过 |
| 状态清理 | 指定的 Runtime JSON 已不存在 | 这次正常关闭清理成功 |
这只是正常路径的通过证据,并不能推出并发、断电和 PID 复用风险已经消失。
11. 两个真实源码故障实验:Unknown 保护与端口回退
11.1 Stale / Unknown:要学会区分“死亡”和“失联”
[实测] 使用原仓库编译后的 findBridgeObservation()、ensureBridge(),配合仅监听 127.0.0.1 并返回 503 的测试服务,分别模拟:
场景 A:HTTP 探测失败 + PID 不存在
→ stopped / pid_missing
场景 B:HTTP 探测失败 + PID 存在
→ unknown / probe_failed
场景 C:在 unknown 时调用 ensureBridge()
→ 拒绝创建第二个实例
实际断言结果:
A: stopped pid_missing
B: unknown probe_failed
C: ensureBridge refused duplicate startup
RESULT: PASS
这是原项目真实构建模块的定向测试,不是仅在教学模拟代码中重新实现了同样的算法。但它只覆盖了所构造的路径,不能证明所有异常和所有系统都得到相同结果。
11.2 EADDRINUSE:首选端口被占用怎么办?
src/bridge/server.ts 的 listen() 在首选端口发生 EADDRINUSE 时,会尝试监听端口 0,由操作系统分配一个可用端口。源码确认:listen()
[实测] 使用一个隔离的占位 HTTP Server 占用端口,再调用真实 startBridge()。结果:
原端口:已被测试服务占用
Bridge:改用另一可用端口
Fallback: PASS
Health: PASS
Original listener: PASS
RESULT: PASS
注意,这项机制不应被理解为固定“端口加一”。0 表示 OS 自行分配,可能是任何可用端口。
端口回退解决网络端口冲突,却并不解决同一 Workspace 多个进程同时启动的问题。
12. 本章最重要的发现:两个 CLI 并发启动同一个 Workspace
前面全部是正常路径或特定异常分支的验证。下面的结果尤其值得重视,因为它并不是只有理论上的竞态分析。
12.1 预期是什么?
理想情况下,同一 Workspace 的两个管理者几乎同时执行 c2c start,仍应只出现一个被管理的 Bridge 实例;第二个请求要么复用它,要么等待第一个启动完成。
当前 ensureBridge() 的顺序则是:
读取当前状态 → 判断 stopped → spawn 子进程
“检查状态”和“占有启动权”之间没有看到一个原子跨进程互斥步骤。因此存在典型的 Check-Then-Act Race(检查—执行竞争)。源码确认:daemon.ts
12.2 实测时发生了什么?
[实测] 在一个新的隔离 Workspace 中,同时启动两条独立 CLI。两条 c2c start 都返回 ok=true,并都拿到相同的 Workspace ID 和最终报告端口。
但 Bridge 结构化日志记录了两次成功监听,时间相隔约 44 毫秒:
T0 Bridge listening on 127.0.0.1:48765
T0+44ms Bridge listening on 127.0.0.1:8342
端口值仅作为本次隔离实验中的现象说明,不是项目固定的双端口分配规则;省略实际工作区路径、原始时间戳和身份凭据。
两个实例共享同一个 Workspace ID。随后的正常 c2c stop 结束了其中一个实例,但另一个监听在原端口的 Node.js serve 进程仍在运行。经过进程命令行标识、进程类型与监听端口交叉验证后,才对该残留进程进行定向清理;最终重复核查,没有发现该实验的残留 Node.js 进程。[实测]
上图是对实验事实的可能时序重建,不是来自高精度事件追踪器的原始因果日志。它展示一种可解释现象的交错,不能直接断言每条 Runtime 写入的绝对顺序。
12.3 为什么两个 CLI 都成功,仍可能创建两个实例?
观察 ensureBridge() 的等待代码:
const runtime = await findLiveBridge(workspace.id);
if (runtime) return { runtime, spawned: true };
这会根据 Workspace ID 查找一个健康的 Runtime,而不验证它是否属于当前 CLI 刚创建的那个子进程。因此并发情况下,两个管理请求可能都以同一份最终健康信息结束,而多个子进程已经启动。源码确认:daemon.ts
同时,Bridge 实际监听端口在 EADDRINUSE 后可能回退,因而两个进程能够分别在不同端口上成功启动。persistRuntime() 按同一个 Workspace ID 写入记录;关闭时 clearRuntimeState(workspace.id) 也是按 Workspace ID 清理。源码确认:server.ts
这些机制共同解释了为什么顺序复用正常,但并发唯一性仍可能失败。
12.4 本次能够证明什么,不能证明什么?
| 结论 | 证据等级 | 判断 |
|---|---|---|
| 同一 Workspace 产生两个成功监听的 Bridge | 实测日志 | 已复现 |
| 两个 CLI 最终均返回成功 | 实测 CLI 输出 | 已确认 |
普通 stop 后有一个实验实例残留 | OS 进程与端口观测 | 已复现 |
| 该问题每次都发生 | 没有重复统计 | 不能断言 |
| 具体 Runtime 写入覆盖的严格事件顺序 | 没有完整事件追踪 | 待验证 |
| 可以远程利用该问题窃取源码 | 没有攻击链验证 | 不能断言 |
| Windows 之外所有系统均会发生 | 未开展跨平台重复测试 | 待验证 |
这应当被描述为特定测试版本和环境下已经复现的进程管理可靠性缺陷,而不是未经验证的安全漏洞或泛化的“所有版本必现”结论。
13. 真实调试还告诉我们:证据采集方法同样会出错
并发实验中有两个非常实用的 Windows / PowerShell 教训。
13.1 Windows 长路径与 8.3 短路径
早期通过完整 Workspace 路径字符串匹配 Win32_Process.CommandLine,得到“匹配进程 0 个”。后来改用实验目录唯一标识匹配,才发现仍存活的 serve 进程。
这是因为 Windows 同一目录可能有短路径和长路径两种表示;字符串不相等,不代表文件系统对象不同。
进程排查应尽量联合判断:
- 进程 ID 和创建时间;
- 可执行程序名与命令行参数;
- 工作区唯一标识;
- 实际监听端口与拥有者 PID;
- 应用日志中的启动事件。
不能仅凭一个 .Contains(fullPath) 就得出“没有孤立进程”的结论。
13.2 Windows 文件共享与 PowerShell 控制流
尝试 ReadAllLines() 读取 Daemon stdout 日志时曾发生 Windows 文件共享错误。后来从独立 bridge.log 读取到两条监听记录,并在显式允许共享读取的方式下读到两条就绪事件。[实测]
此外,交互式 PowerShell 中将 try { ... } catch { ... } finally { ... } 拆成几次独立提交,会导致 finally 不再属于原语法块。若因这种错误跳过清理,文件流句柄可能被保留。
这提示我们:故障注入测试本身也要具备可靠的日志采集和 finally 清理逻辑。
13.3 CLI JSON 与 Windows PowerShell 编码
另一次真实的兼容性现象是:Node.js CLI 直接打印中文 JSON 正常,但通过旧版 PowerShell 管道传给 ConvertFrom-Json 时发生乱码,导致 JSON 解析失败。将当前终端的编码统一为 UTF-8 后,问题消失。[实测]
$utf8 = New-Object System.Text.UTF8Encoding($false)
[Console]::OutputEncoding = $utf8
$OutputEncoding = $utf8
chcp 65001
这些命令只影响当前控制台会话,适用于相应的 PowerShell 编码问题;不要把它们当成通用的 C2C 修复方案。
14. 工程设计评审:怎样真正解决“只能有一个 Bridge”?
问题不是简单地让第二个 CLI “多等一秒”,而是需要对工作区服务所有权建立确定性的约束。[建议]
14.1 推荐的三个必要机制
第一:Workspace 级启动锁或租约。
observe → spawn 之间需要原子的所有权获取。锁的唯一键应该绑定 Workspace ID,且能在持有者崩溃后恢复,不能留下永久死锁。
第二:每次启动都有 Instance ID。
Runtime State、/health、启动日志应携带同一个独立实例标识,而不只是 Workspace ID、PID、Port。这样才能分清“这个工程的服务”和“这一次启动的服务”。
第三:带所有权检查的 Runtime 更新与清理。
旧实例不得覆盖新实例状态;过期实例不能在关闭时删除当前实例的记录。所有写入/删除都应验证 Instance ID 与版本号。
这是一张建议重构方案图,不是原项目当前架构图。真实实现还要考虑操作系统锁语义、启动崩溃、租约过期、迟到进程以及磁盘写入原子性。
14.2 为什么还要考虑停止与清理?
即使通过锁避免重复 spawn,旧进程仍可能在失联后迟到、重新写入 Runtime 或执行 clearRuntimeState()。因此停止路径同样需要校验实例所有权,避免一个旧实例删除新实例的状态文件。
14.3 为什么日志应该有结构化事件?
理想的运行记录至少包含:
{
"event": "bridge.started",
"workspaceId": "workspace-demo",
"instanceId": "instance-example",
"pid": 12345,
"port": 49152,
"stateVersion": 7,
"timestamp": "2026-10-28T08:00:00Z"
}
这里是建议的数据结构,非项目真实输出。引入这样的字段以后,进程实例追溯不必完全依赖临时拼接的自由文本日志。
15. 一张表看懂本章的实证边界
| 能力 | 本章证据 | 当前判断 |
|---|---|---|
| CLI 正常运行 | 用户侧真实 CLI 输出 | 实测通过 |
| Bridge 后台启动 | start + 后续 status | 实测通过 |
| 顺序重复启动的复用 | PID/Port/startedAt 一致 | 实测通过 |
| 正常停止与进程退出 | 状态、进程、监听端口 | 实测通过 |
| Runtime 正常清理 | 目标文件不存在 | 实测通过 |
| Doctor 条件修复 | 修复记录与退出码 0 | 实测通过 |
| 未授权 MCP 入口返回 401 | Doctor 报告 | 仅基础认证链路通过 |
| Stale / Unknown 分支 | 调用真实构建模块断言 | 实测通过 |
| 端口回退 | startBridge() 真实模块 | 实测通过 |
| 跨 CLI 实例唯一性 | 双监听与残留实例 | 已复现不满足 |
| 多轮压力测试、Linux/macOS | 未执行 | 不能作结论 |
| PID 复用、断电一致性 | 未执行 | 保留为测试课题 |
| ChatGPT、Cloudflare 远程链路 | 本章未启用 | 不在实验范围内 |
这些结果说明系统在单请求正常路径上已具有相当实用的运行管理功能,但并发实例唯一性是另一个必须单独验证的可靠性属性。
16. 公开读者如何从零复现本章实验?完整操作指南
这部分不再只给出实验思路,而是给出下载 → 固定源码 → 构建 → 环境预检 → 顺序生命周期 → Doctor → 故障分支 → 证据保存 → 清理的完整流程。读者不需要 ChatGPT 账户,不需要 Cloudflare 账户,也不需要配置公网 Tunnel。
本节配套资源:第八章-CLI-Daemon-Doctor-公开配套实验
16.1 下载包里有什么?
c2c-ch08-public-lab/
├── README.md # 完整说明与 Windows/Linux 用法
├── 安全边界与清理.md # 实验范围、异常退出与清理规范
├── 常见错误与排查.md # 依赖、编码、日志、进程等常见问题
├── 源码映射.md # 实验 → 原仓库函数映射
├── 实验记录模板.md # 可复制的证据记录模板
├── SHA256SUMS.txt # 包内关键文件 SHA-256
├── samples/report-example.json # 仅供了解结构的示例,非真实 PASS 报告
├── scripts/
│ ├── verify-package.mjs # 校验包内文件
│ ├── preflight.mjs # Git、Node、构建产物预检
│ ├── lifecycle-doctor.mjs # 隔离启动、复用、诊断、停止
│ ├── observe-stale-unknown.mjs# 调用原模块验证三态与拒绝启动
│ ├── port-fallback.mjs # 调用原模块验证 EADDRINUSE 回退
│ └── lab-utils.mjs # 隔离环境与报告辅助方法
└── tests/lab-utils.test.mjs # 包自身的基础测试
这些脚本是教学实验工具,并不包含开源项目源码副本。 运行测试时会引用你克隆的原项目 dist/ 构建产物。其中生命周期实验会真的启动本机测试 Bridge,但只使用新建临时目录和回环网络;Doctor 的主动修复必须显式选择。整个资源包不包含自动并发竞争、任意 PID 强制终止或公网开放操作。
16.2 第一步:克隆原项目,记录完整 Git Commit
先安装 Git、Node.js 和 Corepack,并使用自己有权限的测试环境。下例以 Windows PowerShell 为主。
# 尚未克隆时执行;已有原仓库可直接 cd
git clone https://github.com/XiaoDuoYa/codex-with-chatgpt.git
cd .\codex-with-chatgpt
node --version
corepack --version
git rev-parse HEAD
git status --short
git rev-parse HEAD 的完整 SHA 是复现时最重要的版本依据。本文此前的实际 Windows 实验使用 Bridge 0.1.3、Node.js v24.21.0,但没有保存当时仓库的完整 commit SHA;因此不能保证你现在克隆的 main 与当时行为完全一致。执行结果不同,应先比较版本,不要直接断言实验脚本错误或项目功能退化。
16.3 第二步:安装依赖并构建
仍然在原仓库根目录:
corepack pnpm install --frozen-lockfile
corepack pnpm typecheck
corepack pnpm test
corepack pnpm build
# 确认实验依赖的实际构建产物存在
Test-Path .\dist\bridge\runtime.js
Test-Path .\dist\process\daemon.js
Test-Path .\dist\bridge\server.js
最后三个命令应返回 True。若构建失败,应先处理依赖与 TypeScript 问题,而不是删除锁文件或忽略失败后继续实验。
常见问题:直接执行
node .\bin\c2c.js时出现Cannot find package 'tsx',一般说明依赖未安装完整,且 CLI 回退到了 TypeScript 开发入口。先按仓库要求安装、构建,再执行实验。
16.4 第三步:解压资源并验证文件完整性
假设 ZIP 已下载到个人的 Downloads 文件夹。用下列命令新建随机解压目录,避免覆盖已有文件:
$zip = Join-Path $HOME 'Downloads\第八章-CLI-Daemon-Doctor-公开配套实验-v2.zip'
$unpack = Join-Path $env:TEMP ("c2c-ch08-reader-" + [guid]::NewGuid().ToString('N'))
Expand-Archive -LiteralPath $zip -DestinationPath $unpack
$lab = Join-Path $unpack 'c2c-ch08-public-lab'
# 当前所在目录仍应为刚刚克隆的 C2C 原仓库根目录
$env:C2C_REPO_ROOT = (Get-Location).Path
node (Join-Path $lab 'scripts\verify-package.mjs')
node (Join-Path $lab 'scripts\preflight.mjs')
预期出现 PACKAGE CHECK: PASS 和 PRECHECK: PASS。第一个验证配套文件没有意外变更,第二个检查 Git、Node 版本与所需构建模块;两者都不是 Bridge 功能测试通过的证明。
实验包的自检还可以单独运行:
node --test (Join-Path $lab 'tests\lab-utils.test.mjs')
16.5 第四步:验证 CLI → Bridge 的正常生命周期
这是读者最应该首先完成的可运行实验。
node (Join-Path $lab 'scripts\lifecycle-doctor.mjs')
脚本会自动建立唯一临时 Workspace、独立 CODEX_HOME 和独立 C2C_STATE_DIR;顺序执行:
初始 status: stopped
↓
第一次 start:新建测试 Bridge
↓
再次 start:比较 PID、Port、startedAt
↓
doctor --no-fix:检查 Bridge / MCP / OAuth 基础可用性
↓
stop:正常关闭
↓
状态与原 PID 核查
↓
保存脱敏摘要 / 确认安全后清理临时目录
正常情况下应看到 RESULT: PASS。本实验断言顺序重复启动复用同一实例,并检查停止后原 PID 不在;这与两个 CLI 的并发启动不是同一种测试。
如果出现 RESULT: FAIL,脚本会尽可能通过原 CLI 正常停止测试实例。如果无法确认进程已经退出,脚本会保留临时目录,并在终端提示路径。此时不要删除该目录,更不要根据过时 PID 盲目强制终止进程。
16.6 第五步:显式验证 Doctor 自动修复(可选)
默认脚本仅使用 doctor --no-fix。如果希望验证 Doctor 从“缺少白名单/Bridge 未启动”到“修复后健康”的完整过程,可以明确选择:
node (Join-Path $lab 'scripts\lifecycle-doctor.mjs') --with-doctor-fix
该模式只修改本次脚本刚刚创建的临时 CODEX_HOME 配置,并使用新建的 C2C_STATE_DIR;不会以用户日常 Codex 配置为目标。它仍会启动一个临时本地 Bridge,并在末尾执行正常停止与确认。
预期可看到 isolated Doctor repair: PASS。注意 doctor --no-fix 诊断报告中存在 ok:false 时,Doctor 自身可能返回退出码 1;这是诊断状态,不应与 JSON 语法错误混为一谈。
16.7 第六步:调用原仓库模块进行定向故障测试
A. Stale / Unknown 与拒绝盲目重启
node (Join-Path $lab 'scripts\observe-stale-unknown.mjs')
此脚本使用受控 127.0.0.1 HTTP 503 服务,使健康检查失败;分别使用候选不存在 PID 和当前测试进程 PID,调用原仓库的 findBridgeObservation() 和 ensureBridge()。典型预期:
Stale: stopped pid_missing
Unknown: unknown probe_failed
Unknown startup refusal: PASS
RESULT: PASS
pid_missing 使用了较大 PID 作为测试候选值。假如目标系统恰好存在该 PID,断言可能失败;应如实记录,不可把失败修改成 PASS。此脚本不会发出真正的进程终止信号,也不会在 stopped 情况下调用可能启动新 Bridge 的 ensureBridge()。
B. EADDRINUSE 端口冲突回退
node (Join-Path $lab 'scripts\port-fallback.mjs')
脚本先申请一个操作系统随机分配的回环端口,并由测试 HTTP 服务占用,然后要求真实 startBridge() 优先监听该端口。预期 Bridge 回退到另一个空闲端口,且两个 HTTP 服务仍然正常:
Fallback: PASS
Health: PASS
Original listener: PASS
RESULT: PASS
端口值每次都可能不同。即使观察到连续的两个端口,也不代表程序实现了“端口 +1”算法;回退机制使用的是端口 0,由操作系统分配。
16.8 第七步:保存结果、核查清理并恢复当前环境
配套脚本将不含原始 Token 的摘要写在解压目录下的 results/ 文件夹,可用于填写 实验记录模板.md:
Get-ChildItem (Join-Path $lab 'results') -Filter '*.json'
# 删除本教程在当前 PowerShell 中额外设置的变量
Remove-Item Env:C2C_REPO_ROOT -ErrorAction SilentlyContinue
如果生命周期脚本报告清理成功,应该已经停止自己创建的 Bridge 并删除临时 Workspace;若报告保留临时目录,则应先核实进程身份与监听端口,再选择人工处理。不要对任意 node.exe 使用批量终止命令。
发布实验结果时建议至少记录:完整 Git SHA、Node 版本、OS、命令退出码、关键断言、真实 PASS/FAIL,以及是否存在残留进程。公开截图需移除个人路径、Runtime adminToken、配对码与未审核日志。
16.9 Windows 编码与交互式 Shell 常见问题
Windows PowerShell 5.1 在处理包含中文的外部程序输出时,可能发生控制台显示正常但 | ConvertFrom-Json 后乱码的问题。可以先在当前终端会话统一 UTF-8:
$utf8 = New-Object System.Text.UTF8Encoding($false)
[Console]::OutputEncoding = $utf8
$OutputEncoding = $utf8
chcp 65001
另一个常见错误是将 if {} 和 else {}、或 try/catch 与 finally 拆成多次独立提交。它们必须作为同一个完整语法块执行。配套包优先使用完整 .mjs 脚本承载 finally 清理,避免依赖分段粘贴。
16.10 为什么公开包不提供“一键并发双启动”脚本?
本章已有一次真实隔离复现证明:同 Workspace 的两个 CLI 并发启动,可能生成两个都成功监听的 Bridge,而普通 c2c stop 只关闭其中之一。此类实验可能留下不再受当前 Runtime State 管理的进程。对普通读者来说,提供一份一键并发启动加批量强杀的脚本,风险大于教学收益。
因此,公开资源保留了复现思路、源码位置、日志证据分析方法和必要的安全条件,不将高风险竞争实验包装成开箱即用的一键命令。希望进一步研究并发唯一性的读者,应在可恢复的独立虚拟机中,使用带实例身份与进程跟踪的专门故障注入工具。
学习结论的边界: 顺序生命周期 PASS、Doctor PASS、Unknown 分支 PASS、端口回退 PASS,只能支持对应被测性质,绝不能推导跨进程互斥或生产可靠性已经得到保证。此外,公开实验包没有重新完成所有读者系统的集成测试,结果必须以各自的原仓库版本和实际输出为准。
17. 哪些风险应该进入下一轮工程验证?
本章虽已得到一个真实并发反例,但还不等于完成了系统性可靠性评测。下一阶段值得保留:
| 后续测试 | 期望得到的证据 |
|---|---|
| 同 Workspace N 轮双启动 | 实例数量、触发率和事件顺序 |
| 不同 Workspace 同时运行 | 彼此 Runtime、Token、端口完全隔离 |
persistRuntime() 并发写 | 记录冲突、覆盖或损坏情况 |
| 旧实例延迟退出 | 是否误删新实例 Runtime |
| 健康请求超时、PID 存活 | 是否始终保守拒绝重启 |
| 健康启动临界超时 | 是否残留迟到完成的进程 |
| PID 被复用 | stop 兜底能否阻止误操作 |
| Windows / Linux / macOS | 进程句柄、权限和信号语义差异 |
其中有些应通过单元/契约测试解决,有些需要隔离故障注入,还有些必须结合操作系统进程机制开展。每一项都应固定版本、记录原始日志,避免将概率性时序问题简化成单次测试的“通过/不通过”。
18. 这一章的技术思想与结论
回到开头的问题:Agent 系统怎样做到可靠启动和自动修复?
本次源码分析和实验给出了一个分层答案:
第一,CLI 和常驻服务必须分离。 CLI 负责管理动作,后台进程承担长期服务。
第二,Runtime State 只是线索,不能代替健康检查。 PID、端口、健康响应以及 Workspace 身份都需要分层观察。
第三,状态不确定时应允许拒绝自动修复。 unknown 比一个草率的 false 更有工程意义。
第四,端口回退与实例唯一性完全不同。 解决了 EADDRINUSE,不意味着解决并发启动。
第五,判断可靠性必须看真实运行证据。 本章已经复现同 Workspace 多 Bridge 与残留进程问题,证明“顺序重复启动通过”不能替代“跨进程唯一性验证”。
如果把这条经验推广到任何 Agent Orchestrator、MCP Server 或后台工具框架,那么最值得记住的一句话是:
自动恢复系统不仅要知道什么时候可以启动,还必须能证明“谁拥有启动权、现在运行的是哪个实例、谁有权更新或删除它的状态”。
下一章将继续研究配置、自动更新和跨平台部署:当源码、依赖、Skill、状态文件和运行平台发生变化时,怎样保持系统可复现、可回滚、可验证?
参考资料
原项目与源码(均直接引用公开仓库):
- XiaoDuoYa/codex-with-chatgpt
bin/c2c.js:CLI 启动器src/cli/index.ts:start/status/doctor 等命令src/process/daemon.ts:ensureBridge/stopBridgesrc/bridge/runtime.ts:Runtime State 与三态观测src/bridge/server.ts:HTTP、端口回退、Admin Shutdownsrc/config/paths.ts:状态目录与安全文件 I/Osrc/config/sandbox-allow.ts:Codex Sandbox 白名单src/logger/index.ts:日志处理
延伸知识:
版权与引用:文章为独立源码研习和实验分析。项目名称与少量必要代码片段用于说明技术机制;如引用或改编原项目代码,请遵循相应仓库许可证及保留原始来源。本文不代表原项目或 OpenAI 对全部部署场景的安全保证。
系列导航:参见同一系列的「导读」及其他章节。
ChatGPT 负责思考,Codex 负责干活?从源码读懂 Codex with ChatGPT(系列导读)
【Codex with ChatGPT 源码学习①】整体架构:为什么要分离控制平面与数据平面?
【Codex with ChatGPT 源码学习②】694 行 SKILL.md:Markdown 怎么指挥 Codex 干活?
【Codex with ChatGPT 源码学习③】iab、DOM、Playwright:Codex 如何操作 ChatGPT 网页?
【Codex with ChatGPT 源码学习④】深入 MCP Bridge:从 HTTP 请求到安全读取源码的完整链路
【Codex with ChatGPT 源码学习⑤】深入 OAuth 2.1、PKCE 与 Cloudflare Tunnel:ChatGPT 如何安全连接本地 MCP?
【Codex with ChatGPT 源码学习⑥】多 Agent 任务状态机深度解析:Task ID、Checkpoint、执行证据与崩溃恢复
【Codex with ChatGPT 源码学习⑦】安全与可靠性审计:OAuth、MCP、Prompt Injection 与崩溃恢复
【Codex with ChatGPT 源码学习⑧】深入 CLI、Daemon 与 Doctor:从进程自愈到并发启动的真实故障
【Codex with ChatGPT 源码学习⑨】配置、自动更新与跨平台部署:如何实现可验证、可回滚的 Agent 系统?
本文深入分析Codex with ChatGPT开源项目中CLI、Daemon与Doctor模块的运行时管理机制,聚焦进程生命周期、三态健康检查、端口回退、Runtime State局限性及并发启动竞态问题。通过Windows隔离实验复现了同一Workspace下双CLI并发启动导致两个Bridge实例并存且残留的故障,揭示了Check-Then-Act竞争缺陷。强调实例唯一性需依赖原子锁、Instance ID和所有权校验,而非仅靠Workspace ID或健康探测。

355

被折叠的 条评论
为什么被折叠?



