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