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