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