三个事实同时成立。端口在听。健康检查超时。日志零字节。
一个进程不会同时"运行中"和"死锁",但这个 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 在省略重定向参数时的行为是这样的:
- 为子进程单独创建一个新的 conhost(控制台宿主)窗口,窗口隐藏。
- 子进程的 stdout 和 stderr 自动绑定到这个隐藏的 conhost。
- 关键:没有任何用户态进程去消费这个 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,两份文档单独保留,不是同一个根因。
■