Skip to content

design(#5221): true non-restart self-heal of a stale host resolver (fresh-resolver over admission AND egress) #5323

Open

Description

@ranxianglei

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 hard part (verified, #5221 analysis)

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:

  1. fresh dns.Resolver whose nameservers are read LIVE from /etc/resolv.conf (or the effective platform config), rebuilt on demand;
  2. inject that lookup into undici dispatcher(s) and any other native-lookup call site (audit all: web fetches, update checks, registry probes);
  3. fall back to the fresh resolver automatically when wedge conditions from the Long-lived proxy wedges on local DNS after WSL network/VPN changes — tunnel admission stays unresolvable (502) until process restart #5221 detector trip — or probe it in shadow mode first;
  4. IPv6/happy-eyeballs semantics must match what getaddrinfo gives today (no silent behavior drift on healthy hosts);
  5. exit criteria: on a repro box (WSL + Clash TUN toggled while bili stays up, Long-lived proxy wedges on local DNS after WSL network/VPN changes — tunnel admission stays unresolvable (502) until process restart #5221 recipe), traffic recovers without restart; on a healthy box, byte-identical routing vs today.

Why separate from the alarm PR

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).

Activity

  1. ranxianglei commented on Oct 31, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    (ai: vllm-qwen/qwen3.8-27b)

    查重:不重复。 本票是你在 #5221 裁决(floor 7:「真·不重启自愈…单独立项设计,不与本 PR 捆绑」)后亲自立的跟踪票,open/closed 里没有第二个条目覆盖同一范围。相关条目均为互补、非重复:

    标签: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)

    调用点 角色 wedge 时影响 本设计处置
    src/tunnel-guard.ts:1 dns/promises lookup 隧道准入 3×2s 全部请求 502 注入 native→fresh 兜底层
    src/fetch-util.ts:89 undici Agent(directDispatcher,按 timeout+CA 缓存) 模型流量直连热路径 模型流量停摆 注入 connect.lookup
    src/upstream-proxy.ts:413-421 Pool/ProxyAgent -F 代理出站(代理主机本身需本地解析) 低(通常 localhost) 同一行注入
    ~25 个文件的 global fetch()(update.ts / advisory / plugin-install / web client 等) 自更新、插件安装、web 抓取 episode 期间自更新等死亡 v1 默认不覆盖,见下方附议 1
    src/liveness.ts:57、src/restart.ts:149 net.connect localhost 就绪探测 无(loopback 不走网络解析) 不动
    src/responses-ws.ts:354 UpstreamWebSocket 接收我们的 dispatcher 自动继承修复 不动

    关键可行性事实:

    平台边界(诚实声明):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。注入点:

    1. 准入(tunnel-guard):主池 3×2s 耗尽后走一次 resilientLookup,成功则进既有分类管线(fe80/self-port/link-local/private+allowlist 一个不松,安全边界不变);
    2. 出站热路径(fetch-util directDispatcher):connect.lookup = resilientLookup;
    3. upstream-proxy ProxyAgent:同样注入(代理主机);
    4. 可观测性:/__bili/status 加 rescue 计数(fresh 解析器救回 N 次解析/连接)+ 去重 info 日志(10min 间隔,沿用 detector 的 LOG_DEDUP_MS 惯例)——操作者能看到「正在自愈」。

    触发策略对比(issue 第 3 条的三个候选):

    • 逐次 failover(推荐):每次失败路径都 native→fresh,第一个失败请求内即愈合,最坏给那次本来就要失败的请求加 2–3s;健康主机零开销(native 不 throw 就永远到不了 fresh);
    • detector 触发([PR] fix(observability): surface suspected DNS-resolver wedge as logged + panel alarm (#5221 phase 2) #5322 三重门):健康成本字面为零,但每个 episode 前 ≥5 分钟用户持续吃 502,且 detector 只采样准入解析——目的地不走隧道检查的会话可能永不触发它;
    • shadow 模式先行:被「逐次 failover + rescue 日志」天然包含——rescue 日志就是 shadow 观测,repro box 退出标准直接验证,无需单独阶段。

    结构化八问(决策请求版)

    1. 是什么问题:长驻代理在 WSL/VPN 切换后本地名解永久卡死,模型流量 502 停摆直到手动杀 pid 重启;按你的裁决,终局自愈形态必须是不重启的。
    2. 什么原因导致的:OS 层进程内 getaddrinfo 状态污染(进程内修不了、Node 无 reset API),且 bili 的准入与出站共用这同一个不可刷新解析器、没有任何 bili 自有的替代路径。
    3. 影响范围与严重程度(分选项):
      • A(无配置、always-on native-first 兜底):波及面 = 全部直连模型流量 + 准入;健康机器行为字节级不变(新路径不可达),卡死机器数秒内无重启恢复;把 p1 停摆真正解除。
      • B(default-on + opt-out 开关):行为同 A,多一个逃生配置项,供「native-first-fresh-second 可能误伤」的特殊环境关闭。
      • C(opt-in 默认关):零风险,但修复对几乎所有人出厂即失效——等于停留在报警阶段,与裁决意图相悖。
    4. 怎么修复的(推荐 A):bili 自建解析器(dns.Resolver + 实时 resolv.conf nameservers/search),注入准入 + undici 出站两处热路径,native-first fresh-second;健康主机可证零漂移,卡死主机自动救回;单个闭环 PR(模块 + 两处注入 + status 可观测 + 双向测试 + 文档)。
    5. 是否有回归问题(分选项):
    6. 是否破坏缓存:否。前缀缓存/cache ledger/重试游标都在 DNS 层之上;且不重启 = 无进程交接,会话持久化零影响。
    7. 是否改了设计方向:否。架构边界不动;只是让 bili 拥有自己的名字解析能力(host 侧韧性层),与 Hook-model clients (claude/zcode) have no mid-session self-heal when the proxy dies — ECONNREFUSED until next session #1821 自愈方向、After a WSL network/VPN switch, local DNS on the long-running proxy permanently fails; tunnel admission remains unresolv | 长驻代理在 WSL 网络/VPN 切换后本地 DNS 永久失效,隧道准入持续 unresolvable(502),只能重启进程恢复 #5222 Track① 一致。
    8. 是否引入新配置:A 不引入任何配置(仓内纪律:env/config 面稀缺,新增 key 须论证现有面无法表达——这里确实没有 resolver 字段,但「纯逃生开关」式 key 恰是 config-surface 纪律反对擅自发明的形态)。B 则增加一个 opt-out key(如 BILI_RESILIENT_DNS=0,默认开),PR 会带 config-surface 声明节。

    选项对比:

    A:always-on native-first(无配置) B:default-on + opt-out key C:opt-in 默认关
    卡死机器无重启恢复 ✅ ✅ ❌(需手动开)
    健康机器行为 字节级相同 字节级相同 字节级相同
    新增配置面 无 1 个 opt-out key 1 个 opt-in key
    特殊环境逃生 发版回滚(模块是纯加法层,回滚=摘注入点) 开关 开关
    与裁决意图一致 ✅ ✅ ❌

    我的推荐是 A:native-first 的门让新路径在健康主机上结构性不可达——没有可观测的行为变化需要防,逃生开关就缺少具体保护对象;真在特殊环境出问题,发版回滚足够。若你想要开关,B 也可接受,我照做并带 config-surface 声明。

    两个附议小决策(可与上面一起拍):

    1. 覆盖范围:v1 = 准入 + 模型出站热路径 + 代理主机。~25 个 global-fetch 调用点(自更新/插件安装/web 抓取)在 episode 期间仍会死——但模型流量已可用、不构成死锁(用户已在跑含修复的版本,自更新没有要追的目标了),列为本票内的后续子项(同机制家族,按粒度规则不拆票);你要 v1 全覆盖也可以,代价是多改一批文件的 fetch 选项。
    2. 排序:建议在 [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 合入 master 之后再落本 PR(tunnel-guard.ts 三方语义冲突风险最低);若你想并行,我按 union-of-intent 处理冲突。

    判定:等你拍板 A/B/C + 两个附议;拍板后立即开分支,产出一个闭环 PR(Closes #5326),定向测试本地跑、全量以 CI 为准。

  2. ranxianglei commented on Oct 31, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    (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 交换)。推论:

    • 健康主机上我们的 wrapper 就是原样转发给原生 dns.lookup(undici 默认路径本来就是它),结果数组逐字透传 ⇒ 路由字节级不变,退出标准第 5 条结构性成立;
    • fresh 兜底路径只在卡死主机上运行,其结果顺序 = 解析器返回序:单族天然正确;双栈需做确定性交错排序(glibc 的 RFC 6724 排序在兜底路径上用简化规则复刻并文档化)——这是唯一的行为差异面,且被限制在本来就要卡死的主机上。

    另一个顺带发现:本机 c-ares 发出的查询不带 EDNS(纯 29 字节),repo 测试里的假 DNS 服务端可以按最简格式写。

    这些不改变上面的选项与推荐(仍推荐 A),只是把「实现时才发现的坑」提前消掉了。等你拍板 A/B/C + 两个附议(global-fetch 覆盖范围、是否等 #5272/#5322 先合)。

  3. ranxianglei commented on Oct 31, 2026

    @ranxianglei
    OwnerAuthor

    (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.

  4. ranxianglei commented on Oct 31, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    (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} 数组回包、拨号顺序=数组顺序)直接作为模块的实现依据。

  5. ranxianglei commented on Oct 31, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    (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(三个选项下逐字相同)并行推进中,拍板后只剩接线一步。

  6. ranxianglei commented on Oct 31, 2026

    @ranxianglei
    OwnerAuthor

    🤖 Powered by ework · qwen3.8-27b

    (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(commit a6d7a63ad)——按闭环纪律暂不 push、不开 PR,拍板后接线一步到位再开。

    实现要点(与上条设计一致,补两个实现期发现):

    • native-first 逐字透传:原生 getaddrinfo 成功时结果原样回包(形状+顺序即路由保证),fresh 路径在健康主机上结构性不可达;
    • Node v22 没有 Resolver.setSearch(promises/callback 两版都实测不存在,只有 setServers)——search 域按 glibc ndots=1 语义手动展开(短名 suffix-first、点域名原名优先、尾点不展开),三条行为都有定向测试钉住;
    • 诚实失败:fresh 也失败时原样返回原生错误对象(不包装、不换码),调用方看到的错误与今天逐字相同;rescue 计数 + 10 分钟去重 info 日志为观测面预留。

    顺手小修(已直接修掉):#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/status rescue 计数面、文档,然后开一个闭环 PR。#5272 PR 随时可合,不互相阻塞。

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions