From 141031c6ae77b4e97d6c9358658e5e1ca75cab44 Mon Sep 17 00:00:00 2001 From: Ben Noordhuis Date: Sat, 4 Oct 2025 23:25:51 +0200 Subject: [PATCH 1/4] JS code can now catch Ruby exceptions Before this commit exceptions from Ruby callbacks were translated to termination exceptions that cannot be caught by JS code. Turn them into regular exceptions that can be caught. Fixes: https://github.com/rubyjs/mini_racer/issues/357 --- .../mini_racer_extension.c | 22 ++++++++----- ext/mini_racer_extension/mini_racer_v8.cc | 31 +++++++++++++++++-- ext/mini_racer_extension/serde.c | 10 ++++-- test/mini_racer_test.rb | 11 +++++++ 4 files changed, 61 insertions(+), 13 deletions(-) diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index 3e2c135..2e6eed7 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -482,12 +482,10 @@ static void des_object_ref(void *arg, uint32_t id) static void des_error_begin(void *arg) { - push(arg, rb_class_new_instance(0, NULL, rb_eRuntimeError)); } static void des_error_end(void *arg) { - pop(arg); } static int collect(VALUE k, VALUE v, VALUE a) @@ -894,6 +892,7 @@ static VALUE rendezvous_callback_do(VALUE arg) static void *rendezvous_callback(void *arg) { struct rendezvous_nogvl *a; + const char *err; Context *c; int exc; VALUE r; @@ -917,7 +916,12 @@ static void *rendezvous_callback(void *arg) buf_move(&s.b, a->req); return NULL; fail: - ser_init1(&s, 'e'); // exception pending + ser_init0(&s); // ruby exception pending + w_byte(&s, 'e'); // send ruby error message to v8 thread + r = rb_funcall(c->exception, rb_intern("to_s"), 0); + err = StringValueCStr(r); + if (err) + w(&s, err, strlen(err)); goto out; } @@ -975,6 +979,13 @@ static VALUE rendezvous1(Context *c, Buf *req, DesCtx *d) int exc; rendezvous_no_des(c, req, &res); // takes ownership of |req| + r = c->exception; + c->exception = Qnil; + // if js land didn't handle exception from ruby callback, re-raise it now + if (res.len == 1 && *res.buf == 'e') { + assert(!NIL_P(r)); + rb_exc_raise(r); + } r = rb_protect(deserialize, (VALUE)&(struct rendezvous_des){d, &res}, &exc); buf_reset(&res); if (exc) { @@ -982,11 +993,6 @@ static VALUE rendezvous1(Context *c, Buf *req, DesCtx *d) rb_set_errinfo(Qnil); rb_exc_raise(r); } - if (!NIL_P(c->exception)) { - r = c->exception; - c->exception = Qnil; - rb_exc_raise(r); - } return r; } diff --git a/ext/mini_racer_extension/mini_racer_v8.cc b/ext/mini_racer_extension/mini_racer_v8.cc index 38d14ce..4f31e82 100644 --- a/ext/mini_racer_extension/mini_racer_v8.cc +++ b/ext/mini_racer_extension/mini_racer_v8.cc @@ -86,6 +86,7 @@ struct State v8::Persistent persistent_context; // single-thread mode only v8::Persistent persistent_safe_context; // single-thread mode only v8::Persistent persistent_safe_context_function; // single-thread mode only + v8::Persistent ruby_exception; Context *ruby_context; int64_t max_memory; int err_reason; @@ -122,6 +123,20 @@ struct Serialized } }; +bool bubble_up_ruby_exception(State& st, v8::TryCatch *try_catch) +{ + auto exception = try_catch->Exception(); + if (exception.IsEmpty()) return false; + auto ruby_exception = v8::Local::New(st.isolate, st.ruby_exception); + if (ruby_exception.IsEmpty()) return false; + if (!ruby_exception->SameValue(exception)) return false; + // signal that the ruby thread should reraise the exception + // that it caught earlier when executing a js->ruby callback + uint8_t c = 'e'; + v8_reply(st.ruby_context, &c, 1); + return true; +} + // throws JS exception on serialization error bool reply(State& st, v8::Local v) { @@ -388,8 +403,17 @@ void v8_api_callback(const v8::FunctionCallbackInfo& info) v8_roundtrip(st.ruby_context, &p, &n); if (*p == 'c') // callback reply break; - if (*p == 'e') // ruby exception pending - return st.isolate->TerminateExecution(); + if (*p == 'e') { // ruby exception pending + v8::Local message; + auto type = v8::NewStringType::kNormal; + if (!v8::String::NewFromOneByte(st.isolate, p+1, type, n-1).ToLocal(&message)) { + message = v8::String::NewFromUtf8Literal(st.isolate, "Ruby exception"); + } + auto exception = v8::Exception::Error(message); + st.ruby_exception.Reset(st.isolate, exception); + st.isolate->ThrowException(exception); + return; + } v8_dispatch(st.ruby_context); } v8::ValueDeserializer des(st.isolate, p+1, n-1); @@ -523,6 +547,7 @@ extern "C" void v8_call(State *pst, const uint8_t *p, size_t n) cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; st.err_reason = NO_ERROR; } + if (bubble_up_ruby_exception(st, &try_catch)) return; if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; if (cause) result = v8::Undefined(st.isolate); auto err = to_error(st, &try_catch, cause); @@ -571,6 +596,7 @@ extern "C" void v8_eval(State *pst, const uint8_t *p, size_t n) cause = st.err_reason ? st.err_reason : TERMINATED_ERROR; st.err_reason = NO_ERROR; } + if (bubble_up_ruby_exception(st, &try_catch)) return; if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR; if (cause) result = v8::Undefined(st.isolate); auto err = to_error(st, &try_catch, cause); @@ -895,6 +921,7 @@ State::~State() v8::Isolate::Scope isolate_scope(isolate); persistent_safe_context.Reset(); persistent_context.Reset(); + ruby_exception.Reset(); } isolate->Dispose(); for (Callback *cb : callbacks) diff --git a/ext/mini_racer_extension/serde.c b/ext/mini_racer_extension/serde.c index 344c4f5..7d5d51f 100644 --- a/ext/mini_racer_extension/serde.c +++ b/ext/mini_racer_extension/serde.c @@ -194,17 +194,21 @@ static inline int r_zigzag(const uint8_t **p, const uint8_t *pe, int64_t *r) return 0; } -static inline void ser_init(Ser *s) +static void ser_init0(Ser *s) { memset(s, 0, sizeof(*s)); buf_init(&s->b); +} + +static inline void ser_init(Ser *s) +{ + ser_init0(s); w(s, "\xFF\x0F", 2); } static void ser_init1(Ser *s, uint8_t c) { - memset(s, 0, sizeof(*s)); - buf_init(&s->b); + ser_init0(s); w_byte(s, c); w(s, "\xFF\x0F", 2); } diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index 03fceab..8e3e2b1 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1149,6 +1149,17 @@ def test_termination_exception b.kill end + def test_ruby_exception + context = MiniRacer::Context.new + context.attach("test", proc { raise "boom" }) + actual = context.eval("try { test() } catch (e) { e }") + expected = { + "message" => "boom", + "stack" => "Error: boom\n at :1:7", + } + assert_equal(actual, expected) + end + def test_large_integer [10_000_000_001, -2**63, 2**63-1].each { |big_int| context = MiniRacer::Context.new From 22ae7e6fa8e153d611a66c4769dcdc3fd7a183ac Mon Sep 17 00:00:00 2001 From: Ben Noordhuis Date: Sun, 5 Oct 2025 18:37:10 +0200 Subject: [PATCH 2/4] squash! disable test on truffleruby --- test/mini_racer_test.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index 8e3e2b1..d995a75 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1150,6 +1150,9 @@ def test_termination_exception end def test_ruby_exception + if RUBY_ENGINE == "truffleruby" + skip "TruffleRuby doesn't return JS exceptions as dictionaries" + end context = MiniRacer::Context.new context.attach("test", proc { raise "boom" }) actual = context.eval("try { test() } catch (e) { e }") From 615e7b94e9b9b8b566250f3105ea191a8a28ea22 Mon Sep 17 00:00:00 2001 From: Ben Noordhuis Date: Tue, 14 Oct 2025 09:59:04 +0200 Subject: [PATCH 3/4] squash! deserialize js exceptions to MiniRacer::ScriptError --- .../mini_racer_extension.c | 42 +++++++++++++++++++ lib/mini_racer.rb | 13 ++++++ test/mini_racer_test.rb | 11 ++--- 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/ext/mini_racer_extension/mini_racer_extension.c b/ext/mini_racer_extension/mini_racer_extension.c index 2e6eed7..5dd5cf1 100644 --- a/ext/mini_racer_extension/mini_racer_extension.c +++ b/ext/mini_racer_extension/mini_racer_extension.c @@ -13,6 +13,13 @@ #include "serde.c" #include "mini_racer_v8.h" +// for debugging +#define RB_PUTS(v) \ + do { \ + fflush(stdout); \ + rb_funcall(rb_mKernel, rb_intern("puts"), 1, v); \ + } while (0) + #if RUBY_API_VERSION_CODE < 3*10000+4*100 // 3.4.0 static inline void rb_thread_lock_native_thread(void) { @@ -154,6 +161,7 @@ static VALUE platform_init_error; static VALUE context_disposed_error; static VALUE parse_error; static VALUE memory_error; +static VALUE script_error; static VALUE runtime_error; static VALUE internal_error; static VALUE snapshot_error; @@ -482,10 +490,36 @@ static void des_object_ref(void *arg, uint32_t id) static void des_error_begin(void *arg) { + push(arg, rb_ary_new()); } static void des_error_end(void *arg) { + VALUE *a, h, message, stack, cause, newline; + DesCtx *c; + + c = arg; + if (*c->err) + return; + if (c->tos == c->stack) { + snprintf(c->err, sizeof(c->err), "stack underflow"); + return; + } + a = &c->tos->a; + h = rb_ary_pop(*a); + message = rb_hash_aref(h, rb_str_new_cstr("message")); + stack = rb_hash_aref(h, rb_str_new_cstr("stack")); + cause = rb_hash_aref(h, rb_str_new_cstr("cause")); + if (NIL_P(message)) + message = rb_str_new_cstr("JS exception"); + if (!NIL_P(stack)) { + newline = rb_str_new_cstr("\n"); + message = rb_funcall(message, rb_intern("concat"), 2, newline, stack); + } + *a = rb_class_new_instance(1, &message, script_error); + if (!NIL_P(cause)) + rb_iv_set(*a, "@cause", cause); + pop(c); } static int collect(VALUE k, VALUE v, VALUE a) @@ -1640,6 +1674,11 @@ static VALUE snapshot_size0(VALUE self) return LONG2FIX(RSTRING_LENINT(ss->blob)); } +static VALUE script_error_cause(VALUE self) +{ + return rb_iv_get(self, "@cause"); +} + __attribute__((visibility("default"))) void Init_mini_racer_extension(void) { @@ -1654,10 +1693,13 @@ void Init_mini_racer_extension(void) c = rb_define_class_under(m, "EvalError", c); parse_error = rb_define_class_under(m, "ParseError", c); memory_error = rb_define_class_under(m, "V8OutOfMemoryError", c); + script_error = rb_define_class_under(m, "ScriptError", c); runtime_error = rb_define_class_under(m, "RuntimeError", c); internal_error = rb_define_class_under(m, "InternalError", c); terminated_error = rb_define_class_under(m, "ScriptTerminatedError", c); + rb_define_method(script_error, "cause", script_error_cause, 0); + c = context_class = rb_define_class_under(m, "Context", rb_cObject); rb_define_method(c, "initialize", context_initialize, -1); rb_define_method(c, "attach", context_attach, 2); diff --git a/lib/mini_racer.rb b/lib/mini_racer.rb index aecdfc3..be670e5 100644 --- a/lib/mini_racer.rb +++ b/lib/mini_racer.rb @@ -51,6 +51,19 @@ def backtrace end end + class ScriptError < EvalError + def initialize(message) + message, *@frames = message.split("\n") + @frames.map! { "JavaScript #{_1.strip}" } + super(message) + end + + def backtrace + frames = super || [] + @frames + frames + end + end + class SnapshotError < Error def initialize(message) message, *@frames = message.split("\n") diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index d995a75..b9c5f71 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1150,17 +1150,12 @@ def test_termination_exception end def test_ruby_exception - if RUBY_ENGINE == "truffleruby" - skip "TruffleRuby doesn't return JS exceptions as dictionaries" - end context = MiniRacer::Context.new context.attach("test", proc { raise "boom" }) actual = context.eval("try { test() } catch (e) { e }") - expected = { - "message" => "boom", - "stack" => "Error: boom\n at :1:7", - } - assert_equal(actual, expected) + assert_equal(actual.class, MiniRacer::ScriptError) + assert_equal(actual.message, "boom") + assert_equal(actual.backtrace, ["JavaScript Error: boom", "JavaScript at :1:7"]) end def test_large_integer From 0f4471ddf0ee85f19a1969b03520ab053ada7f4f Mon Sep 17 00:00:00 2001 From: Benoit Daloze Date: Tue, 14 Oct 2025 11:12:51 +0200 Subject: [PATCH 4/4] Handle catching and returning a Ruby exception from a Ruby callback called from JS back to Ruby for the TruffleRuby backend --- lib/mini_racer/truffleruby.rb | 4 ++++ test/mini_racer_test.rb | 8 +++++++- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/lib/mini_racer/truffleruby.rb b/lib/mini_racer/truffleruby.rb index 1540085..bae8ba0 100644 --- a/lib/mini_racer/truffleruby.rb +++ b/lib/mini_racer/truffleruby.rb @@ -246,6 +246,10 @@ def convert_js_to_ruby(value) js_map_to_hash(value) elsif map_iterator?(value) value.map { |e| convert_js_to_ruby(e) } + elsif Polyglot::ForeignException === value + exc = MiniRacer::ScriptError.new(value.message) + exc.set_backtrace(value.backtrace) + exc else object = value h = {} diff --git a/test/mini_racer_test.rb b/test/mini_racer_test.rb index b9c5f71..f782f67 100644 --- a/test/mini_racer_test.rb +++ b/test/mini_racer_test.rb @@ -1152,10 +1152,16 @@ def test_termination_exception def test_ruby_exception context = MiniRacer::Context.new context.attach("test", proc { raise "boom" }) + line = __LINE__ - 1 actual = context.eval("try { test() } catch (e) { e }") assert_equal(actual.class, MiniRacer::ScriptError) assert_equal(actual.message, "boom") - assert_equal(actual.backtrace, ["JavaScript Error: boom", "JavaScript at :1:7"]) + if RUBY_ENGINE == "truffleruby" + assert_includes(actual.backtrace[0], "#{__FILE__}:#{line}") + assert_includes(actual.backtrace[0], "block in MiniRacerTest#test_ruby_exception") + else + assert_equal(actual.backtrace, ["JavaScript Error: boom", "JavaScript at :1:7"]) + end end def test_large_integer