Free the ABI string buffers on the JS bridge - #654
Merged
Conversation
Every Ruby String crossing to JS is copied into a fresh `xmalloc` buffer by `rstring_to_abi_string`, and `rb_js_abi_host_string_free` is never called on it. The canonical ABI leaves the caller owning a lowered string param, and the generated bindings only pass ptr/len, so those bytes are never reclaimed. wasm linear memory never shrinks, so the loss is permanent. Four sites lower a string without freeing: `_rb_js_eval_js`, `_rb_js_obj_aref`, `_rb_js_obj_aset` and `_rb_js_string_to_js`. Because `_rb_js_obj_call` resolves its method through `_rb_js_obj_aref`, EVERY `JS::Object#call` leaks the length of its method name. Two more leak in the opposite direction. `js-value-to-string` and `js-value-typeof` have the host allocate through `cabi_realloc` and hand ownership to the guest, but `_rb_js_obj_to_s` and `_rb_js_obj_typeof` copy the bytes out with `rb_utf8_str_new` and abandon the buffer. Their siblings `_rb_js_obj_to_i` and `_rb_js_obj_to_f` already call `rb_js_abi_host_raw_integer_free` on the same kind of returned payload, so this brings the two string readers in line with them. In `_rb_js_eval_js`, `_rb_js_obj_aref` and `_rb_js_obj_aset` the free has to precede `raise_js_error_if_failure`, which longjmps past anything after it. `types.h` gains a `rb_js_abi_host_string_free` alias so the component-model build resolves it to `ext_string_free`; the legacy bindings already declare it. Both implementations are the same guarded `free`. Measured on a 60fps canvas application making roughly 1000 crossings per frame: about 1 MB/s of unreclaimable linear memory before, and flat after. Such an application dies at 2 GB rather than 4, because the generated glue reads guest pointers as signed i32, so the first symptom is a confusing "RangeError: Start offset -2147..." out of `new Uint8Array`.
ryanseys
marked this pull request as ready for review
July 31, 2026 02:32
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.
The problem
Every Ruby String that crosses to JS is copied into a fresh
xmallocbuffer byrstring_to_abi_string, andrb_js_abi_host_string_freeis never called on it:The canonical ABI leaves the caller owning a lowered string param, and the generated bindings only pass
ptr/lenthrough to the import, so nothing reclaims those bytes. wasm linear memory never shrinks, so the loss is permanent and is exactlyRSTRING_LENper crossing.Four sites lower a string without freeing it:
_rb_js_eval_js,_rb_js_obj_aref,_rb_js_obj_asetand_rb_js_string_to_js. Because_rb_js_obj_callresolves its method through_rb_js_obj_aref, everyJS::Object#callleaks the length of its method name.Two more leak in the other direction.
js-value-to-stringandjs-value-typeofhave the host allocate throughcabi_reallocand transfer ownership to the guest, but_rb_js_obj_to_sand_rb_js_obj_typeofcopy the bytes out and abandon the buffer, soJS::Object#to_sleaks the JS string's length per call.The fix
Six
rb_js_abi_host_string_freecalls.The two return-path ones bring
to_sandtypeofin line with their siblings_rb_js_obj_to_iand_rb_js_obj_to_f, which already callrb_js_abi_host_raw_integer_freeon the same kind of returned payload.In
_rb_js_eval_js,_rb_js_obj_arefand_rb_js_obj_asetthe free has to precederaise_js_error_if_failure, which longjmps past anything after it.types.hgains arb_js_abi_host_string_freealias so the component-model build resolves it toext_string_free; the legacy bindings already declare it. Both implementations are the same guardedfree.Impact
Measured on a 60fps canvas application making roughly 1000 crossings per frame: about 1 MB/s of unreclaimable linear memory before this change, and flat after.
Such an application dies at 2 GB rather than 4, because the generated glue reads guest pointers as signed i32. Past
2^31bytescabi_reallocreturns a pointer that reads back negative and every crossing throws out ofnew Uint8Array:Ruby then tries to print a bug report,
fd_writere-enters the same broken glue, and the console fills with "Crashed while printing bug report" - three symptoms that name neither memory nor the leak behind them.Verification
I confirmed the fix out-of-tree first, by shimming the JS host to free the abandoned buffers through the guest's exported
cabi_post_rstring-ptr(whose whole body is a guardedfree). Against the released@ruby/4.0-wasm-wasi2.9.3-2.9.4 that took the same workload from 0.68 MB/s to 0.00 MB/s over 60 seconds, across many thousands of crossings, with no corruption. This PR moves that free to where it belongs.The added test round-trips distinct strings through
[],[]=,to_s,typeofandJS.eval, so a premature free shows up as a wrong value rather than passing by luck, and repeats it underGC.stress.