#10887·rolldown

[Bug]: Callable builtin option callbacks keep released JavaScript owners alive

Author: jav-edCreated Sep 15, 2026Updated Sep 15, 2026

Reproduction link or steps

Runnable reproduction and local workaround, pinned to Rolldown 1.2.8. repro-rolldown.mjs imports only rolldown/experimental and Node builtins; it does not create a Vite server or run a build.

bash
git clone https://gist.github.com/7db63deba8526a8810164775e9e5f330.git memory-retention-repro
cd memory-retention-repro
npm install
npm run repro:rolldown

The script creates four owners, each holding a viteResolvePlugin whose onWarn callback closes over that owner, then drops all ordinary references. Four controls use console.warn instead. After eight event-loop turns and explicit garbage collections:

{ controlRetained: 0, callbackOwnerRetained: 4 }
AssertionError: Native callable builtin callbacks retain their released JS owner

What is expected?

Both sets of unreachable JavaScript owners should be collectible. A callback referring back to its plugin's owner should not make an otherwise unreachable object graph permanently reachable.

What is actually happening?

The callback-owning objects remain reachable through the native callable builtin. The retention path identified while investigating Vite heap growth was:

native callback root -> option callback -> JS owner/environment
                    -> plugin -> native callable builtin

In packages/rolldown/src/builtin-plugin/utils.ts, makeBuiltinPluginCallable passes the original options callbacks into BindingCallableBuiltinPlugin. In a Vite process those callbacks can retain entire old environment/plugin caches. The direct reproduction above isolates this from Vite's separate restart-lifetime problem.

System Info

  • Linux x86_64
  • Rolldown 1.2.8, installed using npm
  • Reproduced on Node 22.22.3, 24.18.0, and 26.7.0
  • Also observed downstream with Rolldown 1.1.5 / Vite 8.1.4

Local workaround and verification

In the same reproduction directory:

bash
node apply-workaround.mjs rolldown
npm run repro:rolldown

Result on all three Node versions:

{ controlRetained: 0, callbackOwnerRetained: 0 }

The workaround keeps the original callbacks strongly owned by the JavaScript plugin's _options, but supplies weak forwarding callbacks at this callable native boundary. A forwarder throws if invoked after its JavaScript owner has been released. This breaks the native-rooted cycle while preserving callback ownership during ordinary JS plugin use. The exact implementation is in apply-workaround.mjs. This locally tested workaround can inshallah serve as a starting point for an upstream fix. The preferred upstream lifetime API still needs maintainer review.

In the downstream application, a lifecycle check additionally resolves modules before and after GC while the plugin is still owned, then verifies collection after closing the Vite environment. Typecheck and production builds pass with the local patch.

I checked #6121 / #6721 (freeExternalMemory). This reproduction has no generated bundle/output object to free, so that API does not address this callable-plugin retention path.