From 03bbead418afc2428fbab616bca51df92bc94176 Mon Sep 17 00:00:00 2001 From: Ryan Seys Date: Thu, 30 Jul 2026 17:08:03 -0700 Subject: [PATCH] Free the ABI string buffers on the JS bridge 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`. --- packages/gems/js/ext/js/js-core.c | 16 +++++++++--- packages/gems/js/ext/js/types.h | 1 + .../ruby-wasm-wasi/test/unit/test_object.rb | 25 +++++++++++++++++++ 3 files changed, 39 insertions(+), 3 deletions(-) diff --git a/packages/gems/js/ext/js/js-core.c b/packages/gems/js/ext/js/js-core.c index f8cd68bad..a84f5fc93 100644 --- a/packages/gems/js/ext/js/js-core.c +++ b/packages/gems/js/ext/js/js-core.c @@ -101,6 +101,7 @@ static VALUE _rb_js_eval_js(VALUE _, VALUE code_str) { rstring_to_abi_string(code_str, &abi_str); rb_js_abi_host_js_abi_result_t ret; rb_js_abi_host_eval_js(&abi_str, &ret); + rb_js_abi_host_string_free(&abi_str); raise_js_error_if_failure(&ret); return jsvalue_s_new(ret.val.success); } @@ -197,6 +198,7 @@ static VALUE _rb_js_obj_aref(VALUE obj, VALUE key) { rstring_to_abi_string(key, &key_abi_str); rb_js_abi_host_js_abi_result_t ret; rb_js_abi_host_reflect_get(p->abi, &key_abi_str, &ret); + rb_js_abi_host_string_free(&key_abi_str); raise_js_error_if_failure(&ret); return jsvalue_s_new(ret.val.success); } @@ -224,6 +226,7 @@ static VALUE _rb_js_obj_aset(VALUE obj, VALUE key, VALUE val) { rstring_to_abi_string(key, &key_abi_str); rb_js_abi_host_js_abi_result_t ret; rb_js_abi_host_reflect_set(p->abi, &key_abi_str, v->abi, &ret); + rb_js_abi_host_string_free(&key_abi_str); raise_js_error_if_failure(&ret); rb_js_abi_host_js_abi_value_free(&ret.val.success); RB_GC_GUARD(rv); @@ -346,7 +349,9 @@ static VALUE _rb_js_obj_typeof(VALUE obj) { struct jsvalue *p = check_jsvalue(obj); rb_js_abi_host_string_t ret0; rb_js_abi_host_js_value_typeof(p->abi, &ret0); - return rb_str_new((const char *)ret0.ptr, ret0.len); + VALUE typeof_str = rb_str_new((const char *)ret0.ptr, ret0.len); + rb_js_abi_host_string_free(&ret0); + return typeof_str; } /* @@ -366,7 +371,9 @@ static VALUE _rb_js_obj_to_s(VALUE obj) { struct jsvalue *p = check_jsvalue(obj); rb_js_abi_host_string_t ret0; rb_js_abi_host_js_value_to_string(p->abi, &ret0); - return rb_utf8_str_new((const char *)ret0.ptr, ret0.len); + VALUE to_s_str = rb_utf8_str_new((const char *)ret0.ptr, ret0.len); + rb_js_abi_host_string_free(&ret0); + return to_s_str; } /* @@ -494,7 +501,10 @@ static VALUE _rb_js_float_to_js(VALUE obj) { static VALUE _rb_js_string_to_js(VALUE obj) { rb_js_abi_host_string_t abi_str; rstring_to_abi_string(obj, &abi_str); - return jsvalue_s_new(rb_js_abi_host_string_to_js_string(&abi_str)); + rb_js_abi_host_own_js_abi_value_t js_str = + rb_js_abi_host_string_to_js_string(&abi_str); + rb_js_abi_host_string_free(&abi_str); + return jsvalue_s_new(js_str); } /* diff --git a/packages/gems/js/ext/js/types.h b/packages/gems/js/ext/js/types.h index b0063aa5f..a51456d20 100644 --- a/packages/gems/js/ext/js/types.h +++ b/packages/gems/js/ext/js/types.h @@ -44,6 +44,7 @@ typedef ext_list_string_t rb_abi_guest_list_string_t; ruby_js_js_runtime_export_js_value_to_host(borrow_js_value(value)) # define rb_js_abi_host_raw_integer_free(ptr) \ ruby_js_js_runtime_raw_integer_free(ptr) +# define rb_js_abi_host_string_free(ptr) ext_string_free(ptr) # define rb_js_abi_host_rb_object_to_js_rb_value(val) \ ruby_js_js_runtime_rb_object_to_js_rb_value(val) # define rb_js_abi_host_int_to_js_number(val) \ diff --git a/packages/npm-packages/ruby-wasm-wasi/test/unit/test_object.rb b/packages/npm-packages/ruby-wasm-wasi/test/unit/test_object.rb index 812bebe22..8c04b747f 100644 --- a/packages/npm-packages/ruby-wasm-wasi/test/unit/test_object.rb +++ b/packages/npm-packages/ruby-wasm-wasi/test/unit/test_object.rb @@ -441,4 +441,29 @@ def test_apply floor = JS.global[:Math][:floor] assert_equal 3, floor.apply(3.14).to_i end + + # Every string crossing the boundary now frees its ABI buffer, in both + # directions. Freeing one too early would hand back another allocation's + # bytes, so each round trip below carries a value distinct from its + # neighbours - garbage or a stale repeat fails the assertion rather than + # passing by luck. GC.stress shakes out an early free that only shows when + # the allocator reuses the block promptly. + def test_string_round_trips_survive_their_abi_buffers + object = JS.eval("return {};") + 64.times do |i| + key = "key_#{i}_#{"x" * i}" + value = "value_#{i}_#{"y" * i}" + object[key] = value + assert_equal value, object[key].to_s + assert_equal "string", object[key].typeof + end + GC.stress = true + 16.times do |i| + key = "stress_#{i}" + object[key] = JS.eval("return #{key.dump};") + assert_equal key, object[key].to_s + end + ensure + GC.stress = false + end end