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