[{"data":1,"prerenderedAt":2430},["ShallowReactive",2],{"\u002F2026-05-16-fa-011-64kb":3,"\u002F2026-05-16-fa-011-64kb-rel":627},{"id":4,"title":5,"body":6,"column":609,"date":610,"description":12,"extension":611,"hero_image":612,"meta":613,"navigation":614,"path":615,"seo":616,"series_id":617,"severity":612,"stem":618,"summary":619,"tags":620,"__hash__":626},"posts\u002F2026-05-16-FA-011-隐藏控制台64KB缓冲.md","端口在听。健康检查超时。日志零字节。",{"type":7,"value":8,"toc":589},"minimark",[9,13,16,19,23,26,62,65,68,71,75,78,101,111,126,129,132,135,150,153,156,165,168,177,180,183,186,201,204,208,211,214,225,236,243,276,292,295,302,317,328,331,339,354,361,384,387,394,397,453,460,464,481,484,495,498,501,524,527,543,546,556,568,571,574,585],[10,11,12],"p",{},"三个事实同时成立。端口在听。健康检查超时。日志零字节。",[10,14,15],{},"一个进程不会同时\"运行中\"和\"死锁\"，但这个 gateway 就是。21 个线程在等待，1 个在运行，却一个字都没写到日志里，对 HTTP 请求也一点不理。内核看得到绑定的 TCP 端口，应用层就是没法响应。",[10,17,18],{},"这个故障的反直觉之处在于，这三件事加在一起，指向一个完全不同的方向。不是网络、不是密钥、不是配置。是一个被隐藏的 Windows 控制台窗口，缓冲区满了。",[20,21,22],"h2",{"id":22},"现象",[10,24,25],{},"客户报告无法收到模型回复。SSH 进入客户机后观察到：",[27,28,29,33,41,48,51],"ul",{},[30,31,32],"li",{},"node gateway (PID 7928) 已运行 12 分钟以上，监听端口 18789。",[30,34,35,36,40],{},"直接探测网关的 ",[37,38,39],"code",{},"\u002Fhealth"," 端点，请求在 5 秒超时前没有任何响应。",[30,42,43,44,47],{},"查看日志文件 ",[37,45,46],{},"data\\openclaw\\logs\\gateway.log","：大小 0 字节，自启动起一行内容都没写。",[30,49,50],{},"进程线程状态：CPU 时间继续涨（825 秒），21 个线程里 1 个 Running，20 个在 Wait。",[30,52,53,54,57,58,61],{},"上游 provider 服务测试通过：直连 ",[37,55,56],{},"https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fmodels"," 返回 200，API key 有效（有效期至 2026-08-08）；",[37,59,60],{},"\u002Fv1\u002Fchat\u002Fcompletions"," 直调成功。",[10,63,64],{},"最诡异的地方是，guardian 守护进程的日志显示：「检测到 Gateway 进程 (PID 7928)：端口 18789 已监听但 \u002Fhealth 暂未就绪，避免误杀并采纳该进程」。换句话说，操作系统确实看到了监听的端口，但 gateway 内部完全没有响应。",[10,66,67],{},"重启后问题立即复现。同一份新 cache 中每次启动都 100% 触发这个故障。",[20,69,70],{"id":70},"排查过程",[72,73,74],"h3",{"id":74},"排除上游和配置问题",[10,76,77],{},"第一步检查的是上游连接性。使用 PowerShell 直接测试上游服务：",[79,80,85],"pre",{"className":81,"code":82,"language":83,"meta":84,"style":84},"language-powershell shiki shiki-themes github-light github-dark","Invoke-WebRequest https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fmodels `\n  -Headers @{Authorization='Bearer \u003CAPI key>'}\n","powershell","",[37,86,87,95],{"__ignoreMap":84},[88,89,92],"span",{"class":90,"line":91},"line",1,[88,93,94],{},"Invoke-WebRequest https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fmodels `\n",[88,96,98],{"class":90,"line":97},2,[88,99,100],{},"  -Headers @{Authorization='Bearer \u003CAPI key>'}\n",[10,102,103,104,107,108,110],{},"响应正常，模型列表包含预期的 ",[37,105,106],{},"gpt-5.5","。再测 ",[37,109,60],{}," 端点：",[79,112,114],{"className":81,"code":113,"language":83,"meta":84,"style":84},"Invoke-WebRequest https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fchat\u002Fcompletions -Method POST `\n  -Body '{\"model\":\"gpt-5.5\",\"input\":\"ping\"}' -ContentType 'application\u002Fjson'\n",[37,115,116,121],{"__ignoreMap":84},[88,117,118],{"class":90,"line":91},[88,119,120],{},"Invoke-WebRequest https:\u002F\u002F\u003C中转站>\u002Fv1\u002Fchat\u002Fcompletions -Method POST `\n",[88,122,123],{"class":90,"line":97},[88,124,125],{},"  -Body '{\"model\":\"gpt-5.5\",\"input\":\"ping\"}' -ContentType 'application\u002Fjson'\n",[10,127,128],{},"返回 200，响应体 \"pong\"。这说明网络、DNS、API key 全部正常。",[72,130,131],{"id":131},"检查网关本身的启动状态",[10,133,134],{},"查看进程和线程状态：",[79,136,138],{"className":81,"code":137,"language":83,"meta":84,"style":84},"Get-Process -Id 7928 | Format-List CPU,WS,Threads,StartTime\n(Get-Process -Id 7928).Threads | Group-Object ThreadState | ft Count,Name\n",[37,139,140,145],{"__ignoreMap":84},[88,141,142],{"class":90,"line":91},[88,143,144],{},"Get-Process -Id 7928 | Format-List CPU,WS,Threads,StartTime\n",[88,146,147],{"class":90,"line":97},[88,148,149],{},"(Get-Process -Id 7928).Threads | Group-Object ThreadState | ft Count,Name\n",[10,151,152],{},"输出显示：1 个线程状态为 Running，20 个为 Wait（在等什么？）。CPU 时间还在累积（825 秒），说明进程没有真的挂死，但主线程肯定陷入了某种阻塞。",[10,154,155],{},"验证端口监听：",[79,157,159],{"className":81,"code":158,"language":83,"meta":84,"style":84},"Get-NetTCPConnection -LocalPort 18789 -State Listen\n",[37,160,161],{"__ignoreMap":84},[88,162,163],{"class":90,"line":91},[88,164,158],{},[10,166,167],{},"确认 owner process ID 是 7928，TCP 三次握手成功，连接能建立。但尝试 HTTP 请求：",[79,169,171],{"className":81,"code":170,"language":83,"meta":84,"style":84},"Invoke-WebRequest -UseBasicParsing http:\u002F\u002F127.0.0.1:18789\u002Fhealth -TimeoutSec 5\n",[37,172,173],{"__ignoreMap":84},[88,174,175],{"class":90,"line":91},[88,176,170],{},[10,178,179],{},"没有任何响应，直到 5 秒超时。",[72,181,182],{"id":182},"查看日志确认启动没完成",[10,184,185],{},"检查日志文件大小和修改时间：",[79,187,189],{"className":81,"code":188,"language":83,"meta":84,"style":84},"Get-Item \"$env:LOCALAPPDATA\\Microsoft\\WakouAI\\Runtime\\\u003Ccache>\\data\\openclaw\\logs\\gateway.log\" |\n  Select-Object Length,LastWriteTime\n",[37,190,191,196],{"__ignoreMap":84},[88,192,193],{"class":90,"line":91},[88,194,195],{},"Get-Item \"$env:LOCALAPPDATA\\Microsoft\\WakouAI\\Runtime\\\u003Ccache>\\data\\openclaw\\logs\\gateway.log\" |\n",[88,197,198],{"class":90,"line":97},[88,199,200],{},"  Select-Object Length,LastWriteTime\n",[10,202,203],{},"Length: 0 bytes。这意味着 file logger 从未接管控制台输出。正常启动的 gateway.log 开头应该是一系列启动消息：\"Hidden-start Gateway on Windows\"、\"loading configuration\"、\"force: no listeners on port 18789\"、\"resolving authentication\" 等。这里什么都没有。",[72,205,207],{"id":206},"排查旧版本-cache","排查旧版本 cache",[10,209,210],{},"同一客户的旧 cache（OTA 之前 prewarm 出来的那份）能正常启动。查看那次启动的日志时间戳是 20:18，启动后网关响应正常。这说明问题不在配置或上游，而在新版本的某个变更。",[72,212,213],{"id":213},"对比启动脚本的两个版本",[10,215,216,217,220,221,224],{},"比对新旧 ",[37,218,219],{},"start-local-cache.ps1"," 的差异。旧版本使用的是 Rust launcher 里的 ",[37,222,223],{},"stage4_spawn"," fallback，显式设置：",[79,226,230],{"className":227,"code":228,"language":229,"meta":84,"style":84},"language-rust shiki shiki-themes github-light github-dark","command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null());\n","rust",[37,231,232],{"__ignoreMap":84},[88,233,234],{"class":90,"line":91},[88,235,228],{},[10,237,238,239,242],{},"新版本（v0.14.0）改用 PowerShell 脚本启动 gateway，对应代码在 ",[37,240,241],{},"start-local-cache.ps1:1194","：",[79,244,246],{"className":81,"code":245,"language":83,"meta":84,"style":84},"$gatewayStarter = Start-Process -FilePath \"cmd.exe\" `\n    -ArgumentList @(\"\u002Fd\", \"\u002Fc\", \"`\"$gatewayStart`\" gateway start\") `\n    -WorkingDirectory $cacheRoot `\n    -WindowStyle Hidden `\n    -PassThru\n",[37,247,248,253,258,264,270],{"__ignoreMap":84},[88,249,250],{"class":90,"line":91},[88,251,252],{},"$gatewayStarter = Start-Process -FilePath \"cmd.exe\" `\n",[88,254,255],{"class":90,"line":97},[88,256,257],{},"    -ArgumentList @(\"\u002Fd\", \"\u002Fc\", \"`\"$gatewayStart`\" gateway start\") `\n",[88,259,261],{"class":90,"line":260},3,[88,262,263],{},"    -WorkingDirectory $cacheRoot `\n",[88,265,267],{"class":90,"line":266},4,[88,268,269],{},"    -WindowStyle Hidden `\n",[88,271,273],{"class":90,"line":272},5,[88,274,275],{},"    -PassThru\n",[10,277,278,279,283,284,287,288,291],{},"注意这里",[280,281,282],"strong",{},"没有"," ",[37,285,286],{},"-RedirectStandardOutput"," 或 ",[37,289,290],{},"-RedirectStandardError"," 参数。",[20,293,294],{"id":294},"根因",[10,296,297,298,301],{},"Windows 下 PowerShell 的 ",[37,299,300],{},"Start-Process -WindowStyle Hidden -PassThru"," 在省略重定向参数时的行为是这样的：",[303,304,305,308,311],"ol",{},[30,306,307],{},"为子进程单独创建一个新的 conhost（控制台宿主）窗口，窗口隐藏。",[30,309,310],{},"子进程的 stdout 和 stderr 自动绑定到这个隐藏的 conhost。",[30,312,313,316],{},[280,314,315],{},"关键","：没有任何用户态进程去消费这个 conhost 上的输出。窗口隐藏，父 PowerShell 进程也不接管它的句柄。",[10,318,319,320,323,324,327],{},"conhost 的屏幕缓冲（screen buffer）有一个硬限制，约 64 KB。当输出累积满了这个缓冲后，下一次 ",[37,321,322],{},"WriteFile"," 系统调用会",[280,325,326],{},"同步阻塞","调用线程，直到缓冲被消费——但这永远不会发生。",[10,329,330],{},"OpenClaw node gateway 在 file logger 接管之前会同步输出多条启动消息。这些消息包括：",[79,332,337],{"className":333,"code":335,"language":336},[334],"language-text","Hidden-start Gateway on Windows\nloading configuration\nforce: no listeners on port 18789\nresolving authentication\nstarting\nstarting HTTP server\ncanvas mounted\nMCP listening\nready\n","text",[37,338,335],{"__ignoreMap":84},[10,340,341,342,345,346,349,350,353],{},"这一坨消息加起来超过 64 KB。当 JavaScript 主线程执行第 N 条 ",[37,343,344],{},"console.log()"," 调用时，底层的 ",[37,347,348],{},"WriteFile()"," 调用卡住了，线程永不返回。主线程死锁，file logger 永远没有机会初始化和接管输出。因此 ",[37,351,352],{},"gateway.log"," 永远是 0 字节。",[10,355,356,357,360],{},"但是 TCP 的 ",[37,358,359],{},"bind()"," 是在内核态完成的，不依赖 JavaScript 主线程的运行。所以端口确实被成功监听了——只是后面没人能处理 HTTP 请求。三个矛盾现象由同一个阻塞点产生：",[27,362,363,372,378],{},[30,364,365,368,369,371],{},[280,366,367],{},"端口监听成功"," ← 内核 ",[37,370,359],{}," 早于主线程阻塞",[30,373,374,377],{},[280,375,376],{},"HTTP 请求超时"," ← 主线程卡死，无法处理请求",[30,379,380,383],{},[280,381,382],{},"gateway.log 为 0 字节"," ← file logger 没来得及初始化",[20,385,386],{"id":386},"修复",[10,388,389,390,393],{},"修复方案已在 v0.14.1 落地，改动位置 ",[37,391,392],{},"start-local-cache.ps1:1192-1212","。思路是把 stdio 重定向到一个日志文件，而不是让它悬挂在隐藏的 conhost 上。",[10,395,396],{},"新的代码：",[79,398,400],{"className":81,"code":399,"language":83,"meta":84,"style":84},"$gatewayLogDir = Join-Path $env:OPENCLAW_HOME \"logs\"\nif (-not (Test-Path -LiteralPath $gatewayLogDir)) {\n    New-Item -ItemType Directory -Force -Path $gatewayLogDir | Out-Null\n}\n$gatewayStdioLog = Join-Path $gatewayLogDir \"gateway-stdio.log\"\n$gatewayStarter = Start-Process -FilePath \"cmd.exe\" `\n    -ArgumentList @(\"\u002Fd\", \"\u002Fc\", \"`\"$gatewayStart`\" gateway start > `\"$gatewayStdioLog`\" 2>&1\") `\n    -WorkingDirectory $cacheRoot `\n    -WindowStyle Hidden `\n    -PassThru\n",[37,401,402,407,412,417,422,427,432,438,443,448],{"__ignoreMap":84},[88,403,404],{"class":90,"line":91},[88,405,406],{},"$gatewayLogDir = Join-Path $env:OPENCLAW_HOME \"logs\"\n",[88,408,409],{"class":90,"line":97},[88,410,411],{},"if (-not (Test-Path -LiteralPath $gatewayLogDir)) {\n",[88,413,414],{"class":90,"line":260},[88,415,416],{},"    New-Item -ItemType Directory -Force -Path $gatewayLogDir | Out-Null\n",[88,418,419],{"class":90,"line":266},[88,420,421],{},"}\n",[88,423,424],{"class":90,"line":272},[88,425,426],{},"$gatewayStdioLog = Join-Path $gatewayLogDir \"gateway-stdio.log\"\n",[88,428,430],{"class":90,"line":429},6,[88,431,252],{},[88,433,435],{"class":90,"line":434},7,[88,436,437],{},"    -ArgumentList @(\"\u002Fd\", \"\u002Fc\", \"`\"$gatewayStart`\" gateway start > `\"$gatewayStdioLog`\" 2>&1\") `\n",[88,439,441],{"class":90,"line":440},8,[88,442,263],{},[88,444,446],{"class":90,"line":445},9,[88,447,269],{},[88,449,451],{"class":90,"line":450},10,[88,452,275],{},[10,454,455,456,459],{},"关键是在 cmd 的命令行里加上 ",[37,457,458],{},"> \"$gatewayStdioLog\" 2>&1","，这会在 cmd 进程内部把所有输出（stdout 和 stderr）重定向到文件。缓冲不再绑到 conhost，写操作也变成非阻塞的文件写入。",[72,461,463],{"id":462},"为什么不用-powershell-的-redirectstandardoutput","为什么不用 PowerShell 的 -RedirectStandardOutput",[10,465,466,467,470,471,473,474,470,477,480],{},"PowerShell 5.1 在 ",[37,468,469],{},"-WindowStyle Hidden"," 和 ",[37,472,286],{}," 同时使用时存在多个已知的边界 bug，表现因 Windows 版本和 PowerShell 版本而异，不可靠。cmd 的 ",[37,475,476],{},">",[37,478,479],{},"2>&1"," 是从 DOS 时代就有的内核级文件描述符复制操作，跨所有 Windows 版本绝对稳定。",[72,482,483],{"id":483},"为什么输出到文件而不是丢弃",[10,485,486,487,490,491,494],{},"如果用 ",[37,488,489],{},">nul 2>&1"," 丢弃所有输出，启动故障时就看不到任何诊断信息。保留到 ",[37,492,493],{},"gateway-stdio.log"," 的好处是下次排障或复现类似问题时，可以直接查看启动阶段的原始输出，加快诊断。特别是在 file logger 接管前的那一段初始化过程。",[20,496,497],{"id":497},"防回归",[72,499,500],{"id":500},"构建时检查",[10,502,503,504,507,508,510,511,514,515,283,517,287,520,523],{},"在 ",[37,505,506],{},"scripts\u002Fcheck-portable-s0.mjs"," 中新增一段正则检查，扫描 ",[37,509,219],{}," 里所有形如 ",[37,512,513],{},"gateway start"," 的启动命令。如果找到这样的启动而",[280,516,282],{},[37,518,519],{},"> ... 2>&1",[37,521,522],{},"-RedirectStandardOutput\u002F-RedirectStandardError","，构建直接失败并打印错误信息指向本文档。",[10,525,526],{},"验证方式：",[27,528,529,536],{},[30,530,531,532,535],{},"把启动行改回原来的无重定向写法 → ",[37,533,534],{},"node scripts\u002Fcheck-portable-s0.mjs"," 返回非零退出码，错误信息准确指向问题行。",[30,537,538,539,542],{},"改成带 ",[37,540,541],{},"> file 2>&1"," → 检查通过，退出码 0。",[72,544,545],{"id":545},"同类隐患排查",[10,547,548,549,551,552,555],{},"在同一文件 ",[37,550,219],{}," 里还有另外两个 ",[37,553,554],{},"Start-Process"," 调用：",[27,557,558,561],{},[30,559,560],{},"第 185 行：启动 guardian PowerShell 脚本。Guardian 有自己的 file logger，stdout 输出很少，64 KB 缓冲在常规 OTA 周期内不会满。当前保留原样。",[30,562,563,564,567],{},"第 1231 行：启动 ",[37,565,566],{},"WakouPanel.exe","（Tauri GUI 进程）。GUI 进程几乎不写 stdout，同样不会触发缓冲满。当前保留原样。",[10,569,570],{},"如果未来观察到 guardian 或 panel 出现类似的「端口监听但不响应」型死锁，按同样的套路加上重定向即可。",[72,572,573],{"id":573},"已知关联但独立的问题",[10,575,576,577,580,581,584],{},"同一客户在 OTA 后还报告了无法收到模型回复的问题。那个故障的根因在 WebSocket 握手阶段，与 stdio 缓冲无关——具体是 ",[37,578,579],{},"patch-openclaw.ps1"," 里的 pattern matching 因为上游 OpenClaw 内部形状变化而失败。详见 ",[37,582,583],{},"docs\u002Fbug\u002F无模型回复-WS握手.md","，两份文档单独保留，不是同一个根因。",[586,587,588],"style",{},"html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":84,"searchDepth":97,"depth":97,"links":590},[591,592,599,600,604],{"id":22,"depth":97,"text":22},{"id":70,"depth":97,"text":70,"children":593},[594,595,596,597,598],{"id":74,"depth":260,"text":74},{"id":131,"depth":260,"text":131},{"id":182,"depth":260,"text":182},{"id":206,"depth":260,"text":207},{"id":213,"depth":260,"text":213},{"id":294,"depth":97,"text":294},{"id":386,"depth":97,"text":386,"children":601},[602,603],{"id":462,"depth":260,"text":463},{"id":483,"depth":260,"text":483},{"id":497,"depth":97,"text":497,"children":605},[606,607,608],{"id":500,"depth":260,"text":500},{"id":545,"depth":260,"text":545},{"id":573,"depth":260,"text":573},"故障档案","2026-05-16","md",null,{},true,"\u002F2026-05-16-fa-011-64kb",{"title":5,"description":12},"FA-011","2026-05-16-FA-011-隐藏控制台64KB缓冲","隐藏的 conhost 控制台缓冲满后阻塞 JS 主线程，导致网关端口监听但永不响应。",[621,622,623,624,625],"Windows","stdio缓冲","PowerShell","启动脚本","进程阻塞","ur8B1zVCS9FTcFkuj2pPDEqTNiXUMixqv2BxrDTSEoY",[628,1043,1501],{"id":629,"title":630,"body":631,"column":609,"date":1028,"description":635,"extension":611,"hero_image":612,"meta":1029,"navigation":614,"path":1030,"seo":1031,"series_id":1032,"severity":612,"stem":1033,"summary":1034,"tags":1035,"__hash__":1042},"posts\u002F2026-07-23-FA-018-共享存储的subPath陷阱.md","一个 PVC 装所有用户，当时看不出有什么问题",{"type":7,"value":632,"toc":1019},[633,636,639,643,646,649,656,704,707,717,721,727,730,741,744,748,751,754,757,775,778,785,788,803,807,810,813,824,835,838,841,844,912,915,922,925,928,954,957,968,971,974,1013,1016],[10,634,635],{},"平台从 Docker Swarm 迁移到 Kubernetes，存储架构做了一个看似理想的调整：用共享 PVC + subPath 替代原来的\"每用户独占 PVC\"。方案优势显而易见——管理简单（1 个 PVC 对 574 个）、自动扩展、成本低。当时看不出有什么缺点。",[10,637,638],{},"但实施中遇到了四个隐蔽的问题。",[20,640,642],{"id":641},"权限错配bind-型数据的属主陷阱","权限错配：bind 型数据的属主陷阱",[10,644,645],{},"旧系统中，用户数据通过两种方式持久化：bind mount 和 Docker volume。迁移时需要从这两个来源完整导出数据。",[10,647,648],{},"对于 bind mount 型数据，文件属主通常是 10001（或其他固定 UID）。当我登上旧集群的宿主机，以普通用户身份尝试读取这些文件时，只能看到 13 个文件。权限拒绝。同一目录里其实有 4778 个文件，但大多数因为属主权限不匹配而不可见。",[10,650,651,652,655],{},"只有用 sudo 才能完整读到。所以迁移脚本必须用 ",[37,653,654],{},"sudo tar"," 来打包：",[79,657,661],{"className":658,"code":659,"language":660,"meta":84,"style":84},"language-bash shiki shiki-themes github-light github-dark","sudo tar -czf \u002Ftmp\u002Fmig\u002F${uid}\u002Fdata.tgz \\\n  -C \u002Fmnt\u002Fyun-claw\u002Fusers\u002F${uid} .\n","bash",[37,662,663,690],{"__ignoreMap":84},[88,664,665,669,673,677,680,684,687],{"class":90,"line":91},[88,666,668],{"class":667},"sScJk","sudo",[88,670,672],{"class":671},"sZZnC"," tar",[88,674,676],{"class":675},"sj4cs"," -czf",[88,678,679],{"class":671}," \u002Ftmp\u002Fmig\u002F",[88,681,683],{"class":682},"sVt8B","${uid}",[88,685,686],{"class":671},"\u002Fdata.tgz",[88,688,689],{"class":675}," \\\n",[88,691,692,695,698,701],{"class":90,"line":97},[88,693,694],{"class":675},"  -C",[88,696,697],{"class":671}," \u002Fmnt\u002Fyun-claw\u002Fusers\u002F",[88,699,700],{"class":682},"${uid} ",[88,702,703],{"class":671},".\n",[10,705,706],{},"这不是脚本设计的问题，而是 Kubernetes 环境下容器权限与宿主权限映射的一个陷阱。容器内运行的网关进程可能以容器用户身份运行，但宿主上的文件属主是不同的 UID。当两个权限体系接不上，访问就会失败。",[10,708,709,710,713,714,716],{},"新方案中，用户数据通过 subPath 挂到容器的 ",[37,711,712],{},"\u002Fdata"," 目录。如果 subPath 指向的目录是由 Kubernetes 在宿主机上创建的，属主可能是 root 或其他用户，容器内进程（以容器指定的 UID 运行）仍然会遭遇权限问题。这个风险需要在 Dockerfile 和容器启动时明确处理：容器内应该预先创建 ",[37,715,712],{}," 并设置正确的属主，或者使用 securityContext 的 fsGroup 或 runAsUser 来强制权限。",[20,718,720],{"id":719},"subpath-目录创建的时机与属主处理","subPath 目录创建的时机与属主处理",[10,722,723,724],{},"Kubernetes 对 subPath 的处理有个微妙之处：如果挂载的 subPath 目录在宿主机上不存在，kubelet 会自动创建它。但 ",[88,725,726],{},"待补：这个自动创建的目录的属主是什么？是 root 还是其他用户？容器的 securityContext 能否保证挂载后的权限符合预期？",[10,728,729],{},"设计文档中没有明确说明这一点。在实施前，需要验证：",[27,731,732,735,738],{},[30,733,734],{},"首次 subPath 目录不存在时，kubelet 创建它并将其属主设置为谁",[30,736,737],{},"容器的 securityContext（fsGroup、runAsUser）是否能在挂载时生效",[30,739,740],{},"是否应该预先在共享 PVC 上创建所有 subPath 目录并设置好属主，而不是依赖 Kubernetes 的隐式创建",[10,742,743],{},"没有明确的答案，就容易踩坑。一个保险做法是在 destroy 用户账户时清空 subPath 目录，同时在 provision 时让一个 init-container 验证或修复目录属主，确保容器进程有写权限。",[20,745,747],{"id":746},"单-pvc-中的节点级故障域","单 PVC 中的节点级故障域",[10,749,750],{},"这是最严重的问题，也是与 FA-015（节点假 Ready）的关联点。",[10,752,753],{},"当一个节点的 NFS 客户端或到 SFS 的网络链路发生 hang 时，所有依赖共享 PVC 的 Pod 都会受影响。这不是共享 PVC 本身的问题，而是故障隔离的问题。",[10,755,756],{},"具体现象：",[27,758,759,766,769,772],{},[30,760,761,762,765],{},"如果 NFS 挂载使用了硬 mount（",[37,763,764],{},"hard"," 参数是 NFS 的默认值），而网络故障或存储服务中断，NFS 客户端会无限重试",[30,767,768],{},"重试期间，所有试图访问 NFS 的进程都会被阻塞在 I\u002FO 上（D 状态，不可中断）",[30,770,771],{},"如果 kubelet 或 containerd 的某个操作（如创建容器的卷挂载步骤）陷入 I\u002FO 阻塞，整个节点就会冻死",[30,773,774],{},"该节点上的所有实例（无论是否在访问存储）都会因为无法创建或删除 Pod 而故障",[10,776,777],{},"这与独立 PVC 的效果截然不同。旧架构中，每个用户有独立 PVC，意味着如果某个 PVC 对应的存储有问题，只会影响这一个用户。其他用户的 PVC 可能分散在不同的存储卷甚至不同的节点上，相对独立。",[10,779,780,781,784],{},"新架构下，单个共享 PVC 承载所有用户，一旦这个 PVC 对应的网络链路或挂载出问题，",[280,782,783],{},"同节点上所有实例都会被殃及","。如果该节点碰巧堆积了 50 个实例，一次节点级的存储 hang 就会导致 50 个实例集体故障，且外表看起来是\"节点 Ready，但实例卡住\"——kubelet 的心跳正常，Pod 状态却卡在 ContainerCreating 或 Terminating。",[10,786,787],{},"这正是 FA-015 在 2026-06-05 观察到的现象：节点假 Ready，但大量实例网关无响应。防护措施包括：",[27,789,790,797,800],{},[30,791,792,793,796],{},"NFS 挂载参数改为软 mount（",[37,794,795],{},"soft,timeo=100,retrans=3","），让 I\u002FO 超时而不是无限等待",[30,798,799],{},"节点加健康检测，检查 ContainerCreating 堆积数和网关探测失败率，及早发现节点冻死",[30,801,802],{},"实例分散部署，用 topologySpreadConstraints 避免单节点堆积",[20,804,806],{"id":805},"无-per-subpath-硬配额","无 per-subPath 硬配额",[10,808,809],{},"共享存储的架构决定了无法在存储层面按 subPath 限制用户配额。SFS（及 NFS 一般）没有 per-directory 的硬配额功能，只能在整个卷级别限制。",[10,811,812],{},"这意味着：",[27,814,815,818,821],{},[30,816,817],{},"无法阻止某个用户的数据膨胀而挤占其他用户的空间",[30,819,820],{},"无法在存储层面实现\"超额用户的写入失败\"",[30,822,823],{},"只能在应用层进行监控、告警和逻辑控制",[10,825,826,827,830,831,834],{},"新系统的方案是应用层监控：定期 ",[37,828,829],{},"du -sb \u002Fdata"," 扫描每个用户的目录大小，落库到 Instance 表，",[37,832,833],{},"\u002Fstatus"," 接口返回超额标志位。前端据此提示用户\"存储已满\"。这是纯监控，不阻断。如果用户无视提示继续写入，直到共享卷真的满了，才会出现\"所有用户集体写入失败\"的惨淡局面。",[10,836,837],{},"这个限制是方案选择的代价。",[20,839,840],{"id":840},"为什么仍然选了这个方案",[10,842,843],{},"尽管有这些问题，平台仍然采用了共享 PVC + subPath 的方案。原因是对比了替代方案：",[845,846,847,866],"table",{},[848,849,850],"thead",{},[851,852,853,857,860,863],"tr",{},[854,855,856],"th",{},"方案",[854,858,859],{},"优点",[854,861,862],{},"缺点",[854,864,865],{},"故障隔离",[867,868,869,884,898],"tbody",{},[851,870,871,875,878,881],{},[872,873,874],"td",{},"独立 PVC（旧架构）",[872,876,877],{},"天然的每用户隔离；故障域清晰",[872,879,880],{},"管理复杂（574 个 PVC）；扩展性差；成本高",[872,882,883],{},"✓ 最好",[851,885,886,889,892,895],{},[872,887,888],{},"共享 PVC + subPath（新架构）",[872,890,891],{},"管理简单（1 个 PVC）；自动扩展；成本低",[872,893,894],{},"故障隔离性差；无硬配额；权限管理复杂",[872,896,897],{},"✗ 较差",[851,899,900,903,906,909],{},[872,901,902],{},"对象存储（S3\u002FOSS）",[872,904,905],{},"真正的多租户隔离；天然分布",[872,907,908],{},"延迟高；成本更高；应用改造大",[872,910,911],{},"✓ 最好，但代价大",[10,913,914],{},"独立 PVC 的管理开销是关键问题。574 个用户意味着 574 个 PVC 对象、574 条 PV 绑定、574 个存储卷。每次实例创建、删除或迁移都要涉及 PVC 的生命周期管理。扩展到 5000 用户时，这种开销会成为瓶颈。对象存储虽然隔离性最好，但需要应用层改造（兼容 S3 API、处理延迟、调整备份策略），而且成本更高。",[10,916,917,918,921],{},"共享 PVC 方案的核心优势是",[280,919,920],{},"运维简洁","：增删用户只需改 subPath（一行 YAML），不涉及存储层操作。代价是故障隔离性从\"用户级\"降到\"节点级\"。",[20,923,924],{"id":924},"适用边界",[10,926,927],{},"这个方案适合以下场景：",[27,929,930,936,942,948],{},[30,931,932,935],{},[280,933,934],{},"用户数量有上限","（几百到几千）。用户过多时，共享卷的单点压力会成问题。",[30,937,938,941],{},[280,939,940],{},"用户数据量可控","（单用户通常 GB 级）。如果单用户数据量达 TB，一次 du 扫描会拖累整体。",[30,943,944,947],{},[280,945,946],{},"可以接受节点级故障隔离","。只要网络和存储配置足够稳定（硬化 NFS 参数、冗余链路），节点 hang 的概率不会很高。",[30,949,950,953],{},[280,951,952],{},"能够实施应用层监控和告警","。无硬配额，就必须有实时监控。",[10,955,956],{},"不适合的场景包括：",[27,958,959,962,965],{},[30,960,961],{},"超大规模用户（万级以上）",[30,963,964],{},"用户数据特别不均衡（少数用户占大头）的场景",[30,966,967],{},"对故障隔离要求极高的系统（比如金融交易）",[20,969,970],{"id":970},"防护与调整",[10,972,973],{},"基于这四个问题，实施中做了以下调整：",[303,975,976,985,995,1001,1007],{},[30,977,978,981,982,984],{},[280,979,980],{},"权限处理","：容器 Dockerfile 中预先创建 ",[37,983,712],{}," 并设置正确的属主；启动脚本检查并修复权限。",[30,986,987,990,991,994],{},[280,988,989],{},"NFS 参数硬化","：StorageClass 的挂载选项改为 ",[37,992,993],{},"soft,timeo=100,retrans=3,intr","，避免硬 mount 导致节点冻。",[30,996,997,1000],{},[280,998,999],{},"节点健康检测","：cron 每 60 秒检查各节点的 ContainerCreating 堆积和网关探测失败率，及早发现故障。",[30,1002,1003,1006],{},[280,1004,1005],{},"实例分散","：StatefulSet 加 topologySpreadConstraints，避免单节点堆积 50+ 个实例。",[30,1008,1009,1012],{},[280,1010,1011],{},"应用层配额","：\u002Fstatus 接口返回 diskUsedMi 和 overQuota 标志位，前端告警用户。",[10,1014,1015],{},"这些措施不能消除风险，但可以大幅降低故障概率和影响范围。",[586,1017,1018],{},"html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}",{"title":84,"searchDepth":97,"depth":97,"links":1020},[1021,1022,1023,1024,1025,1026,1027],{"id":641,"depth":97,"text":642},{"id":719,"depth":97,"text":720},{"id":746,"depth":97,"text":747},{"id":805,"depth":97,"text":806},{"id":840,"depth":97,"text":840},{"id":924,"depth":97,"text":924},{"id":970,"depth":97,"text":970},"2026-07-23",{},"\u002F2026-07-23-fa-018-subpath",{"title":630,"description":635},"FA-018","2026-07-23-FA-018-共享存储的subPath陷阱","K8s 共享 PVC + subPath 方案在实施中暴露的四个陷阱：权限不匹配、目录创建时机、节点级故障域、无 per-subPath 配额。",[1036,1037,1038,1039,1040,1041,865],"Kubernetes","存储","subPath","PVC","NFS","权限","4pJJyzbII4OOc9jJS74HUpUfyVc9CfxdE5ovso8ylSE",{"id":1044,"title":1045,"body":1046,"column":609,"date":1485,"description":1486,"extension":611,"hero_image":612,"meta":1487,"navigation":614,"path":1488,"seo":1489,"series_id":1490,"severity":612,"stem":1491,"summary":1492,"tags":1493,"__hash__":1500},"posts\u002F2026-07-11-FA-017-长任务异步化.md","三个超时机制，一起把长任务掐死了",{"type":7,"value":1047,"toc":1477},[1048,1059,1062,1065,1071,1077,1083,1090,1096,1100,1103,1106,1112,1115,1126,1132,1136,1139,1146,1152,1155,1170,1174,1177,1188,1237,1252,1259,1263,1266,1269,1287,1290,1298,1301,1316,1319,1322,1468,1471,1474],[10,1049,1050,1051,1054,1055,1058],{},"数字人口播的「分析」功能从视频提取口播文稿、分镜脚本、结构卖点，需要下载视频、压缩、调 Gemini 视觉 API，耗时从几十秒到几分钟。最初同步实现：前端调 ",[37,1052,1053],{},"POST \u002Fapi\u002Fworkflow\u002Fdub\u002Fanalyze-parsed","，后端直接跑完再返回。这个方案暴露的问题不是单点，而是",[280,1056,1057],{},"三个独立的缺陷叠加","。",[10,1060,1061],{},"网关超时、CDN 空闲切断、客户端重试——这三个没有一个是\"bug\"，每个都是合理的系统设计。但组合在一起就是连锁灾难：客户端看不到结果（超时或连接断），认为失败了重试，后端却已经扣过费了；或者分析其实已跑完，但因为响应发不出去，重试又跑了一遍。退款时无法幂等判断，某些用户最终被多扣了好几倍。",[10,1063,1064],{},"具体来说，三个问题各自是什么：",[10,1066,1067,1070],{},[280,1068,1069],{},"第一个问题是网关超时","。云平台的入口网关（API Gateway）有一个固定的读超时，通常设为几十秒。当后端分析耗时超过这个阈值，网关主动断连接，客户端收到 504 或连接重置，但后端的分析进程并不知道客户端已经走了，继续跑完整个流程。如果分析结果的退款逻辑放在 try-catch 的 finally 里，此时 HTTP 响应已发不出去，客户就看到\"分析失败\"，但款已经扣了。",[10,1072,1073,1076],{},[280,1074,1075],{},"第二个问题是 CDN 空闲切断","。不少 CDN 和负载均衡层有这样的策略：如果一条 HTTP 连接在某个时间段内没有数据往返，就认为它已死而主动关闭。这与 FA-013 那次踩到的问题一样——长连接因为没有心跳数据而被 CDN 中间件切断，导致请求被突然中断。这个机制在分析请求上也会造成麻烦：如果分析用时较长，连接就可能被切，响应发不出去。",[10,1078,1079,1082],{},[280,1080,1081],{},"第三个问题是客户端重试导致重复扣费","。当客户端没有收到响应（网关超时或 CDN 切断），一般会重试这个请求。问题在于这次重试是一条完全独立的请求，后端会把它当作新任务处理，再次扣费。而且如果第一次的分析实际上已经跑完了，就会出现\"分析跑了两遍、扣费扣了两遍、用户只看到了一个结果\"的情况。如果第一次分析中途被 CDN 切了，第二次重试重新开始，那扣费可能是三倍。而退款的时候，因为业务流程上没有幂等设计，很难追溯哪个 operationId 应该被退，哪个不应该。",[10,1084,1085,1086,1089],{},"这三个问题是",[280,1087,1088],{},"叠加的","，不是独立的。它们共同指向一个根本的架构问题。",[10,1091,1092,1093,1058],{},"根本原因一个：",[280,1094,1095],{},"不该让耗时任务挂在 HTTP 连接上",[20,1097,1099],{"id":1098},"异步任务是必需的不是可选的","异步任务是必需的，不是可选的",[10,1101,1102],{},"解决方案很简单：把分析改成异步任务模式。",[10,1104,1105],{},"前端提交分析请求直接返回任务 ID（HTTP 耗时极短：校验参数、建数据库记录、预扣费）。后端后台 worker 跑分析重逻辑，完成后持久化结果。前端轮询查询状态，得到结果后停止。",[79,1107,1110],{"className":1108,"code":1109,"language":336},[334],"客户端              后端\n  |                 |\n  +--提交---------->|\n  |             创建任务，返回 ID\n  |\u003C--返回 ID-----+\n  |                 |（后台运行分析）\n  |                 |\n  +--轮询--------->|\n  |             查询状态\n  |\u003C--返回状态---+\n  |                 |\n  |（重复轮询）\n  +--轮询--------->|\n  |             查询状态\n  |\u003C--返回结果---+\n  |                 |\n",[37,1111,1109],{"__ignoreMap":84},[10,1113,1114],{},"好处显而易见：",[27,1116,1117,1120,1123],{},[30,1118,1119],{},"单次 HTTP 请求的耗时从分钟级降到秒级，不会触发网关或 CDN 的超时。",[30,1121,1122],{},"后端分析的运行生命周期与 HTTP 连接完全解耦。即使连接在中途被切，已经启动的分析会继续在后台跑，结果最终还是会被保存。",[30,1124,1125],{},"客户端重试不会产生新的分析任务。只要实现幂等判重，同一个请求在短时间内重复提交也只会产生一个任务。",[10,1127,1128,1129,1058],{},"但异步化不是万灵药，它把问题转移到了三个新的维度上：",[280,1130,1131],{},"幂等、持久化、失败语义",[20,1133,1135],{"id":1134},"幂等同一请求只产生一个任务","幂等：同一请求只产生一个任务",[10,1137,1138],{},"假设客户端因为网络抖动，在短时间内连续发了两次\"开始分析\"请求，后端收到两条独立的 HTTP 请求。如果没有幂等设计，就会创建两个任务、扣两次费。",[10,1140,1141,1142,1145],{},"幂等的实现最直接的办法是在",[280,1143,1144],{},"提交阶段就判重","：为这个用户、这个资源设置一个\"只有一个进行中的分析任务\"的约束。素材中的设计是这样做的：",[79,1147,1150],{"className":1148,"code":1149,"language":336},[334],"if 该用户已有 status=running && kind=analyze_parsed 的任务:\n    return 409 Conflict（分析已在进行中）\n",[37,1151,1149],{"__ignoreMap":84},[10,1153,1154],{},"这样双击提交同一个视频，第一次返回 201 + taskId，第二次返回 409，客户端看到 409 就知道不要再建新任务，拿之前返回的 ID 去轮询。",[10,1156,1157,1158,1161,1162,1165,1166,1169],{},"另一个幂等层次是",[280,1159,1160],{},"计费操作的幂等","。预扣费的时候用一个全局唯一的 ",[37,1163,1164],{},"operationId","（比如 ",[37,1167,1168],{},"dub-analyze:{uuid}","），这个 ID 关联了这次预扣。如果扣费 API 被重复调用，因为 operationId 重复，系统只会扣一次。退款时也用同一个 operationId，保证退款与预扣能正确配对。",[20,1171,1173],{"id":1172},"持久化进程重启后任务不丢","持久化：进程重启后任务不丢",[10,1175,1176],{},"后台分析 worker 可能因为发版、机器重启、容器被杀等原因在中途退出。如果任务的状态只保存在内存里，进程一死任务就丢了。",[10,1178,1179,1180,1183,1184,1187],{},"持久化的关键是",[280,1181,1182],{},"任务状态表","。素材中复用了既有的 ",[37,1185,1186],{},"SkyhumanTask"," 表，包含字段：",[27,1189,1190,1196,1202,1207,1213,1219,1225,1231],{},[30,1191,1192,1195],{},[37,1193,1194],{},"id",": 任务 ID",[30,1197,1198,1201],{},[37,1199,1200],{},"status",": 进度（running \u002F completed \u002F failed）",[30,1203,1204,1206],{},[37,1205,1164],{},": 幂等 ID",[30,1208,1209,1212],{},[37,1210,1211],{},"chargedPoints",": 预扣费用",[30,1214,1215,1218],{},[37,1216,1217],{},"resultPayload",": 分析结果的 JSON",[30,1220,1221,1224],{},[37,1222,1223],{},"error",": 失败原因",[30,1226,1227,1230],{},[37,1228,1229],{},"updatedAt",": 最后更新时间戳",[30,1232,1233,1236],{},[37,1234,1235],{},"sourceObjectKey",": 源视频对象路径（用于幂等判重和重试）",[10,1238,1239,1240,1243,1244,1247,1248,1251],{},"任务创建时插一条 ",[37,1241,1242],{},"status=running"," 的记录。后台 worker 定期通过 heartbeat（每 30 秒做一次空 update，仅触碰 updatedAt）来证明自己还活着。分析完成时更新为 ",[37,1245,1246],{},"status=completed, resultPayload=...","；失败时更新为 ",[37,1249,1250],{},"status=failed, error=..."," 并触发退款。",[10,1253,1254,1255,1258],{},"这样即使 worker 进程突然死了，任务记录还在数据库里。新启起的 worker 可以扫描表里的 ",[37,1256,1257],{},"status=running && updatedAt 过期"," 的任务，判断它们已经没有 worker 在跑了，执行清理逻辑。",[20,1260,1262],{"id":1261},"失败语义区分任务失败和查询失败","失败语义：区分\"任务失败\"和\"查询失败\"",[10,1264,1265],{},"异步设计引入了一个新的错误维度：客户端查询任务状态时得到的 404，可能是\"这个任务 ID 不存在\"（用户输错了），也可能是\"任务 ID 之前存在但已被清理\"（进程清理了过期数据）。这两种情况的含义很不一样。",[10,1267,1268],{},"更重要的是，前端轮询收到的错误需要区分是否应该重试：",[27,1270,1271,1281],{},[30,1272,1273,1276,1277,1280],{},[280,1274,1275],{},"任务本身失败","（",[37,1278,1279],{},"status=failed, error=\"Gemini API 返回错误\"","）：这种情况不该重试，因为重试也会失败。应该直接向用户展示失败信息，并根据 operationId 触发退款。",[30,1282,1283,1286],{},[280,1284,1285],{},"查询接口失败","（500、网络超时）：这种情况应该重试查询，因为任务本身可能还在继续跑。",[10,1288,1289],{},"素材中的设计把这两类区分得很清楚：",[27,1291,1292,1295],{},[30,1293,1294],{},"任务最终的状态（completed 或 failed）持久化到数据库，客户端查询会拿到明确的业务语义。",[30,1296,1297],{},"查询接口的 HTTP 错误（5xx）是技术层故障，客户端应该重试。",[10,1299,1300],{},"同时，后端的 reaper 机制（定期扫表清理超期任务）也遵循这个语义：",[27,1302,1303,1309],{},[30,1304,1305,1306,1308],{},"如果一个 ",[37,1307,1242],{}," 的任务超过 90 秒没有 heartbeat 更新，说明 worker 已死，reaper 会强制置为 failed 并退款。",[30,1310,1311,1312,1315],{},"但这个强制置为 failed 不应该抛错，因为此时可能有其他进程也想更新这个任务。更新语句应该带条件：",[37,1313,1314],{},"UPDATE SkyhumanTask SET status=failed WHERE id=? AND status=running","，这样如果 reaper 和其他 worker 同时操作，只有一方会成功。",[20,1317,1318],{"id":1318},"具体实现模式",[10,1320,1321],{},"数字人口播的分析异步化采用了这样的模式：",[303,1323,1324,1369,1415,1438],{},[30,1325,1326,1329,1330],{},[280,1327,1328],{},"提交阶段","（同步）：",[27,1331,1332,1335,1338,1344,1350,1357,1363],{},[30,1333,1334],{},"校验用户和资源",[30,1336,1337],{},"判重：是否已有进行中的任务（409）",[30,1339,1340,1343],{},[37,1341,1342],{},"getObject"," 拉视频，检测时长（若≤0 返 400 不扣费）",[30,1345,1346,1349],{},[37,1347,1348],{},"chargeResource"," 预扣费用，获得 operationId",[30,1351,1352,1353,1356],{},"创建 ",[37,1354,1355],{},"SkyhumanTask{ status:running, operationId, sourceObjectKey }"," 记录",[30,1358,1359,1362],{},[37,1360,1361],{},"scheduleTask"," 丢到后台队列",[30,1364,1365,1366],{},"返回 ",[37,1367,1368],{},"{ taskId }",[30,1370,1371,242,1374],{},[280,1372,1373],{},"后台运行阶段",[27,1375,1376,1379,1382,1389,1395,1405,1412],{},[30,1377,1378],{},"Worker 取出任务记录",[30,1380,1381],{},"开启 heartbeat 定时器（每 30s update 一次 updatedAt）",[30,1383,1384,1385,1388],{},"执行 ",[37,1386,1387],{},"analyzeDubVisionOnly","（下载→压缩→调用 Gemini→解析）",[30,1390,1391,1392],{},"成功：",[37,1393,1394],{},"update( status:completed, resultPayload )",[30,1396,1397,1398,1401,1402],{},"失败：",[37,1399,1400],{},"update( status:failed, error )"," + ",[37,1403,1404],{},"refundResource(operationId)",[30,1406,1407,1408,1411],{},"所有 update 都带条件 ",[37,1409,1410],{},"WHERE id=? AND status=running","（防被 reaper 覆盖）",[30,1413,1414],{},"Finally 清理 heartbeat 定时器",[30,1416,1417,1420,1421],{},[280,1418,1419],{},"查询阶段","（前端轮询）：",[27,1422,1423,1432,1435],{},[30,1424,1425,1428,1429],{},[37,1426,1427],{},"GET \u002Fapi\u002Fworkflow\u002Fdub\u002Ftasks\u002F:id"," 返回 ",[37,1430,1431],{},"{ status, resultPayload, error }",[30,1433,1434],{},"若 status=completed 或 failed，停止轮询",[30,1436,1437],{},"若查询接口返回 5xx，则后续重试",[30,1439,1440,1443,1444],{},[280,1441,1442],{},"清理阶段","（reaper）：",[27,1445,1446,1449,1456,1459,1465],{},[30,1447,1448],{},"后台定期扫表",[30,1450,1451,1452,1455],{},"对于 ",[37,1453,1454],{},"status=running && updatedAt > 90s"," 的任务，判定 worker 已死",[30,1457,1458],{},"若 providerTaskId（这里没有）存在，调用上游的 finalize API",[30,1460,1461,1462,1464],{},"若不存在，直接 ",[37,1463,1404],{}," + 置 failed",[30,1466,1467],{},"本设计无 providerTaskId，所以 reaper 自动走退款分支，无需改动 reaper 代码",[10,1469,1470],{},"这个模式的关键是三道防线：提交时判重（409）、后台心跳（定期 touch）、reaper 兜底（自动退款）。任何一个环节出问题，都有后续环节来补救。",[20,1472,1473],{"id":1473},"防线缺一不可",[10,1475,1476],{},"长耗时任务不能挂在 HTTP 连接上不只是超时问题，而是连接脆弱性与重复处理的共谋。异步化表面是\"返回 ID、后台跑\"这一步，真正的难点在三个维度的同时保证：幂等（不重复扣费）、持久化（不丢数据）、失败语义（不破坏重试逻辑）。缺一就会在某个场景暴露。",{"title":84,"searchDepth":97,"depth":97,"links":1478},[1479,1480,1481,1482,1483,1484],{"id":1098,"depth":97,"text":1099},{"id":1134,"depth":97,"text":1135},{"id":1172,"depth":97,"text":1173},{"id":1261,"depth":97,"text":1262},{"id":1318,"depth":97,"text":1318},{"id":1473,"depth":97,"text":1473},"2026-07-11","数字人口播的「分析」功能从视频提取口播文稿、分镜脚本、结构卖点，需要下载视频、压缩、调 Gemini 视觉 API，耗时从几十秒到几分钟。最初同步实现：前端调 POST \u002Fapi\u002Fworkflow\u002Fdub\u002Fanalyze-parsed，后端直接跑完再返回。这个方案暴露的问题不是单点，而是三个独立的缺陷叠加。",{},"\u002F2026-07-11-fa-017",{"title":1045,"description":1486},"FA-017","2026-07-11-FA-017-长任务异步化","长耗时分析请求（几十秒到分钟级）用同步 HTTP 导致网关超时、CDN切断、客户端重试重复扣费。改异步任务需同时解决幂等、持久化、失败语义。",[1494,1495,1496,1497,1498,1499],"异步任务","HTTP超时","CDN","幂等","任务队列","微服务","ARFDvBMGX3tw-E3Rj_gw1RYEjrykplO6u1dxtdS7Uto",{"id":1502,"title":1503,"body":1504,"column":609,"date":2415,"description":2416,"extension":611,"hero_image":612,"meta":2417,"navigation":614,"path":2418,"seo":2419,"series_id":2420,"severity":612,"stem":2421,"summary":2422,"tags":2423,"__hash__":2429},"posts\u002F2026-06-17-FA-016-WS永久断开三条路径.md","日志里没有重连记录——它根本没在重连",{"type":7,"value":1505,"toc":2403},[1506,1516,1523,1530,1533,1536,1540,1546,1623,1629,1698,1708,1712,1715,1837,1848,1852,1858,1913,1916,1919,1922,1937,1940,1943,1946,1953,2210,2220,2223,2233,2246,2256,2259,2262,2269,2320,2323,2326,2329,2332,2335,2380,2397,2400],[10,1507,1508,1509,1512,1513,1515],{},"从 2026-05-11 开始，多台便携包客户反馈 WebSocket 断线后面板卡死。网关进程（",[37,1510,1511],{},"openclaw_gateway.exe","）明确在运行，",[37,1514,39],{}," 返回 200，但面板显示\"已停止重连，请手动刷新\"——用户必须手动重启才能恢复。",[10,1517,1518,1519,1522],{},"关键线索在控制台：看不到任何 ",[37,1520,1521],{},"[ws] 计划重连"," 的日志。",[10,1524,1525,1526,1529],{},"通常断线后客户端会频繁尝试重连，日志里应该是满屏的重连记录。没有日志意味着问题不在重连失败，而在",[280,1527,1528],{},"压根没启动重连机制","。说明客户端进入了某个永久休眠状态。",[20,1531,1532],{"id":1532},"三条独立的永久断开路径",[10,1534,1535],{},"代码审查发现了三条完全不同的、独立的永久断开路径。任意一条命中，就会停止调度任何重连计时器。",[72,1537,1539],{"id":1538},"路径-a凭据刷新异常","路径 A：凭据刷新异常",[10,1541,503,1542,1545],{},[37,1543,1544],{},"_scheduleReconnect"," 方法末尾，每次普通重连都会调用：",[79,1547,1551],{"className":1548,"code":1549,"language":1550,"meta":84,"style":84},"language-javascript shiki shiki-themes github-light github-dark","this._reconnectTimer = setTimeout(() => {\n  if (!this._intentionalClose) {\n    this._refreshCredentialsAndReconnect(0)\n  }\n}, delay)\n","javascript",[37,1552,1553,1577,1593,1613,1618],{"__ignoreMap":84},[88,1554,1555,1558,1561,1565,1568,1571,1574],{"class":90,"line":91},[88,1556,1557],{"class":675},"this",[88,1559,1560],{"class":682},"._reconnectTimer ",[88,1562,1564],{"class":1563},"szBVR","=",[88,1566,1567],{"class":667}," setTimeout",[88,1569,1570],{"class":682},"(() ",[88,1572,1573],{"class":1563},"=>",[88,1575,1576],{"class":682}," {\n",[88,1578,1579,1582,1585,1588,1590],{"class":90,"line":97},[88,1580,1581],{"class":1563},"  if",[88,1583,1584],{"class":682}," (",[88,1586,1587],{"class":1563},"!",[88,1589,1557],{"class":675},[88,1591,1592],{"class":682},"._intentionalClose) {\n",[88,1594,1595,1598,1601,1604,1607,1610],{"class":90,"line":260},[88,1596,1597],{"class":675},"    this",[88,1599,1600],{"class":682},".",[88,1602,1603],{"class":667},"_refreshCredentialsAndReconnect",[88,1605,1606],{"class":682},"(",[88,1608,1609],{"class":675},"0",[88,1611,1612],{"class":682},")\n",[88,1614,1615],{"class":90,"line":266},[88,1616,1617],{"class":682},"  }\n",[88,1619,1620],{"class":90,"line":272},[88,1621,1622],{"class":682},"}, delay)\n",[10,1624,1625,1626,1628],{},"而 ",[37,1627,1603],{}," 是异步方法，catch 分支没有任何后续处理：",[79,1630,1632],{"className":1548,"code":1631,"language":1550,"meta":84,"style":84},"} catch (e) {\n  console.error('[ws] 刷新凭据失败:', e)\n  this._setConnected(false, 'error', `凭据刷新失败: ${e}`)\n}\n",[37,1633,1634,1645,1660,1694],{"__ignoreMap":84},[88,1635,1636,1639,1642],{"class":90,"line":91},[88,1637,1638],{"class":682},"} ",[88,1640,1641],{"class":1563},"catch",[88,1643,1644],{"class":682}," (e) {\n",[88,1646,1647,1650,1652,1654,1657],{"class":90,"line":97},[88,1648,1649],{"class":682},"  console.",[88,1651,1223],{"class":667},[88,1653,1606],{"class":682},[88,1655,1656],{"class":671},"'[ws] 刷新凭据失败:'",[88,1658,1659],{"class":682},", e)\n",[88,1661,1662,1665,1667,1670,1672,1675,1678,1681,1683,1686,1689,1692],{"class":90,"line":260},[88,1663,1664],{"class":675},"  this",[88,1666,1600],{"class":682},[88,1668,1669],{"class":667},"_setConnected",[88,1671,1606],{"class":682},[88,1673,1674],{"class":675},"false",[88,1676,1677],{"class":682},", ",[88,1679,1680],{"class":671},"'error'",[88,1682,1677],{"class":682},[88,1684,1685],{"class":671},"`凭据刷新失败: ${",[88,1687,1688],{"class":682},"e",[88,1690,1691],{"class":671},"}`",[88,1693,1612],{"class":682},[88,1695,1696],{"class":90,"line":266},[88,1697,421],{"class":682},[10,1699,1700,1701,287,1704,1707],{},"如果 ",[37,1702,1703],{},"api.readOpenclawConfig()",[37,1705,1706],{},"api.autoPairDevice()"," 抛错——磁盘 IO 抖、Rust 端处理器忙、Tauri IPC 队列阻塞——这次重连就永久终止。便携磁盘的 IO 抖动 + 配置文件读写争抢时最容易发生。",[72,1709,1711],{"id":1710},"路径-b认证失败后的强制关闭","路径 B：认证失败后的强制关闭",[10,1713,1714],{},"当 WebSocket 收到 1008 unauthorized 响应时的处理逻辑：",[79,1716,1718],{"className":1548,"code":1717,"language":1550,"meta":84,"style":84},"if (this._authRetryCount \u003C 2) {\n  this._authRetryCount++\n  this._refreshCredentialsAndReconnect()\n  return\n}\nthis._setConnected(false, 'auth_failed', `认证失败: ${e.reason}。请检查 Gateway Token 配置。`)\nthis._intentionalClose = true   \u002F\u002F ← 永久关闭标记\nthis._flushPending()\nreturn\n",[37,1719,1720,1741,1751,1762,1767,1771,1805,1821,1832],{"__ignoreMap":84},[88,1721,1722,1725,1727,1729,1732,1735,1738],{"class":90,"line":91},[88,1723,1724],{"class":1563},"if",[88,1726,1584],{"class":682},[88,1728,1557],{"class":675},[88,1730,1731],{"class":682},"._authRetryCount ",[88,1733,1734],{"class":1563},"\u003C",[88,1736,1737],{"class":675}," 2",[88,1739,1740],{"class":682},") {\n",[88,1742,1743,1745,1748],{"class":90,"line":97},[88,1744,1664],{"class":675},[88,1746,1747],{"class":682},"._authRetryCount",[88,1749,1750],{"class":1563},"++\n",[88,1752,1753,1755,1757,1759],{"class":90,"line":260},[88,1754,1664],{"class":675},[88,1756,1600],{"class":682},[88,1758,1603],{"class":667},[88,1760,1761],{"class":682},"()\n",[88,1763,1764],{"class":90,"line":266},[88,1765,1766],{"class":1563},"  return\n",[88,1768,1769],{"class":90,"line":272},[88,1770,421],{"class":682},[88,1772,1773,1775,1777,1779,1781,1783,1785,1788,1790,1793,1795,1797,1800,1803],{"class":90,"line":429},[88,1774,1557],{"class":675},[88,1776,1600],{"class":682},[88,1778,1669],{"class":667},[88,1780,1606],{"class":682},[88,1782,1674],{"class":675},[88,1784,1677],{"class":682},[88,1786,1787],{"class":671},"'auth_failed'",[88,1789,1677],{"class":682},[88,1791,1792],{"class":671},"`认证失败: ${",[88,1794,1688],{"class":682},[88,1796,1600],{"class":671},[88,1798,1799],{"class":682},"reason",[88,1801,1802],{"class":671},"}。请检查 Gateway Token 配置。`",[88,1804,1612],{"class":682},[88,1806,1807,1809,1812,1814,1817],{"class":90,"line":434},[88,1808,1557],{"class":675},[88,1810,1811],{"class":682},"._intentionalClose ",[88,1813,1564],{"class":1563},[88,1815,1816],{"class":675}," true",[88,1818,1820],{"class":1819},"sJ8bj","   \u002F\u002F ← 永久关闭标记\n",[88,1822,1823,1825,1827,1830],{"class":90,"line":440},[88,1824,1557],{"class":675},[88,1826,1600],{"class":682},[88,1828,1829],{"class":667},"_flushPending",[88,1831,1761],{"class":682},[88,1833,1834],{"class":90,"line":445},[88,1835,1836],{"class":1563},"return\n",[10,1838,1839,1840,1843,1844,1847],{},"设置 ",[37,1841,1842],{},"_intentionalClose = true"," 之后，所有重连逻辑都被 ",[37,1845,1846],{},"if (!this._intentionalClose)"," 短路。即使只是 Gateway 重启窗口碰好出现 token 短暂不匹配，也会一次性把客户端打死，必须刷新页面才能恢复。",[72,1849,1851],{"id":1850},"路径-c快速重连配额耗尽","路径 C：快速重连配额耗尽",[10,1853,1854,1855,242],{},"定义了常数 ",[37,1856,1857],{},"MAX_RECONNECT_ATTEMPTS = 60",[79,1859,1861],{"className":1548,"code":1860,"language":1550,"meta":84,"style":84},"if (this._reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {\n  this._setConnected(false, 'error', `连接失败，已停止重连。请手动刷新页面重试。`)\n  return\n}\n",[37,1862,1863,1882,1905,1909],{"__ignoreMap":84},[88,1864,1865,1867,1869,1871,1874,1877,1880],{"class":90,"line":91},[88,1866,1724],{"class":1563},[88,1868,1584],{"class":682},[88,1870,1557],{"class":675},[88,1872,1873],{"class":682},"._reconnectAttempts ",[88,1875,1876],{"class":1563},">=",[88,1878,1879],{"class":675}," MAX_RECONNECT_ATTEMPTS",[88,1881,1740],{"class":682},[88,1883,1884,1886,1888,1890,1892,1894,1896,1898,1900,1903],{"class":90,"line":97},[88,1885,1664],{"class":675},[88,1887,1600],{"class":682},[88,1889,1669],{"class":667},[88,1891,1606],{"class":682},[88,1893,1674],{"class":675},[88,1895,1677],{"class":682},[88,1897,1680],{"class":671},[88,1899,1677],{"class":682},[88,1901,1902],{"class":671},"`连接失败，已停止重连。请手动刷新页面重试。`",[88,1904,1612],{"class":682},[88,1906,1907],{"class":90,"line":260},[88,1908,1766],{"class":1563},[88,1910,1911],{"class":90,"line":266},[88,1912,421],{"class":682},[10,1914,1915],{},"客户机晚上挂机，Gateway 因为便携磁盘 GC 或 Windows 休眠争抢资源短暂掉线，客户端尝试 60 次仍未成功重连，就永久停摆。早上用户回来面板已成死链。",[20,1917,1918],{"id":1918},"为什么三条缺陷同时存在",[10,1920,1921],{},"这是逐步累加的历史债：",[27,1923,1924,1931,1934],{},[30,1925,1926,1927,1930],{},"路径 B 是在修复\"避免无限自动配对循环\"时添加的保护，但用错了对象——",[37,1928,1929],{},"_intentionalClose=true"," 本来是用户主动断开的语义，不该用在被动失败上",[30,1932,1933],{},"路径 C 是早期\"避免无穷重试\"的防护，但 60 次后完全放弃而不留任何复活路径是绝对错误",[30,1935,1936],{},"路径 A 是在\"凭据 reload\"改动时把所有重连都改走 refreshCredentials，没留意 catch 分支已经成了终态",[10,1938,1939],{},"三条各自独立、都能单独打死客户端，组合在一起命中率非常高。",[20,1941,1942],{"id":1942},"修复方案",[10,1944,1945],{},"核心思想是永不彻底放弃。任何\"快速重连配额耗尽\"的分支都转入慢轮询，留出窗口让用户改配置或等待 Gateway 恢复后能自动复连。",[10,1947,1948,1949,1952],{},"新增辅助方法 ",[37,1950,1951],{},"_schedulePoll(delayMs, kind)"," 作为慢轮询的触发器：",[79,1954,1956],{"className":1548,"code":1955,"language":1550,"meta":84,"style":84},"const AUTH_RETRY_LIMIT = 2\nconst SLOW_POLL_DELAY_AUTH = 60_000      \u002F\u002F 认证持续失败：1 分钟探一次\nconst SLOW_POLL_DELAY_GENERAL = 300_000  \u002F\u002F 一般持续失败：5 分钟探一次\n\n_schedulePoll(delayMs, kind) {\n  this._clearReconnectTimer()\n  this._reconnectAttempts = 0\n  if (kind === 'auth') this._authRetryCount = 0\n  this._reconnectState = 'scheduled'\n  this._pendingReconnect = true\n  this._reconnectTimer = setTimeout(() => {\n    this._reconnectTimer = null\n    if (this._intentionalClose) return\n    this._reconnectState = 'attempting'\n    if (kind === 'auth') {\n      this._refreshCredentialsAndReconnect(0)\n    } else {\n      this._doConnect()\n    }\n  }, delayMs)\n}\n",[37,1957,1958,1972,1987,2002,2007,2015,2026,2037,2061,2073,2085,2102,2114,2129,2141,2154,2170,2181,2193,2199,2205],{"__ignoreMap":84},[88,1959,1960,1963,1966,1969],{"class":90,"line":91},[88,1961,1962],{"class":1563},"const",[88,1964,1965],{"class":675}," AUTH_RETRY_LIMIT",[88,1967,1968],{"class":1563}," =",[88,1970,1971],{"class":675}," 2\n",[88,1973,1974,1976,1979,1981,1984],{"class":90,"line":97},[88,1975,1962],{"class":1563},[88,1977,1978],{"class":675}," SLOW_POLL_DELAY_AUTH",[88,1980,1968],{"class":1563},[88,1982,1983],{"class":675}," 60_000",[88,1985,1986],{"class":1819},"      \u002F\u002F 认证持续失败：1 分钟探一次\n",[88,1988,1989,1991,1994,1996,1999],{"class":90,"line":260},[88,1990,1962],{"class":1563},[88,1992,1993],{"class":675}," SLOW_POLL_DELAY_GENERAL",[88,1995,1968],{"class":1563},[88,1997,1998],{"class":675}," 300_000",[88,2000,2001],{"class":1819},"  \u002F\u002F 一般持续失败：5 分钟探一次\n",[88,2003,2004],{"class":90,"line":266},[88,2005,2006],{"emptyLinePlaceholder":614},"\n",[88,2008,2009,2012],{"class":90,"line":272},[88,2010,2011],{"class":667},"_schedulePoll",[88,2013,2014],{"class":682},"(delayMs, kind) {\n",[88,2016,2017,2019,2021,2024],{"class":90,"line":429},[88,2018,1664],{"class":675},[88,2020,1600],{"class":682},[88,2022,2023],{"class":667},"_clearReconnectTimer",[88,2025,1761],{"class":682},[88,2027,2028,2030,2032,2034],{"class":90,"line":434},[88,2029,1664],{"class":675},[88,2031,1873],{"class":682},[88,2033,1564],{"class":1563},[88,2035,2036],{"class":675}," 0\n",[88,2038,2039,2041,2044,2047,2050,2053,2055,2057,2059],{"class":90,"line":440},[88,2040,1581],{"class":1563},[88,2042,2043],{"class":682}," (kind ",[88,2045,2046],{"class":1563},"===",[88,2048,2049],{"class":671}," 'auth'",[88,2051,2052],{"class":682},") ",[88,2054,1557],{"class":675},[88,2056,1731],{"class":682},[88,2058,1564],{"class":1563},[88,2060,2036],{"class":675},[88,2062,2063,2065,2068,2070],{"class":90,"line":445},[88,2064,1664],{"class":675},[88,2066,2067],{"class":682},"._reconnectState ",[88,2069,1564],{"class":1563},[88,2071,2072],{"class":671}," 'scheduled'\n",[88,2074,2075,2077,2080,2082],{"class":90,"line":450},[88,2076,1664],{"class":675},[88,2078,2079],{"class":682},"._pendingReconnect ",[88,2081,1564],{"class":1563},[88,2083,2084],{"class":675}," true\n",[88,2086,2088,2090,2092,2094,2096,2098,2100],{"class":90,"line":2087},11,[88,2089,1664],{"class":675},[88,2091,1560],{"class":682},[88,2093,1564],{"class":1563},[88,2095,1567],{"class":667},[88,2097,1570],{"class":682},[88,2099,1573],{"class":1563},[88,2101,1576],{"class":682},[88,2103,2105,2107,2109,2111],{"class":90,"line":2104},12,[88,2106,1597],{"class":675},[88,2108,1560],{"class":682},[88,2110,1564],{"class":1563},[88,2112,2113],{"class":675}," null\n",[88,2115,2117,2120,2122,2124,2127],{"class":90,"line":2116},13,[88,2118,2119],{"class":1563},"    if",[88,2121,1584],{"class":682},[88,2123,1557],{"class":675},[88,2125,2126],{"class":682},"._intentionalClose) ",[88,2128,1836],{"class":1563},[88,2130,2132,2134,2136,2138],{"class":90,"line":2131},14,[88,2133,1597],{"class":675},[88,2135,2067],{"class":682},[88,2137,1564],{"class":1563},[88,2139,2140],{"class":671}," 'attempting'\n",[88,2142,2144,2146,2148,2150,2152],{"class":90,"line":2143},15,[88,2145,2119],{"class":1563},[88,2147,2043],{"class":682},[88,2149,2046],{"class":1563},[88,2151,2049],{"class":671},[88,2153,1740],{"class":682},[88,2155,2157,2160,2162,2164,2166,2168],{"class":90,"line":2156},16,[88,2158,2159],{"class":675},"      this",[88,2161,1600],{"class":682},[88,2163,1603],{"class":667},[88,2165,1606],{"class":682},[88,2167,1609],{"class":675},[88,2169,1612],{"class":682},[88,2171,2173,2176,2179],{"class":90,"line":2172},17,[88,2174,2175],{"class":682},"    } ",[88,2177,2178],{"class":1563},"else",[88,2180,1576],{"class":682},[88,2182,2184,2186,2188,2191],{"class":90,"line":2183},18,[88,2185,2159],{"class":675},[88,2187,1600],{"class":682},[88,2189,2190],{"class":667},"_doConnect",[88,2192,1761],{"class":682},[88,2194,2196],{"class":90,"line":2195},19,[88,2197,2198],{"class":682},"    }\n",[88,2200,2202],{"class":90,"line":2201},20,[88,2203,2204],{"class":682},"  }, delayMs)\n",[88,2206,2208],{"class":90,"line":2207},21,[88,2209,421],{"class":682},[10,2211,2212,2213,2215,2216,2219],{},"慢轮询命中后失败会重新进入 ",[37,2214,1544],{}," 快速重连周期（因为 ",[37,2217,2218],{},"_reconnectAttempts"," 重置为 0），相当于\"快速 60 次 → 慢一次 → 快速 60 次 → 慢一次\"的循环，永不放弃。",[10,2221,2222],{},"三条修复并行：",[10,2224,2225,2228,2229,2232],{},[280,2226,2227],{},"Fix A","：普通重连直接走 ",[37,2230,2231],{},"_doConnect()","，凭据刷新隔离到认证失败分支，避免配置读取错误牵连普通重连。",[10,2234,2235,2238,2239,2242,2243,2245],{},[280,2236,2237],{},"Fix B","：认证失败耗尽后转入 ",[37,2240,2241],{},"_schedulePoll('auth')","，不再设置 ",[37,2244,1929],{},"，给认证恢复留出 60 秒的探测窗口。",[10,2247,2248,2251,2252,2255],{},[280,2249,2250],{},"Fix C","：重连次数超过 MAX_RECONNECT_ATTEMPTS 后转入 ",[37,2253,2254],{},"_schedulePoll('general')","，设置 UI 状态为\"连接持续失败，300 秒后重试\"而不是终态。",[10,2257,2258],{},"慢轮询失败后重新进入快速重连周期，形成\"快速 60 次 → 慢一次 → 快速 60 次\"的循环，永不放弃。",[20,2260,2261],{"id":2261},"防回归验证",[10,2263,2264,2265,2268],{},"新增 ",[37,2266,2267],{},"wakou-full\u002Fsrc\u002Flib\u002Fws-client.slow-poll.test.js","，覆盖 7 个 case：",[27,2270,2271,2279,2291,2294,2303,2310,2315],{},[30,2272,2273,2274,2276,2277],{},"Fix A：普通重连命中 ",[37,2275,2190],{},"，不调用 ",[37,2278,1603],{},[30,2280,2281,2282,2285,2286,2288,2289],{},"Fix C：MAX_RECONNECT_ATTEMPTS 后状态是 ",[37,2283,2284],{},"reconnecting","（非终态 ",[37,2287,1223],{},"），5 分钟后触发 ",[37,2290,2190],{},[30,2292,2293],{},"Fix C：第二轮慢轮询失败后还能再调度快速重连",[30,2295,2296,2297,2299,2300],{},"Fix B：",[37,2298,2241],{}," 等待 60 秒调用 ",[37,2301,2302],{},"_refreshCredentialsAndReconnect(0)",[30,2304,2296,2305,2307,2308],{},[37,2306,2254],{}," 等待后调用 ",[37,2309,2190],{},[30,2311,2312,2314],{},[37,2313,1929],{}," 时慢轮询应跳过",[30,2316,2317,2319],{},[37,2318,1603],{}," 抛错路径调度慢轮询",[10,2321,2322],{},"测试全部通过（7\u002F7）。",[20,2324,2325],{"id":2325},"外部因素的叠加",[10,2327,2328],{},"这个时期同步发生了另一个外部问题：CDN 对 WebSocket 空闲连接会在 4-9 分钟后强制切断。客户端收到连接中断后根据指数退避策略重连，如果恰好命中上述三条路径之一，就进入永久断开状态。这解释了为什么故障特别在长时间挂机后高发——空闲足够长，CDN 必定切断，而后续重连很容易落入某条缺陷路径。两个问题各自独立，但组合效果是\"挂机过夜必死\"。",[20,2330,2331],{"id":2331},"排查验证",[10,2333,2334],{},"故障修复后，可以用以下方式验证重连状态：",[79,2336,2338],{"className":1548,"code":2337,"language":1550,"meta":84,"style":84},"__clawpanelWsClient.getConnectionInfo()\n\u002F\u002F {\n\u002F\u002F   connected: false,\n\u002F\u002F   reconnectState: 'scheduled' | 'attempting',\n\u002F\u002F   reconnectAttempts: 0~60,\n\u002F\u002F   ...\n\u002F\u002F }\n",[37,2339,2340,2350,2355,2360,2365,2370,2375],{"__ignoreMap":84},[88,2341,2342,2345,2348],{"class":90,"line":91},[88,2343,2344],{"class":682},"__clawpanelWsClient.",[88,2346,2347],{"class":667},"getConnectionInfo",[88,2349,1761],{"class":682},[88,2351,2352],{"class":90,"line":97},[88,2353,2354],{"class":1819},"\u002F\u002F {\n",[88,2356,2357],{"class":90,"line":260},[88,2358,2359],{"class":1819},"\u002F\u002F   connected: false,\n",[88,2361,2362],{"class":90,"line":266},[88,2363,2364],{"class":1819},"\u002F\u002F   reconnectState: 'scheduled' | 'attempting',\n",[88,2366,2367],{"class":90,"line":272},[88,2368,2369],{"class":1819},"\u002F\u002F   reconnectAttempts: 0~60,\n",[88,2371,2372],{"class":90,"line":429},[88,2373,2374],{"class":1819},"\u002F\u002F   ...\n",[88,2376,2377],{"class":90,"line":434},[88,2378,2379],{"class":1819},"\u002F\u002F }\n",[10,2381,2382,2385,2386,2389,2390,2385,2393,2396],{},[37,2383,2384],{},"reconnectState='scheduled'"," 且 ",[37,2387,2388],{},"reconnectAttempts=0"," 表示在慢轮询窗口正常工作；",[37,2391,2392],{},"reconnectState='idle'",[37,2394,2395],{},"connected=false"," 是老的永久断开路径。",[10,2398,2399],{},"重连机制里避免静默终态是最基本的要求。任何异常路径都必须有可见的状态提示和后续操作入口，否则用户端看到的就是死机。这次故障的根本教训是，不能让客户端在任何情况下进入\"不可恢复\"的状态而没有任何提示。慢轮询的引入给了所有失败情景一个\"最后的机会\"，即使前面的快速重连机制彻底耗尽了，用户等待足够长的时间后系统仍有自动恢复的可能。",[586,2401,2402],{},"html pre.shiki code .sj4cs, html code.shiki .sj4cs{--shiki-default:#005CC5;--shiki-dark:#79B8FF}html pre.shiki code .sVt8B, html code.shiki .sVt8B{--shiki-default:#24292E;--shiki-dark:#E1E4E8}html pre.shiki code .szBVR, html code.shiki .szBVR{--shiki-default:#D73A49;--shiki-dark:#F97583}html pre.shiki code .sScJk, html code.shiki .sScJk{--shiki-default:#6F42C1;--shiki-dark:#B392F0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .sZZnC, html code.shiki .sZZnC{--shiki-default:#032F62;--shiki-dark:#9ECBFF}html pre.shiki code .sJ8bj, html code.shiki .sJ8bj{--shiki-default:#6A737D;--shiki-dark:#6A737D}",{"title":84,"searchDepth":97,"depth":97,"links":2404},[2405,2410,2411,2412,2413,2414],{"id":1532,"depth":97,"text":1532,"children":2406},[2407,2408,2409],{"id":1538,"depth":260,"text":1539},{"id":1710,"depth":260,"text":1711},{"id":1850,"depth":260,"text":1851},{"id":1918,"depth":97,"text":1918},{"id":1942,"depth":97,"text":1942},{"id":2261,"depth":97,"text":2261},{"id":2325,"depth":97,"text":2325},{"id":2331,"depth":97,"text":2331},"2026-06-17","从 2026-05-11 开始，多台便携包客户反馈 WebSocket 断线后面板卡死。网关进程（openclaw_gateway.exe）明确在运行，\u002Fhealth 返回 200，但面板显示\"已停止重连，请手动刷新\"——用户必须手动重启才能恢复。",{},"\u002F2026-06-17-fa-016-ws",{"title":1503,"description":2416},"FA-016","2026-06-17-FA-016-WS永久断开三条路径","客户端重连逻辑中三处独立的\"静默终止\"缺陷导致 WS 永久断开，任何一条路径命中就不再调度重连计时器。",[2424,2425,2426,2427,2428],"WebSocket","Node.js","客户端重连机制","异常处理","便携包","qaISKpAwjYnhiSdxEMYYnJtJ-A8QaOq-djOEJ0W9-bM",1785406912293]