Skip to content

v2.2.8

Choose a tag to compare

@github-actions github-actions released this 29 Aug 08:09
· 31 commits to master since this release

Changed

  • WebUI 重构:合并探测页 + 局部刷新 + 国家 emoji (fork). packages/gui/extra/webui/index.html 重写:合并"Xray 节点"和"探测状态"为"探测"页(Stage1/Stage2/Stage3 三卡片),去掉"上轮汇总"。Stage1 卡片显示主 xray 进程状态/当前选中节点(含国家 emoji)/出站节点数,右下角放 Balancer 锁定/解锁控件 + 时长下拉(锁定时禁用),完整 20 节点详情表可折叠展开(列排序)。Stage2 卡片显示开关/间隔/下一轮预计时间/上轮耗时/上轮抓取数。Stage3 卡片显示开关/轮次/批次进度/节点进度/耗时/下一轮时间/本轮可用/本轮失败。仪表盘新增版本号卡(GET /api/version,含 dev-sidecar + Xray core + Node.js 三版本),端口卡补充 31180 HTTP 代理,去掉"重启服务"按钮。日志页加手动刷新按钮 + 自动刷新切换 + 间隔下拉(2s/5s/10s/30s)。所有面板改局部刷新(textContent 更新,不重建 DOM),避免 10s 自动刷新时界面抖动。缓存/配置页骨架优先渲染(固定卡片布局立即显示 + - 占位 + "加载中..." tbody,API 返回后局部填充)。国家字段用 String.fromCodePoint 算法生成 emoji 国旗(ISO 2 字母代码),内联 TwemojiCountryFlags.woff2 字体(base64 data URL)解决 Linux Chromium 不渲染国旗 emoji 问题。导航精简为 5 项(仪表盘/探测/缓存/日志/配置)。
  • Stage1 bootstrap 后立即临时锁定出口节点 (fork). 解决"启动后 5 分钟内无可用出口"问题(observatory probeInterval=300s,第一次探完前 balancer 无延时数据无法 leastPing 选节点)。Stage1 ado 注入完成后立即调用 overrideBalancer 锁定延时最低的节点,sticky 状态通过 setTimeout(probeInterval * 1000) 自动到期后 removeBalancerOverride 解锁,让 observatory 的 leastPing 策略接管。手动解锁会 clearTimeout 取消自动解锁 timer,与定时器互斥无竞态。
  • WebUI Xray 节点字段提取规则 (fork). 从 _TypedMessage_(带尾部下划线)提取协议,从 proxySettings.server(trojan)或 proxySettings.vnext(vless)提取地址,从 streamSettings.protocolName 提取传输协议,从 securitySettings[0].serverName 提取 SNI,从 metricsRes.observatory(不是 observation)提取延时和存活。
  • WebUI Balancer 改为解析式展示 (fork). 不再展示 YAML 原文,解析出当前选中节点 tag 显示;锁定/解锁按钮 + 时长下拉框(5min/10min/30min/1h/24h/永久)右侧布局。
  • WebUI 延时显示单位 (fork). 延时直接显示毫秒(不除以 1e6)。
  • 远程配置未启用时不删除已有文件 (fork). packages/core/src/config-api.js 的 downloadRemoteConfig / doDownloadRemoteConfig 原逻辑在远程配置未启用或 URL 为空时调用 deleteRemoteConfigFile 删除本地文件。用户可能手动放置了 remote_config_personal.json5,不应被空 URL 删除。改为只跳过下载,不删除已有文件。
  • config.update 用 mergeWith 替代 merge 避免数组合并 (fork). packages/core/src/config-api.js 的 update(partConfig) 原用 lodash.merge 合并配置,对数组字段(如 plugin.xray.rules)会按索引合并而非整体替换,导致用户想替换整个数组时旧元素残留。改为 lodash.mergeWith + customizer,对源端数组返回 srcValue(整体替换),同时 cloneDeep 目标避免污染原配置。
  • WebUI Stage2/Stage3 三态状态 + Stage2 运行实时进度 (fork). 用户反馈 Stage2/Stage3 应区分三种状态(已关闭/空闲/运行中),且 Stage2 运行中看不到进度。getStageStatus() 新增结构化 state 字段(stage2:off/idle/running,running=远端订阅抓取进行中,新增 isStage2Running 标志覆盖启动时后台同步与 Stage3 轮末触发两个入口,三处出口(含异常兜底)复位;stage3 同款三态 + enabled 字段);删除与 state === 'running' 完全等价的冗余 isRunning 字段。Stage2 新增本轮实时数据:progress: {current, total}(正在抓第几个订阅/订阅总数,loadSubscriptionNodes 新增 onSubscriptionProgress 回调,模块级函数经 options 传回插件闭包)、startedAt(本轮开始时间,前端算实时耗时)、fetched(本轮已抓取节点数,实时累计;结束后回落 lastSyncFetchedCount)。nextTriggerAt = max(nextSyncAt, 下一轮 Stage3 开始时间) 替代原"待触发"显示——Stage2 在 Stage3 轮末按需触发,前端显示"约 HH:MM:SS"。前端 Stage2 卡片:状态三态、新增"进度"格、"上轮耗时"→"耗时"(运行中显示本轮实时耗时并随自动刷新跳动,结束后为本轮总耗时)、"上轮抓取"→"抓取";Stage3 耗时字段同步改用 state。

Added

  • WebUI 监控面板 (fork). 新增 packages/gui/extra/webui/index.html 单页面 Web UI(端口 31182),用于无显示器服务器场景的 dev-sidecar 状态监控。面板包含:Dashboard(Xray/系统代理/服务器/Stage 状态概览)、Xray 节点(协议/地址/端口/SNI/传输/延时/存活状态 + 列排序)、缓存(统计卡片 + 已探测节点详情 + 国家分布 + 最优节点 + 订阅源)、Stage(探测进度/批次/候选数)、日志(tail 实时滚动)、配置(只读视图)、Balancer(解析式展示当前选中节点 + sticky 锁定/解锁下拉时长 5min/10min/30min/1h/24h/永久)。骨架优先渲染(render(null) 占位 → API 返回后填充),避免加载闪烁。每 10 秒自动刷新当前激活面板。

  • /api/xray/nodes 节点列表 API (fork). packages/core/src/modules/plugin/webui/routes.js 新增节点列表接口,调用 xray api lso(ListOutbounds)获取 live xray 进程的 outbound 列表,JSON.parse 后过滤 direct/block/metrics 节点,提取 protocol/address/port/SNI/transport 字段;合并 observatory metrics 数据(从 /debug/vars 拉取)展示延时和存活状态。

  • /api/xray/cache/nodes 分页节点接口 (fork). 新增缓存节点分页接口(page/pageSize/sort 参数),sort=smart 时按延时升序 + 失效节点置底。

  • /api/xray/cache/stats 缓存统计接口 (fork). 返回 { totalNodes, dbSizeBytes, countryDistribution },新增 packages/core/src/modules/plugin/xray/cache.js 的 readCountryDistribution(cacheFilePath, limit) SQL GROUP BY country 聚合国家分布。

  • /api/xray/probed-stats 已探测节点统计接口 (fork). 读取 probed-node-stats.json,返回 { totalProbed, countryDistribution, nodes }。

  • /api/xray/balancer 返回 sticky 状态 (fork). balancer 接口返回 { balancer, xrayEnabled, sticky },sticky 来自 getStickyStatus()(比解析 balancer 文本更可靠)。

  • /api/xray/sticky POST/DELETE 锁定/解锁接口 (fork). POST duration 参数(秒),0 表示永久(10 年);DELETE 手动解锁,会 clearTimeout 取消自动解锁 timer,避免重复触发。

  • getStageStatus 扩展 Stage1/2/3 字段 (fork). packages/core/src/modules/plugin/xray/index.js 的 getStageStatus() 从 6 个字段扩展为结构化输出:stage1(processStarted/livePort/apiPort/metricsPort/liveNodes/currentSelectTag)、stage2(enabled/intervalHours/lastSyncAt/lastSyncDurationMs/lastSyncFetchedCount/nextSyncAt/nextSyncOverdue)、stage3(isRunning/generation/roundStartedAt/nextRefreshAt/totalDue/processed/batchIndex/plannedBatchCount/successBatchCount/availableCount/explicitFailureCount/removedCount)。nextRefreshAt 改为真实时间戳(替换原 Date.now()+1 占位符)。新增 getLiveNodeFingerprints() 方法返回 tag→fingerprint 反向映射,供 /api/xray/nodes 关联 country,不暴露到 getStageStatus 避免响应过大。

  • Stage2 同步耗时/抓取数持久化 (fork). packages/core/src/modules/plugin/xray/cache.js 新增 setStage2LastSyncStats(cacheFilePath, durationMs, fetchedCount) 和 getStage2LastSyncStats(cacheFilePath),持久化到 SQLite cache meta(stage2_last_sync_duration_ms/stage2_last_sync_fetched_count)。index.js Stage2 同步开始时记录 stage2SyncStartedAt,完成后写入耗时和 subscriptionNodeCount。Stage2 下一轮预计时间 = lastSyncAt + intervalHours*3600*1000(超过当前时间显示"待触发",因 Stage2 在 Stage3 轮末按需触发,非定时调度)。

  • Stage3 轮次计时暴露 (fork). index.js 新增外层变量 stage3RoundStartedAt/stage3NextRefreshAt/stage3Progress,在 refreshCacheFromCacheOnly 入口、批次成功、轮次结束 3 个 nextDelay 计算点更新。WebUI 实时显示本轮耗时和下一轮触发时间。

  • /api/xray/nodes 关联 country/exitIp (fork). routes.js 的 /api/xray/nodes 路由调 getLiveNodeFingerprints() 拿 tag→fingerprint,再用 readCacheEntriesByFingerprints 查缓存,返回 nodeMetadata: {tag: {country, exitIp, owner}},前端展示节点所属国家。

  • mitmproxy 子进程启用 SIGUSR2 堆快照诊断信号 (fork). packages/core/src/modules/server/index.js 的 fork execArgv 新增 --heapsnapshot-signal=SIGUSR2 和 --diagnostic-dir=<userBasePath>/logs:长期运行的 mitmproxy 子进程可随时 kill -USR2 <pid> 生成 .heapsnapshot(Chrome DevTools Memory 面板加载对比,三快照法定位内存泄漏),进程不会被杀死(Node 注册 handler 捕获信号写快照后恢复;未加 flag 的进程收 USR2 会按 POSIX 默认行为终止——运维时只对 mitmproxy PID 发)。平时零开销;触发时 stop-the-world(实测 19MB 堆 ~秒级暂停,代理功能暂停后自动恢复),快照文件几十至几百 MB 需定期清理。未设 --report-signal(同信号会冲突);诊断报告可经运行时 process.report.writeReport() 获取,无需 flag。实测:发 USR2 后进程存活、快照落盘 ~/.dev-sidecar/logs/、代理恢复 HTTP 200。

Fixed

  • 修复 WebUI readBody 解析失败导致 unhandled rejection 崩进程 (fork). packages/core/src/modules/plugin/webui/routes.js 的 readBody 对非法 JSON body reject,而 5 处调用点都在路由的 try 之外 await 它——非法 JSON 请求(如 PUT /api/config body 为 not json)使 server 端 async handler 未捕获 reject,客户端永远等不到响应(fetch 挂起 → mocha 用例超时 + after-all 钩子挂起),CI 三平台 test packages/core 全部挂死在同一用例。修复:readBody 解析失败改为 resolve(null),由路由已有的 body 类型校验(isPlainObject/Array.isArray)统一拦截返回 400 INVALID_BODY;POST /api/xray/sticky 补同款校验(原实现 body 为 null 时 body.duration 访问会 TypeError 崩进程)。另修复 configApiSave.test.js 的进程级 HOME 污染:该文件为隔离配置把 process.env.HOME 指向临时目录,但 mocha 单进程顺序执行使后续依赖真实 HOME 的测试(versionTest 等发真实网络请求的用例)读到已删除的目录而失败——after 钩子现恢复原 HOME 并 configApi.reload() 单例,临时目录保留(log appenders 仍指向它,删除会 ENOENT);core 测试脚本加 --timeout 10000(CI 冷环境下首次 require expose 初始化超 mocha 默认 2s)。测试基建(CI 全量挂死的其余根因):(1) xrayGenConfig.test.js 的"零节点无 balancer"断言是 v2.2.7 固定模板行为变更前的过时期望,更新为"零节点也总是生成 balancer";(2) webui.test.js 是集成式测试(14 处路由 require expose 加载完整 app),与同进程其他测试文件交互引发 worker 异常退出(mocha parallel 表现为 Workerpool Worker terminated,单进程表现为文件交接处 exit 1 无汇总)——reInjectXrayRules 改为支持 context.xrayApi 注入(xrayApiOverride !== undefined 即跳过全局 require,测试 fake context 传 xrayApi: null 使写用例不再触达真实 expose),webui.test.js 从全量排除(.mocharc.json ignore)并新增 test:webui script 独立进程运行(--no-config 绕过 ignore);(3) core 测试切换 mocha --parallel --jobs 2(每文件独立 worker 进程,隔离进程级副作用)。全量 pnpm test 131 passing、pnpm run test:webui 50 passing。mitmproxy 测试同治(第三次 CI 失败定位):(1) dnsLookupTest.mjs/dnsTest*.mjs 是上游的真实 DNS 网络测试(DoH 到 quad9 等),GitHub runner 被 quad9 返回 HTML 页导致解析失败——.mocharc.json 的 spec 限定 test/*.js 排除 .mjs(保留本地手动 node test/dnsLookupTest.mjs 运行);(2) wwwAuthenticateTest.js 硬编码 /home/uif79392/.dev-sidecar 绝对路径的 CA 证书——CI runner 必然挂——改为 os.homedir() 推导(支持 DS_CA_CERT/DS_CA_KEY 环境变量覆盖),CA 不存在时 describe.skip 整套件(CI 上 14 passing + 3 pending,本地有 CA 全跑)。(3) tlsUtilsAkiTest.js 的"真实落盘 CA"用例存在两处同型地雷:/home/uif79392/ 硬编码路径 + 箭头函数里 this.skip()(this 非 mocha context,CI 无 CA 走该分支时 this.skip is not a function TypeError 崩掉)——改为同款 homedir 推导 + 用例级条件 it.skip。
  • 修复 Stage3 轮末 Stage2 周期触发是死代码,长期运行服务订阅永不刷新 (fork). refreshCacheFromCacheOnly 入口置 isStageRunning = true(v2.2.7 修复"未设置 isStageRunning"时引入),但"Stage3 后触发 Stage2"的轮末检查用的是 !isStageRunning 守卫——该轮末段运行在 refreshCacheFromCacheOnly 体内,isStageRunning 恒为 true,守卫恒 false,周期性 Stage2 订阅刷新从未执行过(全部历史日志 0 次出现该触发)。影响:dev-sidecar 长期不重启时,subscriptionSyncIntervalHours=24h 的订阅重抓永不发生,免费订阅节点逐渐失效、节点池枯竭;此前靠服务频繁重启(每次启动 start() 触发一次 Stage2)掩盖。修复:守卫改为 !isStage2Running(防与启动时后台 Stage2 并发——isStage2Running 是"订阅抓取进行中"的精确信号),generation 守卫保留。触发块内的冷却判断(shouldSkipRemoteFetchDueToCooldown)已有双保险,无误触发风险。
  • 恢复 Stage1 bootstrap 探测 (fork). v2.2.7 commit 92601af 误删了 Stage1 bootstrap 探测逻辑(readCacheEntriesForStartup → probeNodesBatch → annotateProbeEntries → 按 delay/country/owner 过滤 → 排序 → 切片 startupNodeLimit),导致启动后无节点注入。已重新实现:bootstrap 探测成功后通过 ado 注入 startupNodeLimit 个节点(默认 20)到 live xray 进程。
  • 修复 refreshCacheFromCacheOnly 未设置 isStageRunning (fork). refreshCacheFromCacheOnly 函数入口未设置 isStageRunning = true,导致 Stage3 运行时 Dashboard 显示"空闲"。修复:入口处设 true,finally 块设 false。
  • 修复 WebUI Xray 面板 enabled 读取错误 (fork). 前端从 stage/status 读取 enabled 字段(不存在),导致 Xray 状态显示"关"。修复:改为从 status.plugin.xray.enabled 读取,支持 3 态显示(关/启动中/开)。
  • 修复 xray lso 返回 JSON 字符串未解析 (fork). /api/xray/nodes 接口直接 JSON.stringify 了 xray lso 的 stdout 字符串,前端 nodes.map is not a function 报错。修复:后端 JSON.parse 后取 outbounds 数组。
  • 修复 bootstrapSelectedEntries 变量作用域 (fork). 变量声明在 if 块内部,ado 注入块访问时报 ReferenceError: bootstrapSelectedEntries is not defined。修复:将声明提到外层作用域(与 startupNodes 同级)。
  • 修复 www-authenticate 响应头被错误拆分导致 docker buildx imagetools inspect 失败 (fork). packages/mitmproxy/src/lib/proxy/mitmproxy/createRequestHandler.js 用 split(',') 拆分 www-authenticate 头,无法区分"多个 challenge 之间的逗号"与"单个 Bearer challenge 内 auth-param 参数分隔逗号"。Docker registry 返回的单个 Bearer challenge(Bearer realm="...",service="...",scope="...")被错误拆成多段,导致 docker buildx imagetools inspect 的 Go HTTP 客户端无法解析完整 challenge,OAuth token 获取流程中断,报 unexpected status from HEAD request: 401 Unauthorized。修复:移除 split(','),保留字符串原值传给 res.setHeader()——单行含逗号分隔的多 challenge 符合 RFC 7235,HTTP/1 与 HTTP/2 模块均能正确处理字符串头值。
  • 修复 MITM 叶子证书缺有效 Authority Key Identifier 导致 OpenSSL 3.2+ 验证失败 (fork). packages/mitmproxy/src/lib/proxy/tls/tlsUtils.js 的 createFakeCertificateByDomain / createFakeCertificateByCA 用 { name: 'authorityKeyIdentifier' } 不给 keyIdentifier 值,node-forge 1.4.0 据此序列化出空 AKI(OID 存在但无 keyIdentifier)。OpenSSL 3.2+ 严格校验器判为 Missing Authority Key Identifier 并拒签,经 dev-sidecar 访问任何被 MITM 的 HTTPS 站点都报 SSLCertVerificationError;OpenSSL 3.0 宽松故长期被掩盖。修复:改为 { name: 'authorityKeyIdentifier', keyIdentifier: caCert.generateSubjectKeyIdentifier().getBytes() },叶子 AKI 指向 issuer CA 的 SKI。仅改叶子签发,CA 证书不变,客户端无需重新信任;叶子证书纯内存 LRU 缓存,重启即用新逻辑重签。
  • 修复 configApi.save() 持久化 Xray 插件自动注入的 intercepts 污染 config.json (fork). packages/core/src/config-api.js 的 save(newConfig) 直接用传入的 newConfig(含运行时内存配置)与默认配置 doDiff,把 Xray 插件 injectRules() 注入到 globalConfig.get().server.intercepts 的 desc: 'Auto-injected by Xray Plugin' 条目当作用户自定义差异写入 ~/.dev-sidecar/config.json。任何 configApi.save()/update() 调用(WebUI 保存配置、config reload、GUI 保存)都会污染 config.json,且这些条目下次 xray 启动会重新注入,持久化无意义。修复:save() 深拷贝 newConfig 后过滤掉 server.intercepts 中 desc === 'Auto-injected by Xray Plugin' 的域名条目,再 doDiff,不持久化运行时注入的规则;深拷贝避免污染内存配置,Xray 注入的 intercepts 仍在内存中工作。
  • 修复 WebUI 热重载后 Xray 路由规则未重新注入 (fork). packages/core/src/modules/plugin/webui/routes.js 的 PUT /api/config、PUT /api/xray/rules、POST /api/config/reload 三处路由调用 DevSidecar.api.plugin.xray.injectRules?.() 时未传参数,而 injectRules(rules, port) 签名要求两个参数,首行 if (!rules || !Array.isArray(rules)) return 直接返回,导致热重载(ctxServer.reload() 重置内存 intercepts 为 config.json 状态)后 Xray 的 Auto-injected intercepts 从内存消失,chatgpt/openai/linux.do 等域名流量不再走 xray 隧道,需重启服务才能恢复。根因:configApi.save() 第 236 行 configApi.set(diffConfig) 用过滤后的 diffConfig 重新加载内存配置,导致内存中 Auto-injected intercepts 被覆盖,任何调用 save()/update()/reload() 的路由都受影响。修复:提取 reInjectXrayRules() 辅助函数(先 removeRules() 清空 injectedRules 数组避免累积,再从 globalConfig.get().plugin.xray.rules 和 server.setting.xrayPort 取参数调 injectRules(rules, port),仅在 xray 启用且端口有效时注入),统一应用到 5 处热重载路由:PUT /api/config、PUT /api/intercepts、PUT /api/presetiplist、PUT /api/xray/rules、POST /api/config/reload。PUT /api/intercepts 和 PUT /api/presetiplist 原本完全未调用 injectRules,同样导致 Auto-injected 丢失。
  • 修复 preSetIpList: [] 被持久化并在启动时清空全部预设 IP (fork). 用户报告 ~/.dev-sidecar/config.json 中出现 "server": {"preSetIpList": []},导致依赖预设 IP 的域名(如 google.com)间歇性 ECONNRESET——启动时 getConfigFromFiles() 最后一步 doMerge(merged, userConfig) 用空数组把远程/默认配置合并出的全部预设 IP(63 键对象)整体替换为空(lodash 对"对象 vs 空数组"默认取数组类型),DNS 回退到 cf-DoT 实时解析出被墙 IP。三层缺陷叠加:(1) merge.js 的 doDiff 对数组字段是整段替换语义,空数组也会原样写入 config.json;doMerge 未防御"srcValue 是空数组、objValue 是普通对象"的情况,仅处理了反向(objValue 是数组)。(2) PUT /api/presetiplist 等三个 WebUI 写路由无 body 类型校验,[] 直接持久化。(3) packages/mitmproxy/src/index.js 的 updateConfig() 用 if (newProxyOptions.dnsConfig.preSetIpList) truthy 判断,而 domainMapRegexply([]) 返回 {origin:{}} 为 truthy,热重载时空表覆盖内存中正确的 preSetIpList,且 SpeedTester 构造时捕获 dnsMap,老域名保留旧表、新域名拿到空表,造成"时好时坏"的间歇故障。修复:(1) doMerge customizer 新增防御:srcValue 是空数组且 objValue 是 plain object 时返回 objValue(数组字段如 plugin.xray.rules 对空数组的整体替换语义不受影响);同时 doDiff 写入侧新增对称防御:新值为空数组且旧值为 plain object 时跳过不写入 diff,避免 [] 持久化污染 config.json,且已有脏数据在下次 save() 时自愈消失(diff 不再产生该差异,文件整体重写);(2) PUT /api/config、PUT /api/intercepts、PUT /api/presetiplist 要求 body 为 JSON object,PUT /api/xray/rules 要求 body 为 JSON array,其他类型返回 400 INVALID_BODY;(3) updateConfig 改为 origin 键数 > 0 才替换 preSetIpList(判断逻辑提取为 options.js 的 shouldReplacePreSetIpList 导出函数以便测试)。补充:SpeedTester._doTest 中曾测速成功的 IP(item.time 非空)后续失败不会被标记 failed(第 219 行 if (item.time == null) 才标记),持续失败会留在 pickNextForProbing() 轮转池中;但因 reportProbeResult(按需探测反馈)已无条件标记失败,实际影响有限,且无条件降级会牺牲网络抖动容忍(上游原设计意图),暂不改动。
  • 修复 sticky 锁定在 observatory 首轮探测前被提前解除 (fork). 用户报告 dev-sidecar 启动后 observatory 还没开始第一次探测,锁定就被解除,xray 主进程 balancer 无节点可选。两个时序缺陷:(1) Stage3 热刷新路径(maybeRegenerateLiveConfigFromCache)在移除 sticky 锁定节点时裸调 removeBalancerOverride 解锁——实测启动后仅 29 秒即解锁(sticky 节点恰好在移除集中),远早于 300s 探测周期;(2) 300s 自动解锁定时器假设 observatory 一轮内必然完成探测,但 observatory 只在每个周期边界发现 ado 注入的新节点——节点在周期开始后才注入时要再等一个完整周期(最多 600s),解锁时数据仍可能为空。balancer 的 leastPing 策略无 observatory 数据时不选择任何节点(v2.2.6 已移除 fallbackTag: direct 防止暴露真实 IP),流量直接失败。修复:提取 fetchLiveObservatoryAliveCount() 辅助函数(查询主进程 /debug/vars 的 observatory alive 节点数);新增 armStickyAutoUnlock(logLabel, delayMs) 共享函数,到期时先检查 observatory 存活节点数,为 0 则延长锁定 60s(最多 5 次扩展),Stage1 bootstrap 定时器与 enableSticky 自动释放定时器统一改用之(同时消除 disableSticky 中的重复内联代码);热刷新移除 sticky 节点时改为改锁新节点——从缓存查询剩余活节点延时(Stage3 刚用 observatoryProbeUrl 探测过,数据可信),改锁延时最低者并重置 300s 自动释放定时器,无候选时才解除锁定。实测:300s 定时器到期后正确延长 60s,observatory 第二轮探测完成后安全解锁,balancer leastPing 恢复正常选节点。新增 xrayObservatoryMetrics.test.js(10 用例)覆盖 getObservatoryStatusMap 的 4 种 metrics 键名兼容解析与 alive 计数语义(0=延长锁定、-1=metrics 未就绪),防止键名解析回归导致防护静默失效。