Skip to content

Free the ABI string buffers on the JS bridge - #654

Merged
kateinoigakukun merged 1 commit into
ruby:mainfrom
ryanseys:fix-js-string-leaks
Jul 31, 2026
Merged

Free the ABI string buffers on the JS bridge#654
kateinoigakukun merged 1 commit into
ruby:mainfrom
ryanseys:fix-js-string-leaks

Conversation

@ryanseys

@ryanseys ryanseys commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

The problem

Every Ruby String that crosses 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:

static inline void rstring_to_abi_string(VALUE rstr,
                                         rb_js_abi_host_string_t *abi_str) {
  abi_str->len = RSTRING_LEN(rstr);
  abi_str->ptr = xmalloc(abi_str->len);
  memcpy(abi_str->ptr, RSTRING_PTR(rstr), abi_str->len);
}

The canonical ABI leaves the caller owning a lowered string param, and the generated bindings only pass ptr/len through to the import, so nothing reclaims those bytes. wasm linear memory never shrinks, so the loss is permanent and is exactly RSTRING_LEN per crossing.

Four sites lower a string without freeing it: _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 other direction. js-value-to-string and js-value-typeof have the host allocate through cabi_realloc and transfer ownership to the guest, but _rb_js_obj_to_s and _rb_js_obj_typeof copy the bytes out and abandon the buffer, so JS::Object#to_s leaks the JS string's length per call.

The fix

Six rb_js_abi_host_string_free calls.

The two return-path ones bring to_s and typeof in line with their siblings _rb_js_obj_to_i and _rb_js_obj_to_f, which already call rb_js_abi_host_raw_integer_free on the same kind of returned payload.

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.

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^31 bytes cabi_realloc returns a pointer that reads back negative and every crossing throws out of new Uint8Array:

RangeError: Start offset -2147463136 is outside the bounds of the buffer
    at new Uint8Array (<anonymous>)
    at imports.rb-js-abi-host.reflect-get: ...

Ruby then tries to print a bug report, fd_write re-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 guarded free). Against the released @ruby/4.0-wasm-wasi 2.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, typeof and JS.eval, so a premature free shows up as a wrong value rather than passing by luck, and repeats it under GC.stress.

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
ryanseys marked this pull request as ready for review July 31, 2026 02:32

@kateinoigakukun kateinoigakukun left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks!

@kateinoigakukun
kateinoigakukun merged commit 463fab0 into ruby:main Jul 31, 2026
39 checks passed
@ryanseys
ryanseys deleted the fix-js-string-leaks branch July 31, 2026 18:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants