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