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
};
- Bun routes a bare/relative import (
@opentui/core, solid-js, ai, ./foo, …) to the
plugin's /.*/ onResolve.
- The handler calls
resolveSourcePathFromSpecifier, which — for any specifier that is not
node:/bun:/http(s):/data:/file:/absolute — calls resolveFromParent.
resolveFromParent calls import.meta.resolve(specifier, parent).
- Within
Bun.build, import.meta.resolve dispatches through the registered resolver
plugins, so it fires the same /.*/ onResolve again with the same
(specifier, importer).
- → 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)
- 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.
- 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.
- 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.
Summary
createRuntimePlugin()(from@opentui/core/runtime-plugin) registers a catch‑allbuild.onResolve({ filter: /.*/ }, …)handler whose body callsimport.meta.resolve().Inside a
Bun.build,import.meta.resolve()dispatches back through the bundler'sresolver plugins, which re-enters the very same
/.*/handler with the samespecifier — 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/core0.4.0(also reproduced-as-identical in0.4.1,0.4.2, and0.0.0-20260616-38ae4bd9snapshot)@opentui/solid0.4.01.3.8The plugin source is byte‑for‑byte identical across
0.4.0,0.4.1,0.4.2, and thecurrent
snapshotdist‑tag (diffofruntime-plugin.jsis empty), so upgrading within0.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)
The three repeating frames map 1:1 to the published
runtime-plugin.jsand to theTypeScript source
packages/core/src/runtime-plugin.ts:.js)packages/core/src/runtime-plugin.ts)<anonymous>runtime-plugin.js:439build.onResolve({ filter: /.*/ }, …)body —const path = resolveSourcePathFromSpecifier(args.path, args.importer)(L578 / L583)resolveSourcePathFromSpecifierruntime-plugin.js:271const resolvedSpecifier = resolveFromParent(specifier, importer)(L350 / L370)resolveFromParentruntime-plugin.js:244const resolvedSpecifier = import.meta.resolve(specifier, parent)(L333 / L335)Root cause
The cycle is a closed loop within the plugin:
@opentui/core,solid-js,ai,./foo, …) to theplugin's
/.*/onResolve.resolveSourcePathFromSpecifier, which — for any specifier that is notnode:/bun:/http(s):/data:/file:/absolute — callsresolveFromParent.resolveFromParentcallsimport.meta.resolve(specifier, parent).Bun.build,import.meta.resolvedispatches through the registered resolverplugins, so it fires the same
/.*/onResolveagain with the same(specifier, importer).There is a
MAX_RUNTIME_RESOLVE_PARENTS = 64cap, but it only bounds the length of theresolveParentsByRecencyarray — it does not bound thisonResolve ↔ import.meta.resolverecursion 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-dispatchesthrough 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 isalready cached at the instant the plugin's
onResolvefires 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:
onResolvebehaves incorrectly for asynchronous imports oven-sh/bun#9862 — Runtime pluginonResolvebehaves incorrectly for asynchronous importsonResolvedoesn't filter non-file:protocol imports oven-sh/bun#9863 — Bun runtime pluginonResolvedoesn't filter non-file:protocol importsimport.meta.resolve(…)arguments in the source graph forbun buildoven-sh/bun#2906 — Includeimport.meta.resolve(…)arguments in the source graph forbun buildHow it surfaces / minimal reproduction
The bug triggers whenever this plugin's
onResolveis active during aBun.build. The commonpath:
@opentui/solid/runtime-plugin-support(and the React equivalent) registers the pluginglobally via Bun's
plugin()API:A global plugin registered this way participates in every
Bun.buildin the same process.So a project that lists the preload in
bunfig.toml:and then runs a programmatic
Bun.build(...)in that process (e.g. abun scripts/build.tsrelease build that compiles a standalone binary) intermittently hits the recursion — even
though
build.tsnever passescreateRuntimePlugintoBun.builditself; it leaks in via theglobal registration.
Sketch of a repro harness (intermittent — may need many iterations to trip):
Suggested fixes (for maintainers)
import.meta.resolve(specifier, parent)inresolveFromParentwith a resolver that does not dispatch back through the bundler'splugin pipeline (e.g.
Bun.resolveSync(specifier, dirOf(parent))). The handler is alreadysync-only (per the file header comment), so a sync resolver fits, and it removes the
re-entry entirely.
(specifier, importer)pairs in aSet;if
resolveSourcePathFromSpecifier/resolveFromParentis invoked for a pair already beingresolved, return
nullimmediately. Cheap and localized even if option 1 is undesirable./.*/filter so the catch-allonResolveis not invoked for the specifiersthat re-trigger it.
Workaround (for users, verified)
Run the programmatic build in a process that does not load the
runtime-plugin-supportpreload, so the recursing plugin is never registered globally for theBun.build. For abun scripts/build.tsthat already creates and passes its owncreateSolidTransformPlugin()toBun.build, pointing Bun at a config without the preloads issufficient:
bun --config /dev/null scripts/build.ts # or: bun -c <empty-bunfig.toml> scripts/build.tsIn our case this produced an identical, working standalone binary across repeated runs with
zero recursion failures.