Repository navigation
Don't keep side-module wasm bytes alive after dlopen - #28002
Open
rustyconover wants to merge 2 commits into
Open
rustyconover wants to merge 2 commits into
rustyconover wants to merge 2 commits into
Conversation
In the synchronous path of `loadWebAssemblyModule`, the `binary` bytes stayed referenced by the function's closure scope. Any lazy import stub created by `proxyHandler` (for a symbol not yet defined at load time) lives as long as the module and shares that scope, so the full wasm bytes of every such side module were kept in memory for the life of the program. Replace `binary` with the compiled `WebAssembly.Module` before instantiating, as the async path already does. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
sbc100
reviewed
Oct 17, 2026
sbc100
reviewed
Oct 17, 2026
sbc100
reviewed
Oct 17, 2026
| // though they do not reference it directly. | ||
| binary = binary instanceof WebAssembly.Module ? binary : new WebAssembly.Module(binary); | ||
| var instance = new WebAssembly.Instance(binary, info); | ||
| return postInstantiation(binary, instance); |
Collaborator
There was a problem hiding this comment.
Actually can you just add binary = null;, keeping the module local separate. I think this is explictly obvious what the intent it. Then the comment can be more clear too.
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
After a synchronous
dlopen, the side module's wasm bytes are now released once the module is compiled, instead of being kept in memory for the life of the program.Cause
Inside
loadWebAssemblyModule,loadModulecapturesbinary. When a side module imports a symbol that isn't defined yet,proxyHandlercreates a lazy stub that becomes one of the instance's imports. The stub does not referencebinarydirectly: it usesresolveSymbol, which captureslocalScopefrom the same outer scope. In V8, these captured bindings can share a context, so the surviving stub's context chain can retain the fullbinaryUint8Array. This concerns captured bindings; it does not mean every local variable is retained. V8 documents this shared-environment behavior. The async path already replacesbinarywith the compiledWebAssembly.Module.Effect
I found this while profiling extension loading in Haybarn, a DuckDB-wasm fork, where extensions are side modules loaded with
dlopen. In Node 26 on aarch64, afterLOAD spatial(a 26 MB side module) and two forced GCs,process.memoryUsage().arrayBuffersstayed 25.9 MB higher. With this change it's +0.03 MB, and RSS growth went from about 120–150 MB to about 92–118 MB. LOAD time didn't change (~387 ms). Every extension we loaded showed the same thing (iceberg 13.6 MB retained, ducklake 8.2 MB, autocomplete 3.7 MB).Standalone repro (the new test): a
MAIN_MODULE=2programdlopens a side module with one not-yet-defined import and a 32 MB initializedEMSCRIPTEN_KEEPALIVEC array. The test checks that the compiled side module actually contains at least 32 MB of bytes and reserves 64 MB of initial Wasm memory for loading it. It runsgc()and comparesprocess.memoryUsage().arrayBuffersbefore and after loading. Before: 32 MB retained. After: 0 MB.Change
In the sync path, replace
binarywith the compiled module before instantiating, the same way the async path does.getDylinkMetadata(binary)runs before this, so nothing reads the bytes afterwards. AWebAssembly.Modulepassed in directly (for example fromsharedModulesunder pthreads) is used as before.Testing
other.test_dlopen_binary_not_retainedon Node 24.19.0 with--expose-gc: fails with the pre-fix loader ("retained: 32MB") and passes with the fix ("retained: 0MB"). Five consecutive repeated runs passed.core0/core2×*dylink*/*dlfcn*/*dlopen*, plusother.*dylink*/*dlfcn*/*dlopen*/*MAIN_MODULE*/*main_module*/*side_module*and both dylink codesize tests: 393 tests total, 370 passed and 23 skipped.hello_dylinkJS unchanged (gzip −3 bytes);hello_dylink_allJS unchanged.git diff --checkpassed.🤖 Generated with Claude Code