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