故障档案

端口在听。健康检查超时。日志零字节。

隐藏的 conhost 控制台缓冲满后阻塞 JS 主线程,导致网关端口监听但永不响应。

三个事实同时成立。端口在听。健康检查超时。日志零字节。

一个进程不会同时"运行中"和"死锁",但这个 gateway 就是。21 个线程在等待,1 个在运行,却一个字都没写到日志里,对 HTTP 请求也一点不理。内核看得到绑定的 TCP 端口,应用层就是没法响应。

这个故障的反直觉之处在于,这三件事加在一起,指向一个完全不同的方向。不是网络、不是密钥、不是配置。是一个被隐藏的 Windows 控制台窗口,缓冲区满了。

现象

客户报告无法收到模型回复。SSH 进入客户机后观察到:

  • node gateway (PID 7928) 已运行 12 分钟以上,监听端口 18789。
  • 直接探测网关的 /health 端点,请求在 5 秒超时前没有任何响应。
  • 查看日志文件 data\openclaw\logs\gateway.log:大小 0 字节,自启动起一行内容都没写。
  • 进程线程状态:CPU 时间继续涨(825 秒),21 个线程里 1 个 Running,20 个在 Wait。
  • 上游 provider 服务测试通过:直连 https://<中转站>/v1/models 返回 200,API key 有效(有效期至 2026-08-08);/v1/chat/completions 直调成功。

最诡异的地方是,guardian 守护进程的日志显示:「检测到 Gateway 进程 (PID 7928):端口 18789 已监听但 /health 暂未就绪,避免误杀并采纳该进程」。换句话说,操作系统确实看到了监听的端口,但 gateway 内部完全没有响应。

重启后问题立即复现。同一份新 cache 中每次启动都 100% 触发这个故障。

排查过程

排除上游和配置问题

第一步检查的是上游连接性。使用 PowerShell 直接测试上游服务:

Invoke-WebRequest https://<中转站>/v1/models `
  -Headers @{Authorization='Bearer <API key>'}

响应正常,模型列表包含预期的 gpt-5.5。再测 /v1/chat/completions 端点:

Invoke-WebRequest https://<中转站>/v1/chat/completions -Method POST `
  -Body '{"model":"gpt-5.5","input":"ping"}' -ContentType 'application/json'

返回 200,响应体 "pong"。这说明网络、DNS、API key 全部正常。

检查网关本身的启动状态

查看进程和线程状态:

Get-Process -Id 7928 | Format-List CPU,WS,Threads,StartTime
(Get-Process -Id 7928).Threads | Group-Object ThreadState | ft Count,Name

输出显示:1 个线程状态为 Running,20 个为 Wait(在等什么?)。CPU 时间还在累积(825 秒),说明进程没有真的挂死,但主线程肯定陷入了某种阻塞。

验证端口监听:

Get-NetTCPConnection -LocalPort 18789 -State Listen

确认 owner process ID 是 7928,TCP 三次握手成功,连接能建立。但尝试 HTTP 请求:

Invoke-WebRequest -UseBasicParsing http://127.0.0.1:18789/health -TimeoutSec 5

没有任何响应,直到 5 秒超时。

查看日志确认启动没完成

检查日志文件大小和修改时间:

Get-Item "$env:LOCALAPPDATA\Microsoft\WakouAI\Runtime\<cache>\data\openclaw\logs\gateway.log" |
  Select-Object Length,LastWriteTime

Length: 0 bytes。这意味着 file logger 从未接管控制台输出。正常启动的 gateway.log 开头应该是一系列启动消息:"Hidden-start Gateway on Windows"、"loading configuration"、"force: no listeners on port 18789"、"resolving authentication" 等。这里什么都没有。

排查旧版本 cache

同一客户的旧 cache(OTA 之前 prewarm 出来的那份)能正常启动。查看那次启动的日志时间戳是 20:18,启动后网关响应正常。这说明问题不在配置或上游,而在新版本的某个变更。

对比启动脚本的两个版本

比对新旧 start-local-cache.ps1 的差异。旧版本使用的是 Rust launcher 里的 stage4_spawn fallback,显式设置:

command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());

新版本(v0.14.0)改用 PowerShell 脚本启动 gateway,对应代码在 start-local-cache.ps1:1194

$gatewayStarter = Start-Process -FilePath "cmd.exe" `
    -ArgumentList @("/d", "/c", "`"$gatewayStart`" gateway start") `
    -WorkingDirectory $cacheRoot `
    -WindowStyle Hidden `
    -PassThru

注意这里没有 -RedirectStandardOutput-RedirectStandardError 参数。

根因

Windows 下 PowerShell 的 Start-Process -WindowStyle Hidden -PassThru 在省略重定向参数时的行为是这样的:

  1. 为子进程单独创建一个新的 conhost(控制台宿主)窗口,窗口隐藏。
  2. 子进程的 stdout 和 stderr 自动绑定到这个隐藏的 conhost。
  3. 关键:没有任何用户态进程去消费这个 conhost 上的输出。窗口隐藏,父 PowerShell 进程也不接管它的句柄。

conhost 的屏幕缓冲(screen buffer)有一个硬限制,约 64 KB。当输出累积满了这个缓冲后,下一次 WriteFile 系统调用会同步阻塞调用线程,直到缓冲被消费——但这永远不会发生。

OpenClaw node gateway 在 file logger 接管之前会同步输出多条启动消息。这些消息包括:

Hidden-start Gateway on Windows
loading configuration
force: no listeners on port 18789
resolving authentication
starting
starting HTTP server
canvas mounted
MCP listening
ready

这一坨消息加起来超过 64 KB。当 JavaScript 主线程执行第 N 条 console.log() 调用时,底层的 WriteFile() 调用卡住了,线程永不返回。主线程死锁,file logger 永远没有机会初始化和接管输出。因此 gateway.log 永远是 0 字节。

但是 TCP 的 bind() 是在内核态完成的,不依赖 JavaScript 主线程的运行。所以端口确实被成功监听了——只是后面没人能处理 HTTP 请求。三个矛盾现象由同一个阻塞点产生:

  • 端口监听成功 ← 内核 bind() 早于主线程阻塞
  • HTTP 请求超时 ← 主线程卡死,无法处理请求
  • gateway.log 为 0 字节 ← file logger 没来得及初始化

修复

修复方案已在 v0.14.1 落地,改动位置 start-local-cache.ps1:1192-1212。思路是把 stdio 重定向到一个日志文件,而不是让它悬挂在隐藏的 conhost 上。

新的代码:

$gatewayLogDir = Join-Path $env:OPENCLAW_HOME "logs"
if (-not (Test-Path -LiteralPath $gatewayLogDir)) {
    New-Item -ItemType Directory -Force -Path $gatewayLogDir | Out-Null
}
$gatewayStdioLog = Join-Path $gatewayLogDir "gateway-stdio.log"
$gatewayStarter = Start-Process -FilePath "cmd.exe" `
    -ArgumentList @("/d", "/c", "`"$gatewayStart`" gateway start > `"$gatewayStdioLog`" 2>&1") `
    -WorkingDirectory $cacheRoot `
    -WindowStyle Hidden `
    -PassThru

关键是在 cmd 的命令行里加上 > "$gatewayStdioLog" 2>&1,这会在 cmd 进程内部把所有输出(stdout 和 stderr)重定向到文件。缓冲不再绑到 conhost,写操作也变成非阻塞的文件写入。

为什么不用 PowerShell 的 -RedirectStandardOutput

PowerShell 5.1 在 -WindowStyle Hidden-RedirectStandardOutput 同时使用时存在多个已知的边界 bug,表现因 Windows 版本和 PowerShell 版本而异,不可靠。cmd 的 >2>&1 是从 DOS 时代就有的内核级文件描述符复制操作,跨所有 Windows 版本绝对稳定。

为什么输出到文件而不是丢弃

如果用 >nul 2>&1 丢弃所有输出,启动故障时就看不到任何诊断信息。保留到 gateway-stdio.log 的好处是下次排障或复现类似问题时,可以直接查看启动阶段的原始输出,加快诊断。特别是在 file logger 接管前的那一段初始化过程。

防回归

构建时检查

scripts/check-portable-s0.mjs 中新增一段正则检查,扫描 start-local-cache.ps1 里所有形如 gateway start 的启动命令。如果找到这样的启动而没有 > ... 2>&1-RedirectStandardOutput/-RedirectStandardError,构建直接失败并打印错误信息指向本文档。

验证方式:

  • 把启动行改回原来的无重定向写法 → node scripts/check-portable-s0.mjs 返回非零退出码,错误信息准确指向问题行。
  • 改成带 > file 2>&1 → 检查通过,退出码 0。

同类隐患排查

在同一文件 start-local-cache.ps1 里还有另外两个 Start-Process 调用:

  • 第 185 行:启动 guardian PowerShell 脚本。Guardian 有自己的 file logger,stdout 输出很少,64 KB 缓冲在常规 OTA 周期内不会满。当前保留原样。
  • 第 1231 行:启动 WakouPanel.exe(Tauri GUI 进程)。GUI 进程几乎不写 stdout,同样不会触发缓冲满。当前保留原样。

如果未来观察到 guardian 或 panel 出现类似的「端口监听但不响应」型死锁,按同样的套路加上重定向即可。

已知关联但独立的问题

同一客户在 OTA 后还报告了无法收到模型回复的问题。那个故障的根因在 WebSocket 握手阶段,与 stdio 缓冲无关——具体是 patch-openclaw.ps1 里的 pattern matching 因为上游 OpenClaw 内部形状变化而失败。详见 docs/bug/无模型回复-WS握手.md,两份文档单独保留,不是同一个根因。

星野的头像

星野 XINGYE

一个人维护 AI 平台的工程师。这里记录 63 篇复盘:18 份故障档案、OTA、架构演进与工作流。