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