Skip to content

BUG: createRuntimePlugin onResolve recurses infinitely (RangeError: Maximum call stack size exceeded) during Bun.build #1209

Description

@vivere-dally

Summary

createRuntimePlugin() (from @opentui/core/runtime-plugin) registers a catch‑all
build.onResolve({ filter: /.*/ }, …) handler whose body calls import.meta.resolve().
Inside a Bun.build, import.meta.resolve() dispatches back through the bundler's
resolver plugins
, which re-enters the very same /.*/ handler with the same
specifier — an unbounded onResolve → resolveSourcePathFromSpecifier → resolveFromParent → import.meta.resolve → onResolve … loop that blows the stack.

It is intermittent: most builds succeed, occasionally one dies with ~tens of
thousands of identical stack frames. When it fails, partial output (the compiled binary,
notices) may already be on disk, but the process exits non‑zero.

Environment

@opentui/core 0.4.0 (also reproduced-as-identical in 0.4.1, 0.4.2, and 0.0.0-20260616-38ae4bd9 snapshot)
@opentui/solid 0.4.0
Bun 1.3.8
OS macOS 14 (darwin arm64)

The plugin source is byte‑for‑byte identical across 0.4.0, 0.4.1, 0.4.2, and the
current snapshot dist‑tag
(diff of runtime-plugin.js is empty), so upgrading within
0.4.x does not change the behavior — the recursing code is still present on the latest
published build.

Symptom (observed stack trace, truncated — it repeats tens of thousands of times)

RangeError: Maximum call stack size exceeded.
   at resolveFromParent (…/@opentui/core/runtime-plugin.js:244:47)
   at resolveSourcePathFromSpecifier (…/@opentui/core/runtime-plugin.js:271:31)
   at <anonymous> (…/@opentui/core/runtime-plugin.js:439:30)
   at resolveFromParent (…/@opentui/core/runtime-plugin.js:244:47)
   at resolveSourcePathFromSpecifier (…/@opentui/core/runtime-plugin.js:271:31)
   at <anonymous> (…/@opentui/core/runtime-plugin.js:439:30)
   …                       (repeats until the stack overflows)
244 |         const resolvedSpecifier = import.meta.resolve(specifier, parent);
                                                    ^

The three repeating frames map 1:1 to the published runtime-plugin.js and to the
TypeScript source packages/core/src/runtime-plugin.ts:

Stack frame (compiled .js) Source (packages/core/src/runtime-plugin.ts)
<anonymous> runtime-plugin.js:439 build.onResolve({ filter: /.*/ }, …) body — const path = resolveSourcePathFromSpecifier(args.path, args.importer) (L578 / L583)
resolveSourcePathFromSpecifier runtime-plugin.js:271 const resolvedSpecifier = resolveFromParent(specifier, importer) (L350 / L370)
resolveFromParent runtime-plugin.js:244 const resolvedSpecifier = import.meta.resolve(specifier, parent) (L333 / L335)

Root cause

The cycle is a closed loop within the plugin:

build.onResolve({ filter: /.*/ }, (args) => {          // L578  — matches EVERY specifier
    …
    const path = resolveSourcePathFromSpecifier(        // L583
        args.path, args.importer);
});

const resolveSourcePathFromSpecifier = (specifier, importer) => {
    …                                                   // node:/bun:/file:/absolute short-circuits
    const resolvedSpecifier = resolveFromParent(        // L370  — reached for any *bare/relative* specifier
        specifier, importer);
};

const resolveFromParent = (specifier, parent) => {
    const resolvedSpecifier = import.meta.resolve(      // L335  — RE-ENTERS the bundler's resolver…
        specifier, parent);                             //        …which fires onResolve({filter:/.*/}) again
};
  1. Bun routes a bare/relative import (@opentui/core, solid-js, ai, ./foo, …) to the
    plugin's /.*/ onResolve.
  2. The handler calls resolveSourcePathFromSpecifier, which — for any specifier that is not
    node:/bun:/http(s):/data:/file:/absolute — calls resolveFromParent.
  3. resolveFromParent calls import.meta.resolve(specifier, parent).
  4. Within Bun.build, import.meta.resolve dispatches through the registered resolver
    plugins, so it fires the same /.*/ onResolve again with the same
    (specifier, importer).
  5. → step 2, forever. The arguments never change, so there is no termination condition.

There is a MAX_RUNTIME_RESOLVE_PARENTS = 64 cap, but it only bounds the length of the
resolveParentsByRecency array — it does not bound this onResolve ↔ import.meta.resolve
recursion depth.

Why it is intermittent (hypothesis — the mechanism above is proven by the stack trace; this part is reasoned, not read from Bun internals)

The loop is only entered when import.meta.resolve(specifier, parent) actually re-dispatches
through the plugin instead of being served from Bun's resolver cache. Bun resolves the module
graph concurrently and caches resolutions; whether a given (specifier, importer) pair is
already cached at the instant the plugin's onResolve fires depends on resolution scheduling,
which varies run to run. On most runs the relevant pairs are already resolved (cache hit → no
re-entry → success); occasionally one is seen uncached at exactly the wrong moment → re-entry
→ stack overflow. This matches the observed rarity (we could not force it in 20+ consecutive
builds, yet it recurs in normal use). Possibly related Bun behavior:

How it surfaces / minimal reproduction

The bug triggers whenever this plugin's onResolve is active during a Bun.build. The common
path: @opentui/solid/runtime-plugin-support (and the React equivalent) registers the plugin
globally via Bun's plugin() API:

// @opentui/solid/scripts/runtime-plugin-support-configure.js
registerBunPlugin(createRuntimePlugin({ core, additional: modules, rewrite: options.rewrite }));

A global plugin registered this way participates in every Bun.build in the same process.
So a project that lists the preload in bunfig.toml:

preload = ["@opentui/solid/preload", "@opentui/solid/runtime-plugin-support"]

and then runs a programmatic Bun.build(...) in that process (e.g. a bun scripts/build.ts
release build that compiles a standalone binary) intermittently hits the recursion — even
though build.ts never passes createRuntimePlugin to Bun.build itself; it leaks in via the
global registration.

Sketch of a repro harness (intermittent — may need many iterations to trip):

import { plugin } from "bun";
import { createRuntimePlugin } from "@opentui/core/runtime-plugin";

plugin(createRuntimePlugin({ additional: { "solid-js": await import("solid-js") } }));

// entry.ts contains a handful of bare imports (@opentui/core, solid-js, ai, zod, …)
await Bun.build({ entrypoints: ["entry.ts"], target: "bun" });

Suggested fixes (for maintainers)

  1. Don't re-enter the resolver. Replace import.meta.resolve(specifier, parent) in
    resolveFromParent with a resolver that does not dispatch back through the bundler's
    plugin pipeline (e.g. Bun.resolveSync(specifier, dirOf(parent))). The handler is already
    sync-only (per the file header comment), so a sync resolver fits, and it removes the
    re-entry entirely.
  2. Add a re-entrancy guard. Track in-flight (specifier, importer) pairs in a Set;
    if resolveSourcePathFromSpecifier/resolveFromParent is invoked for a pair already being
    resolved, return null immediately. Cheap and localized even if option 1 is undesirable.
  3. Narrow the /.*/ filter so the catch-all onResolve is not invoked for the specifiers
    that re-trigger it.

Workaround (for users, verified)

Run the programmatic build in a process that does not load the
runtime-plugin-support preload, so the recursing plugin is never registered globally for the
Bun.build. For a bun scripts/build.ts that already creates and passes its own
createSolidTransformPlugin() to Bun.build, pointing Bun at a config without the preloads is
sufficient:

bun --config /dev/null scripts/build.ts      # or: bun -c <empty-bunfig.toml> scripts/build.ts

In our case this produced an identical, working standalone binary across repeated runs with
zero recursion failures.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions