Skip to content

[dsh] native proxyEnvOrigin() 把临时 origin 冻结进 BILLION_CONTEXT_PROXY,绕过 ensureProxyRunning() 的 instance 验活;工具调用全部 fetch failed,需重启宿主 #983

Closed

Description

@Vergemesh
## 现象

DSH Desktop 宿主内,billion-context 注册的全部 6 个工具(`compress` / `decompress` / `search_context` / `acp_status` / `acp_cache` / `absorb`)**同时**返回:

Error: fetch failed


而代理侧完全健康。同一时刻从外部(PowerShell `Invoke-WebRequest`,走 .NET 栈、不经过 `globalThis.fetch`)访问同一批端点全部正常:

GET http://127.0.0.1:<代理端口>/__bili/plugin/manifest → 200
GET http://127.0.0.1:<代理端口>/__bili/plugin/status?conversationId=… → 200
POST http://127.0.0.1:<代理端口>/__bili/plugin/tool {tool:"acp_status"} → 200


`bili.log` 与 `$TEMP\bili-proxy-<port>.log` 持续正常输出 `forward POST → https://api.deepseek.com/chat/completions`;对这四个日志全文检索 `fetch failed` / `EADDRINUSE` / `ECONNREFUSED` / `EACCES`,**计数全为 0**。

**只有重启 DSH Desktop 才能恢复**,`acp_status` 立刻可用。

## 关键判据

> 宿主内工具调用失败,但同端点的外部 HTTP 调用成功 → 故障在宿主的 fetch 路径 / origin 取值,不在代理服务。

## 根因:写侧冻结了 ephemeral origin,短路掉读侧已有的验活发现

同一个文件 `dist/agent/dsh-native.js` 里,**同一件事有两套实现:一套验活,一套不验活**。

**读侧(有验活)** — `ensureProxyRunning()` `:44025-44029`:

```js
const existing = await probeExistingInstance(readInstance, fetchHealthInfo);
if (existing && instanceCompatible(existing, opts)) {
  console.error(`bili: attaching to running proxy at ${existing.origin} (pid ${existing.pid})`);
  return { origin: existing.origin, port: existing.port, attached: true };
}

probeExistingInstance(:43947-43950)里第一件事就是 if (!isPidAlive(inst.pid)) return void 0; —— 完整发现链:instance 文件 → pid 存活校验 → health probe → instanceCompatible。

写侧(冻结) — bootstrap() :44492:

process.env.BILLION_CONTEXT_PROXY = handle.origin;   // 写入后永不清除

全文件对该变量的 delete 只有 1 处(:44605,onGiveUp),覆盖不全。

而 attach 路径完全不验活 —— planNativeDsh() :44421 把 env 排在最前:

const attachOrigin = nativeAttachOrigin(env) ?? proxyEnvOrigin(env);
if (attachOrigin !== void 0) return { mode: "attach", attachOrigin };

proxyEnvOrigin()(:44151-44162)全程只做字符串/new URL() 语法校验,没有任何网络或 pid 校验:

if (url.length === 0 || !/^https?:\/\//i.test(url)) return void 0;
try { new URL(url); } catch { return void 0; }     // ← 仅语法校验
return url.replace(/\/+$/, "");

apply() 的 attach 分支(:44591-44599)拿到它之后直接采信:

state.attach = true;
state.origin = plan.attachOrigin;
state.ready = Promise.resolve(plan.attachOrigin);   // ← 立即 resolve,从不探活
register.base = plan.attachOrigin;
process.env.BILLION_CONTEXT_PROXY = plan.attachOrigin;

机制四步:

  1. 以 spawn 模式自举成功 → bootstrap() 把实际端口写进 process.env.BILLION_CONTEXT_PROXY(:44492)。
  2. 该变量在进程内一直保留。
  3. 下一次同进程内 apply():proxyEnvOrigin() 读到旧端口 → 判定 mode = "attach"(:44422);同时 nativeBootstrapGate()(:44147)因为该变量非空而拒绝重新自举。
  4. attach 不验活 → register.base = 旧端口 → forwardTool()(:44382-44392)把请求打向已失效地址 → undici 抛 TypeError: fetch failed。

这同时解释了为什么代理侧日志零错误:请求根本没送到活着的代理,而是打向了一个死端口。

为什么重启就好:进程重建 → process.env 全新 → 走 spawn 分支 → 由上面那套有验活的 ensureProxyRunning() 正常发现/自举。

与 #926 的关系

#926 / PR #936 修的是同一个模式:写侧把 ephemeral origin 冻结进 BILI_MCP_PROXY,短路了 resolveProxyOrigin() 已有的 pid-liveness 发现,结论是「只有用户显式指定 origin 时才落盘」。

本 issue 是同一根因在 dsh native 通道上未被覆盖的残留:同样的「冻结短路验活」,变量名换成 BILLION_CONTEXT_PROXY,受害读侧换成 ensureProxyRunning()。附注:0.1.120 / 0.1.121 均发布于 #936 之后(0.1.120 @ 2026-09-18T14:11Z、0.1.121 @ 2026-09-19T04:05Z,而 #936 于 2026-09-18T08:39Z 关闭),因此这是当前版本上仍然存在的行为。

建议修复(按 #926 既有结论,保持一致)

  1. 不冻结:bootstrap() 写入的 origin 与「用户显式设置的 origin」分离——后者用独立变量名(如 BILI_INTERNAL_PROXY),避免污染 attach 判定。
  2. attach 前验活:attach 分支不要 Promise.resolve(attachOrigin),改走 probeExistingInstance / health probe;失败则 delete process.env.BILLION_CONTEXT_PROXY 并回退到 spawn。
    • 最小改动版:planNativeDsh 里对 proxyEnvOrigin() 的结果加一次 GET /__bili/plugin/manifest(现有 MANIFEST_TIMEOUT_MS = 5e3)。
  3. 补齐清理点:进程退出 / 插件 dispose 时也清 process.env.BILLION_CONTEXT_PROXY(目前仅 onGiveUp 一处)。

一并报告的两个同文件缺陷

(B) fetch 劫持让「未就绪」静默惩罚,且真因被吞

installNativeFetchIntercept() 在 :44324 执行 globalThis.fetch = patched(全局劫持宿主 fetch)。

:44264-44267:

async function readyOrigin(state2) {
  if (state2.origin !== void 0) return state2.origin;
  return withTimeout(state2.ready, state2.readyTimeoutMs ?? 15e3);   // 默认 15s
}

:44251-44263 的 withTimeout 在 catch 里 return void 0 —— 吞掉一切异常。后果:spawn 模式下 state.ready 是长跑 promise,期间每一条模型请求都要在 await readyOrigin(state2) 处白等最多 15s,超时后静默降级直连,只 console.error 一行(warned 标志位保证不再重复)。

实测复现(独立 node 进程):

withTimeout(Promise that never resolves, 15000)  →  returned undefined after 15001 ms

建议:patched 里 state.origin === undefined 时应立即直连,不 await;withTimeout 不要吞掉 err(cause 被剥掉正是本次一开始被 fetch failed 误导的原因)。

(C) onGiveUp 与 maybeRetry 自相矛盾,give-up 后无法自愈

:44604-44608:

state.onGiveUp = () => {
  delete process.env.BILLION_CONTEXT_PROXY;
  register.base = void 0;          // ← 清空
  register.toolsReady = false;
};

:44537-44543:

function maybeRetry(ctx) {
  if (register.dead || register.toolsReady || register.base === void 0) return;   // ← 却要求 base !== undefined
  ...
}

registerTools() 自身同样要求 base !== void 0(:44520)。于是 give-up 之后工具注册被钉死在「未注册 + 无法重试」,只能重启宿主。

环境

项 值
OS Windows
Node v24.19.0
宿主 @deepseek-ai/dsh@0.1.2-alpha.5(DSH Desktop,profile web)
插件 billion-context@0.1.121(profile package.json 声明 ^0.1.120)
分析文件 dist/agent/dsh-native.js(1,993,893 bytes / 44,678 行)

已排除

假设 排除依据
代理进程挂了 同端点外部 Invoke-WebRequest 返回 200
代理内部报错 spawn 日志中 fetch failed/EADDRINUSE/ECONNREFUSED/EACCES 计数全 0
双重 fetch 劫持 :44270 幂等守卫 if (g[INTERCEPT_FLAG] === true) return false;
工具未注册 外部 POST /__bili/plugin/tool 能正常执行 acp_status
URL 判定误伤 /__bili/ 实测 isModelApiUrl("…/__bili/plugin/tool") === false
环境变量被系统持久化 User 级 / Machine 级环境变量均为空,settings.yaml 与 overlay 中也无设置

Activity

  1. Vergemesh commented on Sep 19, 2026

    @Vergemesh
    Author

    comment 补充
    安装内容与目录名/锁文件错位

    取证于本机 Windows + pnpm profile,非推测,全部可复现。
    状态:已在本机修复并验证(见文末「修复与验证」)。

    结论

    pnpm 按 0.1.120 的 integrity 校验/记账,却把 0.1.121 的内容落到了 billion-context@0.1.120 这个目录名下:
    目录名说 0.1.120、package.json 说 0.1.121、pnpm-lock.yaml 说 0.1.120 —— 三者不一致。

    证据链

    # 检查项 结果
    1 npm 0.1.120 tarball 内 package.json version: 0.1.120,dist/agent/dsh-native.js = 1,988,893 B
    2 npm 0.1.121 tarball 内 package.json version: 0.1.121,dist/agent/dsh-native.js = 1,993,893 B
    3 pnpm-lock.yaml:20-21 billion-context@0.1.120,integrity: sha512-JxG4IVrNZES…
    4 磁盘 .pnpm/billion-context@0.1.120/node_modules/billion-context/package.json version: **0.1.121** ❌
    5 磁盘同目录 dist/agent/dsh-native.js 1,993,893 B ❌
    6 该文件 SHA256 748852F86850BDC3D17BCEBA7DF13BDC4B77933876ED9F93B349492003B0FD4C
    7 npm 0.1.121 tarball 内同文件 SHA256 748852F86850BDC3D17BCEBA7DF13BDC4B77933876ED9F93B349492003B0FD4C —— 逐字节相同

    上游无责(已自证)

    对两个 tarball 各自实算 sha512-base64,与 registry 声明的 integrity 完全一致:

    npm 0.1.120  声明: sha512-JxG4IVrNZEStrbosETl8yHaTw5JJLasK2OrYQZgb6Vj35JxOuDujLTXBFCkjQ0NQCZvhBqpCi5bN84jjXcGsSA==
    npm 0.1.120  实测: sha512-JxG4IVrNZEStrbosETl8yHaTw5JJLasK2OrYQZgb6Vj35JxOuDujLTXBFCkjQ0NQCZvhBqpCi5bN84jjXcGsSA==  ✅
    npm 0.1.121  声明: sha512-0rZcXO/5B/2fK8f0kM+VsDWusZ4aMzNqsvbfOS2hs7WisLvySyk874o+thH2yRmTUZawf3w9tbDxRi0TmnIDVw==
    npm 0.1.121  实测: sha512-0rZcXO/5B/2fK8f0kM+VsDWusZ4aMzNqsvbfOS2hs7WisLvySyk874o+thH2yRmTUZawf3w9tbDxRi0TmnIDVw==  ✅
    

    ⇒ npm registry 侧内容各自正确,错位发生在本机 pnpm 的 store / 安装过程。

    访问面 / 影响范围

    • 单一入口:profiles/web/node_modules/billion-context 是 Junction(LinkType=Junction,Attributes=Directory,ReparsePoint),
      Target = .pnpm\billion-context@0.1.120\node_modules\billion-context;整个 profile 只有一个 junction、一条加载路径。
      ⇒ 不存在多版本并列运行(已核实代理进程全局只有 1 个)。
    • 旁证:旧版目录是残缺的
      .pnpm/billion-context@0.1.118/.../dist/ 内 没有 dsh-native.js(0.1.118 时代尚无 dsh native 通道)。
      两版 dist 文件数对比:0.1.118 = 12 个,0.1.120 = 18 个;新增的正是
      agent/dsh-native.js(.map)、agent/opencode-native.js(.map)、agent/pi-native.js(.map)。
      ⇒ 该目录不可能与 0.1.120 争夺同一通道。

    与仓库既有 issue 的关系

    与 #953(版本漂移 / 崩溃窗口) 同族,但表现不同:#953 是版本漂移本身,这里是 integrity 记账与实际内容错位,
    且文件名级可复现(dsh-native.js 字节数 1,988,893 vs 1,993,893 就是判别指纹)。

    与主 issue(fetch failed)的关系 —— 请注意区分

    两者不是同一个问题,我在报告里明确分开,避免误导修复方向:

    问题 成因层 修它能治好 fetch failed 吗
    工具调用全部 fetch failed 代码逻辑:attach 分支不验活,采信 process.env 残留旧端口(planNativeDsh :44421 / proxyEnvOrigin :44151-44162) —
    版本内容错位(本条) 安装层:pnpm 记账与内容不符 ❌ 不能

    理由:主故障里那个被误采信的 env 值是当前进程自己 spawn 成功时写进去的(bootstrap() :44492),
    与磁盘上装的是 0.1.120 还是 0.1.121 无关——即使版本完全正确,第二次 apply() 照样误判 attach。

    建议

    修法不是改 upstream 代码,而是让本机安装对齐(重装 / 清 store 后重装,令目录名 = lockfile = 内容)。
    若要 upstream 出防御:dist/agent/dsh-native.js 可在启动时自检
    require("../package.json").version 与预期主版本是否一致,不一致则告警——但这是加固,不是本故障的根因修复。


    修复与验证(本机实测)

    修复动作(三步,可复现、可回退):

    $pnpm = "C:\Users\MyPC\AppData\Roaming\npm\pnpm.cmd"   # pnpm 11.24.0,未在 PATH
    $prof = "C:\Users\MyPC\.dsh\profiles\web"
    
    # 1) 备份(回退保险)
    Copy-Item "$prof\node_modules\.pnpm\billion-context@0.1.120\node_modules\billion-context" `
              "F:\dsh\backup-billion-context-20260919\pkg-full" -Recurse -Force
    
    # 2) 删除错位的虚拟 store 目录(物理目录,非软链)
    Remove-Item "$prof\node_modules\.pnpm\billion-context@0.1.120" -Recurse -Force
    
    # 3) 严格按 lockfile 重装
    cd $prof; & $pnpm install --frozen-lockfile

    修复前后对照:

    检查项 修复前 修复后 期望(真 0.1.120)
    .pnpm/ 目录名 billion-context@0.1.120 billion-context@0.1.120 0.1.120 ✅
    package.json version 0.1.121 ❌ 0.1.120 ✅ 0.1.120
    dist/agent/dsh-native.js 字节数 1,993,893 ❌ 1,988,893 ✅ 1,988,893
    该文件 SHA256 748852F8…(0.1.121) ❌ 2FE0EDA8… ✅ 0.1.120 内容
    dist 文件数 — 18 ✅ 18
    junction 指向 .pnpm/billion-context@0.1.120/…(曾短暂悬空) 已恢复可达 ✅ —
    .pnpm/ 下版本目录数 2(0.1.118 孤儿 + 0.1.120) 1(仅 0.1.120) ✅ 1

    结论:pnpm install --frozen-lockfile 能自愈对齐——重装后目录名、package.json、lockfile 三者一致,说明锁文件与 registry 侧元数据是对的,错位是安装落盘环节产生的。

    旁证:那个 0.1.118 目录是彻底孤儿(清理前实测)——无任何 junction 指向它、lockfile 中零引用、且其 dist/ 里连 dsh-native.js 都没有。清理零风险。

    注意:重装不影响运行中的进程。本机重装时,运行中的代理进程(PID 7328)已把旧代码加载进内存,
    仍自报旧版本号;磁盘已对齐,下次进程重启即从正确内容加载。重装过程中压缩工具通道保持可用(无中断)。

    对维护者的提示:本机无法定性「是 pnpm 自身 bug,还是安装时目录被占用/并发写入导致」——
    但判别指纹很明确:目录名版本 ≠ package.json 版本 ≠ lockfile 版本,且 dsh-native.js 字节数会是
    1,988,893(0.1.120) / 1,993,893(0.1.121) 之一。任何人可用这两项一行命令自查。

  2. ranxianglei commented on Sep 19, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    [bot] 🏷

    收到主 issue 和这条补充。先贴我的独立核实结论(已按源码逐行核对,非照抄),再说修法。

    根因(主故障 · 代码逻辑层)——成立:

    src/agent/dsh-native.ts:

    • bootstrap()(:180)自举成功后把自己 spawn 出来的临时端口写进 process.env.BILLION_CONTEXT_PROXY;
    • planNativeDsh()(:81)nativeAttachOrigin(env) ?? proxyEnvOrigin(env)——下一次同进程 apply() 把这个旧端口读回并判成 mode:"attach";
    • proxyEnvOrigin()(native-bootstrap.ts:31-42)只做字符串 / new URL() 语法校验,没有任何 pid / 网络验活;
    • attach 分支(dsh-native.ts:297-302)直接 state.ready = Promise.resolve(plan.attachOrigin)、register.base = plan.attachOrigin,从不探活;
    • nativeBootstrapGate()(native-bootstrap.ts:23)又因该变量非空拒绝重新自举。

    → 和你判断一致:写侧把 ephemeral origin 冻结进一个被读侧无条件信任的变量,短路了 ensureProxyRunning() 里现成的 pid-liveness + health 发现链。请求打到死端口 → undici TypeError: fetch failed,代理侧零日志;重启宿主清掉 process.env 才好。

    两个附带缺陷也核实了:

    • (C) onGiveUp(:307-311)把 register.base 置 undefined,而 maybeRetry(:235)与 registerTools(:210)都要求 base !== undefined → give-up 后工具注册被钉死在「未注册且无法重试」,只能重启宿主。真实缺陷。
    • (B) withTimeout(native-intercept.ts:133-136)catch 里吞掉一切异常返回 undefined;spawn 模式下 readyOrigin(:142-145)在 bootstrap 未就绪时每条请求最多白等 15s。机制属实,但影响面小于 (A)/(C)(是延迟惩罚 + 静默降级直连,不是打错端口)。

    关于这条补充(pnpm 目录名 / 内容 / lockfile 三者错位):
    同意你自己的分层——那是安装落盘层问题,不是本次 fetch failed 的代码根因,而且你本机已用 --frozen-lockfile 重装对齐。我不会把它并进这个修复。「启动自检 package.json.version 一致性」那条加固属于另一类、且作用在构建产物上,我倾向单列处理,不塞进本次 PR(下面说明范围)。

    修法方向(对齐 #926 既有结论「只有用户显式指定 origin 时才落盘」):
    核心是让自举产生的临时 origin 不再污染 attach 判定所依赖的 BILLION_CONTEXT_PROXY。我还在定改法落在共享层还是仅 dsh 入口——pi/opencode 走的是同一套 proxyEnvOrigin + bootstrap 写入,理论上是同族隐患,正在核对其它入口是否同样暴露。随后给 PR。

    先说这些,继续动手。

  3. ranxianglei commented on Sep 19, 2026

    @ranxianglei
    Owner

    🤖 Powered by ework · qwen3.8-27b

    @Vergemesh 已修复并提交 PR:#984 #984

    核实结论(对照源码逐条验证,不是只看描述就开工):

    • A(根因,确认):bootstrap() 把自启动的临时 origin 写进 process.env.BILLION_CONTEXT_PROXY;同进程后续 apply() 经 proxyEnvOrigin()(只做 URL 语法校验、无验活)把它当成 attach 目标,attach 分支立即 resolve、无探测 → register.base 指向已死端口 → 所有工具 fetch failed。与你的分层判断一致。
    • C(确认):onGiveUp 清空 base 后,maybeRetry/registerTools 都要求 base !== undefined,give-up 后无法自愈,只能重启宿主。
    • B:属产品权衡(见下),本次未改。

    修法(对齐 #926「仅在用户显式设置时才持久化 origin」的原则,从源头消除而非治标):

    • adoptOrigin() 只把 origin 写入进程内状态(state.origin + register.base),不再写 BILLION_CONTEXT_PROXY。重新规划因此保持 spawn 模式,走 ensureProxyRunning() 既有的 pid+health 验活链路——活实例则重连,否则新起,不再信任陈旧端口。
    • giveUp() 现在会武装重试退避,maybeRetry 在 base 被清空后重跑 single-flight bootstrap,代理恢复即可重新注册工具,无需重启宿主。

    范围:

    B 为何暂缓:初始 bootstrap 窗口内 ≤15s「未就绪→降级直连」是延迟/产品取舍(早期请求不压缩 vs. 等待),withTimeout 吞错是有意的优雅降级;两者都是产品决策,不想混进这个 fix。若你希望更早直连回落,我可以单独讨论。

    验证(rebase 到当前 master @ 0.1.121 后重跑):typecheck ✅ / 全量测试 1941 pass · 0 fail · 2 skip(e2e 门控)✅ / build ✅。新增两条回归测试,且各自先对「复现后的 bug」跑红、修后转绿:①自启动 adopt 不污染 BILLION_CONTEXT_PROXY、重新规划仍为 spawn;②give-up 后代理恢复可无重启重注册工具。

    一句话中文摘要:修了 dsh native 把自启动临时端口冻结进 BILLION_CONTEXT_PROXY 导致绕过验活、工具全部 fetch failed 的根因(外加 give-up 后无法自愈),从源头改为只在进程内路由、并让 give-up 后可自动恢复;已提交 #984,可以合并。

    注:已在跑的坏实例需升级到新版本后重启一次以清掉残留的旧变量;新版本起不会再复发。

  4. ranxianglei commented on Sep 19, 2026

    @ranxianglei
    Owner

    Fix delivered in #989.

    Confirmed root cause exactly as analyzed — three defects: (a) the spawn/attach branches froze BILLION_CONTEXT_PROXY into process.env with no in-process reader, so a same-process re-apply planned an unverified attach to a dead origin; (b) withTimeout swallowed the bootstrap error; (c) a failed respawn left the register base-less forever (maybeRetry returned early).

    Fix: no env writes (reuse via ensureProxyRunning instance discovery), attach origins are probed with a dead preset falling back to a spawned proxy, and maybeRetry self-heals by re-arming the bootstrap (tools read the live base so a respawn's new origin is picked up). Not an opencode/bili mixup — the freeze only exists in the dsh native lane.

  5. added a commit that references this issue on Sep 19, 2026
  6. added a commit that references this issue on Oct 1, 2026
  7. added 2 commits that reference this issue on Oct 16, 2026
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