Skip to content

Infinite recursion when dsh-vision-router is installed alongside this plugin #1

Description

@milechazi

Plugin version: dsh-opencode-session-header@0.1.0
Harness: DeepSeek Harness (@deepseek-ai/dsh) 0.1.5-rc.2
Runtime: Node.js v24.15.0, Windows 11 (10.0.26200)
Severity: blocks boot — the whole plugin tree fails to load

Summary

With dsh-vision-router present in the same profile, mounting this plugin makes the
harness fail to boot with RangeError: Maximum call stack size exceeded. The repeating
stack frame is this plugin's own fetch middleware:

Exception in PromiseRejectCallback:
file:///.../node_modules/dsh-opencode-session-header/lib/index.js:171
	return next(input, { ...init, headers })
RangeError: Maximum call stack size exceeded

Removing either plugin makes the profile boot cleanly.

Reproduction (bisected, minimal)

A profile whose dsh.profile.bundles contains only:

["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "dsh-opencode-session-header"]

boots fine. Adding dsh-vision-router@2.2.0 to that minimal list makes it crash
immediately at boot (no LLM request is involved — both plugins issue network calls during
apply(), and the recursion starts there).

Bisect log (profile had 41 non-core plugins; each round was a restart):

Round plugins enabled result
1 0–20 (21 plugins) crash
2 0–10 crash
3 0–5 crash
4 0–2 crash
5 dsh-vision-router alone crash
control same set, vision-router removed clean boot

Root cause (strong indication)

The two plugins make mutually incompatible assumptions about globalThis.fetch:

A. This plugin treats any external reassignment of globalThis.fetch as its new
underlying fetch.
lib/index.js, installFetchPipeline():

const prevDesc = Object.getOwnPropertyDescriptor(globalThis, 'fetch')
state.patchedFetch = compose(state)
Object.defineProperty(globalThis, 'fetch', {
  configurable: true,
  enumerable: prevDesc?.enumerable ?? true,
  get() { return state.patchedFetch },
  set(newFetch) {
    if (newFetch === state.patchedFetch) return
    prevDesc?.set?.call(globalThis, newFetch)
    state.setUnderlyingFetch(newFetch)      // ← captures the foreign wrapper
    state.patchedFetch = compose(state)
  },
})

(and ensureFetchPipeline() captures let underlyingFetch = globalThis.fetch at first use).

B. dsh-vision-router replaces globalThis.fetch with a wrapper whose original is
whatever globalThis.fetch was at wrap time.
lib/pi-ai-bridge-wire-compat.js:

const original = globalThis.fetch          // line 286
...
if (!active) return Reflect.apply(original, globalThis, [input, init])   // early path taken at boot
...
globalThis.fetch = wrapped                 // line 337
return () => { if (globalThis.fetch === wrapped) globalThis.fetch = original }  // line 340

Interleaved at boot, this closes a cycle:

  1. this plugin installs its accessor; underlying = native fetch
  2. vision-router wraps and reads original → this plugin's patched fetch
  3. globalThis.fetch = wrapped fires this plugin's setter → underlying = wrapped
  4. patched fetch → middleware → next → underlying (= wrapped)
  5. wrapped (inactive path) applies original → this plugin's patched fetch → step 4

Note that each side is defensible alone: vision-router does capture its original and does
restore on dispose, and this plugin's own tests pass (13/13) — the failure only exists when
the two are combined. Vision-router also has a second, dynamic-injection pattern
(index.js:820 and lib/ollama-cold-start.js:115: fetchImpl = (...args) => globalThis.fetch(...args))
which is the same class of hazard.

Why this plugin's own tests don't catch it

tests/smoke.mjs exercises createSessionHeaderMiddleware() against a stub next and never
installs the pipeline on a live globalThis.fetch. I wrote an independent probe that installs
the middleware through your exported registerFetchMiddleware() and then performs two real
fetch() calls (one matching opencode.ai, one not) — it completed without recursion, i.e.
the install path is sound in isolation. The cycle needs a second global-fetch writer.

Suggested directions

  • Don't adopt external assignments as the underlying fetch: keep the reference captured at
    install time and treat a foreign assignment as "someone else now owns globalThis.fetch"
    (e.g. re-read prevDesc/skip re-capture, or stop wrapping and log a warning).
  • Or make next() re-entrancy-safe (depth counter or an in-flight marker) so a wrapper that
    re-enters the patched fetch degrades instead of overflowing.
  • Longer term, the harness could offer an official seam (an injected ctx.http / a documented
    fetch-pipeline API) so plugins stop racing over globalThis.fetch.

Happy to test a patch — the bisect setup above reproduces in under a minute.

Activity

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