You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
design(#5221): true non-restart self-heal of a stale host resolver (fresh-resolver over admission AND egress) #5323
Follow-up to the #5221 ruling thread: owner chose alarm-only for now ("如果要自愈需要以不重启的方式自愈,这个顶多报警") — auto re-exec stayed out of scope and the detection+visibility phase shipped as its own PR (branch 2026-10-31_dns-wedge-alarm, see that PR for the shipped surface).
This issue tracks the actual non-restart heal so it never gets lost in alarm-land:
Goal
A wedged long-lived proxy (stale OS resolver state after WSL/VPN churn) should be able to recover model traffic WITHOUT killing the process.
The poisoned state lives at OS level inside the process: glibc/libuv keep a stale view of /etc/resolv.conf + resolver state after the tunnel endpoint changes, and Node exposes no reset API. bili EGRESS runs undici → net.connect → native getaddrinfo — the same unrefreshable resolver used by admission. So a real fix must override BOTH admission AND every egress connection with a resolver bili builds itself:
fresh dns.Resolver whose nameservers are read LIVE from /etc/resolv.conf (or the effective platform config), rebuilt on demand;
inject that lookup into undici dispatcher(s) and any other native-lookup call site (audit all: web fetches, update checks, registry probes);
Hot-path surgery on ALL model traffic (owner spec §7.4 human-gate class), plus the "fallback values / defaults" clause. It needs its own design pass + probably an explicit opt-in config, owner-design territory.
Labels suggested: p1 (availability), scope/wiring, scope/kernel NO (host-side only unless kernel lookup helpers wanted).
Cross-link filed while triaging #5221: PR #5272 (open, "Closes #5222") implements a fresh worker-thread resolver PROBE at the admission gate — if the worker resolves, admission passes even though the process-native lookup pool is wedged.
That is a genuine first step toward non-restart recovery, and it overlaps this issue — but it deliberately stops at ADMISSION: egress (undici → net.connect → native getaddrinfo) still runs the poisoned resolver, so under an in-process wedge model traffic can still die after being admitted. This issue therefore tracks the remaining delta, NOT the whole of #5272:
egress-side coverage: route outbound model traffic through the same fresh resolver (or feed the probe results into connection setup);
Activity
ranxianglei commentedon Oct 31, 2026
(ai: vllm-qwen/qwen3.8-27b)
查重:不重复。 本票是你在 #5221 裁决(floor 7:「真·不重启自愈…单独立项设计,不与本 PR 捆绑」)后亲自立的跟踪票,open/closed 里没有第二个条目覆盖同一范围。相关条目均为互补、非重复:
/__bili/status/ 日志)——只报警,不自愈;标签:p1 + scope/wiring(跟踪的是 p1 可用性缺陷——模型流量停摆至重启;属宿主侧 DNS 出站路径,非 kernel)。注:本环境镜像 API 未实现 labels 端点,无法经 API 打标,在此申报。
Triage 结论(设计类 → 先评估后动手)
合理性:成立。 裁决后剩下的唯一自愈形态就是不重启自愈,而 #5221 的机制分析已钉死为什么必须这么做:卡死的是进程内 OS 层 getaddrinfo/libuv 状态,Node 无 reset API;出站(undici → net.connect → getaddrinfo)与准入共用同一个不可刷新的解析器——所以「只换准入解析路径」(#5272 做的)顶多让准入放行,出站连接照样挂。进程内唯一的逃生口是 bili 自建解析器:
dns.Resolver自己实现 UDP/TCP DNS 查询、完全不经过 getaddrinfo/libuv 线程池,nameservers 从 /etc/resolv.conf 实时读取。这个方向是对的,且是 issue 目标里列的五条硬约束都能满足的唯一机制。系统契合/耦合:纯 host 侧(src/),零 kernel 变更、零 wire 格式变更,位于两种压缩模式之下(DNS 在 wire 层以下)。耦合面集中在两个文件:
src/tunnel-guard.ts(准入)与src/fetch-util.ts(出站 dispatcher)——正是 #5272 与 #5322 在改的热文件,需要排序/rebase,但无架构冲突。不依赖决策的部分(已完成):全量原生解析调用点清点(origin/master)
src/tunnel-guard.ts:1dns/promises lookupsrc/fetch-util.ts:89undiciAgent(directDispatcher,按 timeout+CA 缓存)connect.lookupsrc/upstream-proxy.ts:413-421Pool/ProxyAgent-F代理出站(代理主机本身需本地解析)fetch()(update.ts / advisory / plugin-install / web client 等)src/liveness.ts:57、src/restart.ts:149net.connectsrc/responses-ws.ts:354UpstreamWebSocket关键可行性事实:
new dns.Resolver()+setServers(实时解析的 resolv.conf nameservers)+setSearch(search 域)完全绕开被污染的 getaddrinfo——修复核心;^7.29.0)Agent 的connect选项支持自定义lookup;注入后必须保证健康主机上地址顺序与 getaddrinfo 一致(RFC 3484 排序),否则 happy-eyeballs 路由漂移——实现时的测试钉死点;平台边界(诚实声明):Linux/WSL/macOS 有 /etc/resolv.conf 可实时读取,完整生效;Windows 无此文件、有效配置即系统解析器本身,v1 在 Windows 上保持 native-only(该平台的 wedge 场景罕见,发生时仍走报警+重启通道),文档写明。
设计方案(待拍板)
新模块
src/fresh-resolver.ts:实时解析 /etc/resolv.conf(nameservers + search),包装dns.Resolver,有界 TTL 缓存 + 按需重建;暴露单一入口resilientLookup(host, opts, cb)= native-first、fresh-second。注入点:connect.lookup = resilientLookup;/__bili/status加 rescue 计数(fresh 解析器救回 N 次解析/连接)+ 去重 info 日志(10min 间隔,沿用 detector 的 LOG_DEDUP_MS 惯例)——操作者能看到「正在自愈」。触发策略对比(issue 第 3 条的三个候选):
结构化八问(决策请求版)
unresolvable逐字不变;④ 与 [PR] fix: tunnel admission survives a wedged process-local DNS resolver + wedge detection & visibility (#5222) #5272/[PR] fix(observability): surface suspected DNS-resolver wedge as logged + panel alarm (#5221 phase 2) #5322 的热文件冲突:排序在其合入之后 + rebase,union-of-intent 解决。均不触碰 wire/持久化/kernel,两种压缩模式不受影响。BILI_RESILIENT_DNS=0,默认开),PR 会带 config-surface 声明节。选项对比:
我的推荐是 A:native-first 的门让新路径在健康主机上结构性不可达——没有可观测的行为变化需要防,逃生开关就缺少具体保护对象;真在特殊环境出问题,发版回滚足够。若你想要开关,B 也可接受,我照做并带 config-surface 声明。
两个附议小决策(可与上面一起拍):
判定:等你拍板 A/B/C + 两个附议;拍板后立即开分支,产出一个闭环 PR(Closes #5326),定向测试本地跑、全量以 CI 为准。
ranxianglei commentedon Oct 31, 2026
(ai: vllm-qwen/qwen3.8-27b)
补充:等拍板期间把设计里风险最高的技术前提做了本地可行性验证(undici 7.30.1 / Node v22.23.2,与本仓
^7.29.0同线;全程 hermetic——本地 UDP 假 DNS + 本地 HTTP,不出网)。三个关键契约全部实测钉死:1. fresh 解析器机制成立。
node:dns/promises的Resolver(注意:是 promises 版,node:dns的回调版没有 promise API)+setServers(["127.0.0.1:<live-port>"])实时指向任意 nameserver,resolve4对假 DNS 1ms 返回;无记录时干净地抛ENODATA(错误分类清晰,可区分「解析器可达但无记录」与「解析器不可达」)。完全不经过 getaddrinfo/libuv 线程池——绕开污染状态的核心机制确认。2. undici 注入点契约。 Agent/Pool 的
connect.lookup被调用时的实际参数是{ hints: 0x20, all: true }(fetch/Agent 路径多一个值为 undefined 的family键),且 callback 必须用数组形式回包cb(null, [{address, family}, ...])——单值形式会直接ERR_INVALID_IP_ADDRESS。这是实现里最容易踩的坑,已记入实现清单。3. happy-eyeballs 语义(退出标准第 4 条的关键)。 带自定义 lookup 时,undici 走 Node 的
lookupAndConnectMultiple:返回数组的顺序 = 拨号优先级——首址先拨、成功即赢、失败才轮到下一址(两组顺序各跑一发验证:[A,B]只拨了 A,[B,A]只拨了 B,均 1–10ms 完成完整 HTTP 交换)。推论:dns.lookup(undici 默认路径本来就是它),结果数组逐字透传 ⇒ 路由字节级不变,退出标准第 5 条结构性成立;另一个顺带发现:本机 c-ares 发出的查询不带 EDNS(纯 29 字节),repo 测试里的假 DNS 服务端可以按最简格式写。
这些不改变上面的选项与推荐(仍推荐 A),只是把「实现时才发现的坑」提前消掉了。等你拍板 A/B/C + 两个附议(global-fetch 覆盖范围、是否等 #5272/#5322 先合)。
ranxianglei commentedon Oct 31, 2026
(ai: sglang-gpu0/qwen3.8-27b)
Cross-link filed while triaging #5221: PR #5272 (open, "Closes #5222") implements a fresh worker-thread resolver PROBE at the admission gate — if the worker resolves, admission passes even though the process-native lookup pool is wedged.
That is a genuine first step toward non-restart recovery, and it overlaps this issue — but it deliberately stops at ADMISSION: egress (undici → net.connect → native getaddrinfo) still runs the poisoned resolver, so under an in-process wedge model traffic can still die after being admitted. This issue therefore tracks the remaining delta, NOT the whole of #5272:
Suggested coordination order (per #5322 review): merge #5322 first, then rebase #5272 and adjust the hook semantics noted above.
ranxianglei commentedon Oct 31, 2026
(ai: vllm-qwen/qwen3.8-27b)
收到,三点确认:
1. 范围界定同意。 本票跟踪的是剩余 delta:egress 侧覆盖(出站模型流量走 fresh 解析器)+ detector 耦合修复,不重复 #5272 已做的准入探针。「把探针结果喂给连接建立」的替代形态我评估过、不取:worker 探针只产出一个 IP(单族、无排序语义),喂进连接会丢掉 happy-eyeballs 双栈行为;统一走同一个
resilientLookup(native-first、fresh-second)准入与出站共享一套判据和一套测试,更收敛。2. detector 耦合采用「主池耗尽即记失败证据」方案(你列的第二条)。理由:detector 的职责是监测原生解析器健康度,不是端到端判决——救援层(#5272 的 worker 探针、本票的 fresh 兜底)都叠在它之上。具体缝:checkTunnelDestination 向 detector 上报两个事实而非一个——原生池结果 + 最终判决,detector 只消费前者;这样三重门逻辑一个字符不用改,卡死场景下报警照常响(哪怕流量已被救回),整机真故障时也不误报(原生池失败但 fresh/worker 也失败 → 仍记失败)。这条随 #5272 rebase 时以独立标注 commit 落进去(引用本票),不另开条目——同机制家族,不拆片。
3. 协调顺序确认:#5322 合入 → rebase #5272 + hook 语义调整 → 本票 PR 垫底其上。 合并动作在你;#5322 一落地我立即做 #5272 的 rebase + hook commit,然后本票开工。
还剩一个待拍板项(上条回复的 A/B/C):默认值。注意到你立票时写的是「probably an explicit opt-in config」——那对应我的选项 C(默认关),意味着修复出厂即休眠、所有人要手动开才生效,与裁决里「自愈必须真的自愈」的意图有张力;我的推荐仍是 A(native-first 让新路径在健康主机上结构性不可达,没有可观测漂移需要开关来防;出问题发版回滚即可),退而求其次 B(default-on + 一个 opt-out key,PR 带 config-surface 声明)。这一项定了即可全速推进。
不等拍板已在做的部分(无决策依赖):核心模块
src/fresh-resolver.ts(实时 resolv.conf 解析 + promises Resolver 封装 +resilientLookup入口 + 指标计数)在三个选项下逐字相同,只有接线处的门控不同——现在把它和双向测试写完,拍板后只剩接线一步。spike 已验证的三个契约(promises Resolver、undici{hints, all:true}数组回包、拨号顺序=数组顺序)直接作为模块的实现依据。ranxianglei commentedon Oct 31, 2026
(ai: vllm-qwen/qwen3.8-27b)
协调进度:你定的顺序第 2 步已完成——PR #5272 已同步 post-#5322 的 master(union-of-intent merge,细节在其线程)。你点名的耦合语义已确认落在代码里:主池判决在 worker stage 之前记录,worker 救回的准入仍计为楔死证据、报警照常响;#5322 失败路径的即时求值(warn 日志 trip 当下触发)也一并保留。本地 typecheck + 定向套件全绿,全量以 CI 为准。
本票继续等 A/B/C 默认值拍板;核心模块
src/fresh-resolver.ts(三个选项下逐字相同)并行推进中,拍板后只剩接线一步。ranxianglei commentedon Oct 31, 2026
(ai: vllm-qwen/qwen3.8-27b)
无决策依赖的部分已完成并本地落定:
src/fresh-resolver.ts(核心模块)+tests/fresh-resolver.test.ts(14 个测试全绿,~230ms,hermetic 假 DNS UDP 服务器,无外网),typecheck 干净。已提交到本地分支2026-10-31_fresh-resolver-heal(commita6d7a63ad)——按闭环纪律暂不 push、不开 PR,拍板后接线一步到位再开。实现要点(与上条设计一致,补两个实现期发现):
Resolver.setSearch(promises/callback 两版都实测不存在,只有 setServers)——search 域按 glibc ndots=1 语义手动展开(短名 suffix-first、点域名原名优先、尾点不展开),三条行为都有定向测试钉住;顺手小修(已直接修掉):#5272 PR 分支里一条注释把本票编号写成了镜像号 5326(仓库惯例用上游号 5323)→ 独立 commit
12b06735f已推送到该分支,不影响其 CI 语义。当前唯一阻塞项 = 你的 A/B/C 裁决(推荐不变:A 无配置 always-on native-first;退而求其次 B default-on + opt-out key)。裁决下来即一次完成:admission 接线(叠在 #5272 hook 之上、detector 证据继续来自主池)、directDispatcher/ProxyAgent egress 注入、
/__bili/statusrescue 计数面、文档,然后开一个闭环 PR。#5272 PR 随时可合,不互相阻塞。