【Codex with ChatGPT 源码学习⑧】深入 CLI、Daemon 与 Doctor:从进程自愈到并发启动的真实故障

本文深入分析Codex with ChatGPT开源项目中CLI、Daemon与Doctor模块的运行时管理机制,聚焦进程生命周期、三态健康检查、端口回退、Runtime State局限性及并发启动竞态问题。通过Windows隔离实验复现了同一Workspace下双CLI并发启动导致两个Bridge实例并存且残留的故障,揭示了Check-Then-Act竞争缺陷。强调实例唯一性需依赖原子锁、Instance ID和所有权校验,而非仅靠Workspace ID或健康探测。

【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.js v24.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.jsJS 启动器如何选择已构建 JS 与 TS 开发入口?
src/cli/index.tsstart、serve、status、stop、doctorCLI 如何解析命令、决定动作?
src/process/daemon.tsensureBridge()、stopBridge()如何创建、复用和停止后台进程?
src/bridge/runtime.tsfindBridgeObservation()、probeBridge()Runtime State 与服务健康是什么关系?
src/bridge/server.tsstartBridge()、listen()、/health真正的 Express Bridge 如何启动?

还会用到 src/config/paths.ts 和 src/config/sandbox-allow.ts 来理解状态目录和 Codex 沙箱白名单。

2.1 不要把 CLI 进程和 Bridge 进程混为一谈

healthy

stopped

unknown

用户 / Codex Agent

c2c CLI 进程

status / doctor / start

Runtime State + Health Probe

服务观测结果

复用已有 Bridge

创建后台 serve 子进程

拒绝盲目重启

startBridge:Express + OAuth + MCP

/health + Admin API

图中 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 当作健康证明,原因至少有四个:

  1. 记录的 PID 可能已退出,甚至后来被其他进程复用。
  2. 端口可能已被别的应用重新占用。
  3. 进程可能仍存在,却因为死锁或初始化故障而无法响应 HTTP。
  4. 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 Statestopped / runtime_missing没有可用的记录
健康响应且 Workspace 匹配healthy可以按当前规则复用
返回的是另一 Workspaceunknown / workspace_mismatch不能操作不属于当前工程的服务
HTTP 探测失败、PID 确定不存在stopped / pid_missing已确认记录中的进程不在
HTTP 探测失败、PID 仍存在unknown / probe_failed不能把不可达当作已死亡

否

是

匹配 Workspace

其他 Workspace

无有效响应

missing

present

unknown

读取 Runtime State

有记录吗?

stopped / runtime_missing

调用 HTTP /health

健康响应?

healthy

unknown / workspace_mismatch

进程 PID 探测

PID 状态?

stopped / pid_missing

unknown / probe_failed

unknown / pid_unknown

最重要的设计取舍: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

确认健康

尚未确认

继续等待

失败

ensureBridge

Spawn serve 子进程

startBridge 创建 Express

监听端口并持久化 Runtime State

CLI 轮询 findLiveBridge

在健康等待窗口内?

返回 Runtime;CLI 可退出

子进程退出或超时?

报告错误,检查日志和残留进程

不要把这三个事件混为一谈:进程创建成功 ≠ HTTP 端口监听成功 ≠ 工作区身份和服务健康探测通过。

同样,健康等待超时只能证明 CLI 没在期限内确认成功,不能单凭超时认定刚刚创建的进程已经退出。启动失败后仍应检查是否有残留进程。

7. 日志:后台服务可观测性的第一道保障

Daemon 打开一个追加日志,形式类似:

<state-dir>/logs/bridge-<workspace-id>.out.log

Bridge 内部 Logger 还可能产生独立结构化日志 bridge.log。这是两种不同用途的日志:一种承接 stdout/stderr,另一种记录服务内部事件。源码:daemon.ts · Logger

日志对于后台进程诊断很重要,但它同时引出两个工程要求:

  1. 安全性:日志可能记录错误对象、路径或临时凭据,不能因为文件使用 0600 就认为绝不会泄露;Windows 还需要注意实际 ACL。
  2. 可观测性:仅通过某条日志出现与否,不一定能可靠重建所有启动与退出事件;并发日志共享时尤其如此。

后面的并发实验就展示了:不正确的文件共享模式会导致日志读取失败,而错误的进程命令行字符串匹配还会让人误以为“没有残留进程”。

8. stopBridge():优雅关闭和兜底信号

根据 stopBridge(),关闭流程先读取 Runtime State,尝试确认该端口的健康响应属于目标 Workspace。

如果判断匹配,则向本地管理接口发送带 Admin Token 的 /admin/shutdown;如果请求失败或不能确认健康,代码还有使用 Runtime State PID 发送 SIGTERM 的兜底路径。

否

是

是

是

否

否

c2c stop

读取目标 Workspace 的 Runtime State

记录存在?

报告没有可管理的实例

验证 /health 与 Workspace

当前服务匹配?

POST /admin/shutdown

请求成功?

Bridge 异步关闭 Tunnel 与 HTTP

清理 Runtime State

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 典型诊断层次

诊断域检查什么应如何理解
NodeNode.js 版本运行条件是否满足
SandboxC2C 状态目录是否在 Codex 可写根列表Codex 侧的配置,不等于 Windows 文件系统权限
Workspace工程根是否有效不代表 Bridge 已启动
Bridgehealthy / stopped / unknown决定复用、尝试修复或拒绝重启
MCP无 Bearer 的 /mcp 请求是否返回 401验证拒绝未授权请求的基础路径
OAuth与基础 401 认证检查相结合不等于完成全部 OAuth 流程
Tunnel本地模式或既有公网连接的状态仅在有相应历史/配置时可能涉及恢复

这里有个容易被误读的成功信号:HTTP 401 有时恰好是 Doctor 期待的正确结果。因为它表示未授权请求没有直接进入 MCP 工具调用。它不能证明 PKCE、Token 刷新和真实工具执行全部通过。

9.2 --no-fix 和默认 Doctor 的区别

情形doctor --no-fixdoctor
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。[实测]

healthy

stopped 且 fix 关闭

stopped 且 fix 开启

unknown

Doctor

检查 Node / Sandbox / Workspace

Bridge 三态观测

状态

继续检查 MCP / OAuth

报告未运行

尝试 ensureBridge

不自动重启,报告不确定

按历史配置检查 Tunnel

输出 report / repairs / exitCode

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启动成功
再次 startPID、端口、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 进程。[实测]

OS / Bridge Runtime State CLI B CLI A OS / Bridge Runtime State CLI B CLI A 两个 CLI 最终都取得健康服务信息 正常 stop 后,一个 Bridge 可能仍存活 观测 Workspace 状态 stopped 观测 Workspace 状态 stopped spawn Bridge A spawn Bridge B Bridge A 写入 Runtime (port 48765) Bridge B 写入 Runtime (port 8342)

上图是对实验事实的可能时序重建,不是来自高精度事件追踪器的原始因果日志。它展示一种可解释现象的交错,不能直接断言每条 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 与版本号。

否

是

healthy 且身份匹配

unknown

stopped

CLI start

原子获取 Workspace 启动锁

取得所有权?

等待并核对已有 Instance ID

观测当前 Runtime + OS + Health

状态

复用现有实例

拒绝创建,保留现场

生成新 Instance ID 并 spawn

检查启动实例的 PID / Health / Port

CAS 提交 Runtime State

释放锁并返回唯一实例

这是一张建议重构方案图,不是原项目当前架构图。真实实现还要考虑操作系统锁语义、启动崩溃、租约过期、迟到进程以及磁盘写入原子性。

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 入口返回 401Doctor 报告仅基础认证链路通过
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、状态文件和运行平台发生变化时,怎样保持系统可复现、可回滚、可验证?


参考资料

原项目与源码(均直接引用公开仓库):

延伸知识:

版权与引用:文章为独立源码研习和实验分析。项目名称与少量必要代码片段用于说明技术机制;如引用或改编原项目代码,请遵循相应仓库许可证及保留原始来源。本文不代表原项目或 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 系统?

评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值