Repository navigation
[dsh] native proxyEnvOrigin() 把临时 origin 冻结进 BILLION_CONTEXT_PROXY,绕过 ensureProxyRunning() 的 instance 验活;工具调用全部 fetch failed,需重启宿主 #983
Description
## 现象
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;机制四步:
- 以
spawn模式自举成功 →bootstrap()把实际端口写进process.env.BILLION_CONTEXT_PROXY(:44492)。 - 该变量在进程内一直保留。
- 下一次同进程内
apply():proxyEnvOrigin()读到旧端口 → 判定mode = "attach"(:44422);同时nativeBootstrapGate()(:44147)因为该变量非空而拒绝重新自举。 - 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 既有结论,保持一致)
- 不冻结:
bootstrap()写入的 origin 与「用户显式设置的 origin」分离——后者用独立变量名(如BILI_INTERNAL_PROXY),避免污染 attach 判定。 - 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)。
- 最小改动版:
- 补齐清理点:进程退出 / 插件 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
Vergemesh commentedon Sep 19, 2026
comment 补充
安装内容与目录名/锁文件错位
结论
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 —— 三者不一致。证据链
0.1.120tarball 内package.jsonversion: 0.1.120,dist/agent/dsh-native.js= 1,988,893 B0.1.121tarball 内package.jsonversion: 0.1.121,dist/agent/dsh-native.js= 1,993,893 Bpnpm-lock.yaml:20-21billion-context@0.1.120,integrity: sha512-JxG4IVrNZES….pnpm/billion-context@0.1.120/node_modules/billion-context/package.jsonversion: **0.1.121**❌dist/agent/dsh-native.js748852F86850BDC3D17BCEBA7DF13BDC4B77933876ED9F93B349492003B0FD4C0.1.121tarball 内同文件 SHA256748852F86850BDC3D17BCEBA7DF13BDC4B77933876ED9F93B349492003B0FD4C—— 逐字节相同上游无责(已自证)
对两个 tarball 各自实算 sha512-base64,与 registry 声明的 integrity 完全一致:
⇒ 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 failedprocess.env残留旧端口(planNativeDsh:44421/proxyEnvOrigin:44151-44162)理由:主故障里那个被误采信的 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/目录名billion-context@0.1.120billion-context@0.1.120package.jsonversion0.1.121❌0.1.120✅dist/agent/dsh-native.js字节数748852F8…(0.1.121) ❌2FE0EDA8…✅dist文件数.pnpm/billion-context@0.1.120/…(曾短暂悬空).pnpm/下版本目录数结论:
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) 之一。任何人可用这两项一行命令自查。ranxianglei commentedon Sep 19, 2026
[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 / 网络验活;state.ready = Promise.resolve(plan.attachOrigin)、register.base = plan.attachOrigin,从不探活;nativeBootstrapGate()(native-bootstrap.ts:23)又因该变量非空拒绝重新自举。→ 和你判断一致:写侧把 ephemeral origin 冻结进一个被读侧无条件信任的变量,短路了
ensureProxyRunning()里现成的 pid-liveness + health 发现链。请求打到死端口 → undiciTypeError: fetch failed,代理侧零日志;重启宿主清掉process.env才好。两个附带缺陷也核实了:
onGiveUp(:307-311)把register.base置undefined,而maybeRetry(:235)与registerTools(:210)都要求base !== undefined→ give-up 后工具注册被钉死在「未注册且无法重试」,只能重启宿主。真实缺陷。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。先说这些,继续动手。
ranxianglei commentedon Sep 19, 2026
@Vergemesh 已修复并提交 PR:#984 #984
核实结论(对照源码逐条验证,不是只看描述就开工):
bootstrap()把自启动的临时 origin 写进process.env.BILLION_CONTEXT_PROXY;同进程后续apply()经proxyEnvOrigin()(只做 URL 语法校验、无验活)把它当成 attach 目标,attach 分支立即 resolve、无探测 →register.base指向已死端口 → 所有工具fetch failed。与你的分层判断一致。onGiveUp清空base后,maybeRetry/registerTools都要求base !== undefined,give-up 后无法自愈,只能重启宿主。修法(对齐 #926「仅在用户显式设置时才持久化 origin」的原则,从源头消除而非治标):
adoptOrigin()只把 origin 写入进程内状态(state.origin+register.base),不再写BILLION_CONTEXT_PROXY。重新规划因此保持 spawn 模式,走ensureProxyRunning()既有的 pid+health 验活链路——活实例则重连,否则新起,不再信任陈旧端口。giveUp()现在会武装重试退避,maybeRetry在base被清空后重跑 single-flight bootstrap,代理恢复即可重新注册工具,无需重启宿主。范围:
/__bili/health,失败签名不同(响亮地 fail-close,而非静默 fetch failed);为保持本 PR 单一范围未动它,需要的话我可另开跟进。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,可以合并。注:已在跑的坏实例需升级到新版本后重启一次以清掉残留的旧变量;新版本起不会再复发。
ranxianglei commentedon Sep 19, 2026
Fix delivered in #989.
Confirmed root cause exactly as analyzed — three defects: (a) the spawn/attach branches froze
BILLION_CONTEXT_PROXYinto process.env with no in-process reader, so a same-process re-apply planned an unverified attach to a dead origin; (b)withTimeoutswallowed the bootstrap error; (c) a failed respawn left the register base-less forever (maybeRetryreturned early).Fix: no env writes (reuse via
ensureProxyRunninginstance discovery), attach origins are probed with a dead preset falling back to a spawned proxy, andmaybeRetryself-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.Merge pull request #989 from ranxianglei/2026-09-19_dsh-attach-verify
fix: verify dsh native attach targets instead of trusting a frozen env (
fix: never spawn a second instance over a model channel pinned to an …
fix(dsh): stale verifyAttachAndRecover landings must never clobber th…
fix: contain dsh-native attach/recovery chains within their own test (#…
fix: contain dsh-native attach/recovery chains within their own test (#…
fix(#1795): route preset BILLION_CONTEXT_PROXY to attach in the omp n…
fix(ranxianglei#1795): route preset BILLION_CONTEXT_PROXY to attach i…
fix: dsh plugin — stop the re-registration self-lock after a failed p…
fix(#3544): scope the giveUp invalidation to attach-to-existing-origi…
fix: guard maybeRetry self-heal against a superseded respawn flight
fix(dsh-native): stop freezing self-spawned origin into BILLION_CONTE…