Skip to content

Commit 23ab9cc

Browse files
bnoordhuiseregon
andauthored
JS code can now catch Ruby exceptions (#373)
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: #357 Co-authored-by: Benoit Daloze <[email protected]>
1 parent 2ab94dd commit 23ab9cc

File tree

6 files changed

+124
-13
lines changed

6 files changed

+124
-13
lines changed

ext/mini_racer_extension/mini_racer_extension.c

Lines changed: 56 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,13 @@
1313
#include "serde.c"
1414
#include "mini_racer_v8.h"
1515

16+
// for debugging
17+
#define RB_PUTS(v) \
18+
do { \
19+
fflush(stdout); \
20+
rb_funcall(rb_mKernel, rb_intern("puts"), 1, v); \
21+
} while (0)
22+
1623
#if RUBY_API_VERSION_CODE < 3*10000+4*100 // 3.4.0
1724
static inline void rb_thread_lock_native_thread(void)
1825
{
@@ -154,6 +161,7 @@ static VALUE platform_init_error;
154161
static VALUE context_disposed_error;
155162
static VALUE parse_error;
156163
static VALUE memory_error;
164+
static VALUE script_error;
157165
static VALUE runtime_error;
158166
static VALUE internal_error;
159167
static VALUE snapshot_error;
@@ -482,12 +490,36 @@ static void des_object_ref(void *arg, uint32_t id)
482490

483491
static void des_error_begin(void *arg)
484492
{
485-
push(arg, rb_class_new_instance(0, NULL, rb_eRuntimeError));
493+
push(arg, rb_ary_new());
486494
}
487495

488496
static void des_error_end(void *arg)
489497
{
490-
pop(arg);
498+
VALUE *a, h, message, stack, cause, newline;
499+
DesCtx *c;
500+
501+
c = arg;
502+
if (*c->err)
503+
return;
504+
if (c->tos == c->stack) {
505+
snprintf(c->err, sizeof(c->err), "stack underflow");
506+
return;
507+
}
508+
a = &c->tos->a;
509+
h = rb_ary_pop(*a);
510+
message = rb_hash_aref(h, rb_str_new_cstr("message"));
511+
stack = rb_hash_aref(h, rb_str_new_cstr("stack"));
512+
cause = rb_hash_aref(h, rb_str_new_cstr("cause"));
513+
if (NIL_P(message))
514+
message = rb_str_new_cstr("JS exception");
515+
if (!NIL_P(stack)) {
516+
newline = rb_str_new_cstr("\n");
517+
message = rb_funcall(message, rb_intern("concat"), 2, newline, stack);
518+
}
519+
*a = rb_class_new_instance(1, &message, script_error);
520+
if (!NIL_P(cause))
521+
rb_iv_set(*a, "@cause", cause);
522+
pop(c);
491523
}
492524

493525
static int collect(VALUE k, VALUE v, VALUE a)
@@ -894,6 +926,7 @@ static VALUE rendezvous_callback_do(VALUE arg)
894926
static void *rendezvous_callback(void *arg)
895927
{
896928
struct rendezvous_nogvl *a;
929+
const char *err;
897930
Context *c;
898931
int exc;
899932
VALUE r;
@@ -917,7 +950,12 @@ static void *rendezvous_callback(void *arg)
917950
buf_move(&s.b, a->req);
918951
return NULL;
919952
fail:
920-
ser_init1(&s, 'e'); // exception pending
953+
ser_init0(&s); // ruby exception pending
954+
w_byte(&s, 'e'); // send ruby error message to v8 thread
955+
r = rb_funcall(c->exception, rb_intern("to_s"), 0);
956+
err = StringValueCStr(r);
957+
if (err)
958+
w(&s, err, strlen(err));
921959
goto out;
922960
}
923961

@@ -975,18 +1013,20 @@ static VALUE rendezvous1(Context *c, Buf *req, DesCtx *d)
9751013
int exc;
9761014

9771015
rendezvous_no_des(c, req, &res); // takes ownership of |req|
1016+
r = c->exception;
1017+
c->exception = Qnil;
1018+
// if js land didn't handle exception from ruby callback, re-raise it now
1019+
if (res.len == 1 && *res.buf == 'e') {
1020+
assert(!NIL_P(r));
1021+
rb_exc_raise(r);
1022+
}
9781023
r = rb_protect(deserialize, (VALUE)&(struct rendezvous_des){d, &res}, &exc);
9791024
buf_reset(&res);
9801025
if (exc) {
9811026
r = rb_errinfo();
9821027
rb_set_errinfo(Qnil);
9831028
rb_exc_raise(r);
9841029
}
985-
if (!NIL_P(c->exception)) {
986-
r = c->exception;
987-
c->exception = Qnil;
988-
rb_exc_raise(r);
989-
}
9901030
return r;
9911031
}
9921032

@@ -1634,6 +1674,11 @@ static VALUE snapshot_size0(VALUE self)
16341674
return LONG2FIX(RSTRING_LENINT(ss->blob));
16351675
}
16361676

1677+
static VALUE script_error_cause(VALUE self)
1678+
{
1679+
return rb_iv_get(self, "@cause");
1680+
}
1681+
16371682
__attribute__((visibility("default")))
16381683
void Init_mini_racer_extension(void)
16391684
{
@@ -1648,10 +1693,13 @@ void Init_mini_racer_extension(void)
16481693
c = rb_define_class_under(m, "EvalError", c);
16491694
parse_error = rb_define_class_under(m, "ParseError", c);
16501695
memory_error = rb_define_class_under(m, "V8OutOfMemoryError", c);
1696+
script_error = rb_define_class_under(m, "ScriptError", c);
16511697
runtime_error = rb_define_class_under(m, "RuntimeError", c);
16521698
internal_error = rb_define_class_under(m, "InternalError", c);
16531699
terminated_error = rb_define_class_under(m, "ScriptTerminatedError", c);
16541700

1701+
rb_define_method(script_error, "cause", script_error_cause, 0);
1702+
16551703
c = context_class = rb_define_class_under(m, "Context", rb_cObject);
16561704
rb_define_method(c, "initialize", context_initialize, -1);
16571705
rb_define_method(c, "attach", context_attach, 2);

ext/mini_racer_extension/mini_racer_v8.cc

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ struct State
8686
v8::Persistent<v8::Context> persistent_context; // single-thread mode only
8787
v8::Persistent<v8::Context> persistent_safe_context; // single-thread mode only
8888
v8::Persistent<v8::Function> persistent_safe_context_function; // single-thread mode only
89+
v8::Persistent<v8::Value> ruby_exception;
8990
Context *ruby_context;
9091
int64_t max_memory;
9192
int err_reason;
@@ -122,6 +123,20 @@ struct Serialized
122123
}
123124
};
124125

126+
bool bubble_up_ruby_exception(State& st, v8::TryCatch *try_catch)
127+
{
128+
auto exception = try_catch->Exception();
129+
if (exception.IsEmpty()) return false;
130+
auto ruby_exception = v8::Local<v8::Value>::New(st.isolate, st.ruby_exception);
131+
if (ruby_exception.IsEmpty()) return false;
132+
if (!ruby_exception->SameValue(exception)) return false;
133+
// signal that the ruby thread should reraise the exception
134+
// that it caught earlier when executing a js->ruby callback
135+
uint8_t c = 'e';
136+
v8_reply(st.ruby_context, &c, 1);
137+
return true;
138+
}
139+
125140
// throws JS exception on serialization error
126141
bool reply(State& st, v8::Local<v8::Value> v)
127142
{
@@ -388,8 +403,17 @@ void v8_api_callback(const v8::FunctionCallbackInfo<v8::Value>& info)
388403
v8_roundtrip(st.ruby_context, &p, &n);
389404
if (*p == 'c') // callback reply
390405
break;
391-
if (*p == 'e') // ruby exception pending
392-
return st.isolate->TerminateExecution();
406+
if (*p == 'e') { // ruby exception pending
407+
v8::Local<v8::String> message;
408+
auto type = v8::NewStringType::kNormal;
409+
if (!v8::String::NewFromOneByte(st.isolate, p+1, type, n-1).ToLocal(&message)) {
410+
message = v8::String::NewFromUtf8Literal(st.isolate, "Ruby exception");
411+
}
412+
auto exception = v8::Exception::Error(message);
413+
st.ruby_exception.Reset(st.isolate, exception);
414+
st.isolate->ThrowException(exception);
415+
return;
416+
}
393417
v8_dispatch(st.ruby_context);
394418
}
395419
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)
523547
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
524548
st.err_reason = NO_ERROR;
525549
}
550+
if (bubble_up_ruby_exception(st, &try_catch)) return;
526551
if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR;
527552
if (cause) result = v8::Undefined(st.isolate);
528553
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)
571596
cause = st.err_reason ? st.err_reason : TERMINATED_ERROR;
572597
st.err_reason = NO_ERROR;
573598
}
599+
if (bubble_up_ruby_exception(st, &try_catch)) return;
574600
if (!cause && try_catch.HasCaught()) cause = RUNTIME_ERROR;
575601
if (cause) result = v8::Undefined(st.isolate);
576602
auto err = to_error(st, &try_catch, cause);
@@ -895,6 +921,7 @@ State::~State()
895921
v8::Isolate::Scope isolate_scope(isolate);
896922
persistent_safe_context.Reset();
897923
persistent_context.Reset();
924+
ruby_exception.Reset();
898925
}
899926
isolate->Dispose();
900927
for (Callback *cb : callbacks)

ext/mini_racer_extension/serde.c

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -194,17 +194,21 @@ static inline int r_zigzag(const uint8_t **p, const uint8_t *pe, int64_t *r)
194194
return 0;
195195
}
196196

197-
static inline void ser_init(Ser *s)
197+
static void ser_init0(Ser *s)
198198
{
199199
memset(s, 0, sizeof(*s));
200200
buf_init(&s->b);
201+
}
202+
203+
static inline void ser_init(Ser *s)
204+
{
205+
ser_init0(s);
201206
w(s, "\xFF\x0F", 2);
202207
}
203208

204209
static void ser_init1(Ser *s, uint8_t c)
205210
{
206-
memset(s, 0, sizeof(*s));
207-
buf_init(&s->b);
211+
ser_init0(s);
208212
w_byte(s, c);
209213
w(s, "\xFF\x0F", 2);
210214
}

lib/mini_racer.rb

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,19 @@ def backtrace
5151
end
5252
end
5353

54+
class ScriptError < EvalError
55+
def initialize(message)
56+
message, *@frames = message.split("\n")
57+
@frames.map! { "JavaScript #{_1.strip}" }
58+
super(message)
59+
end
60+
61+
def backtrace
62+
frames = super || []
63+
@frames + frames
64+
end
65+
end
66+
5467
class SnapshotError < Error
5568
def initialize(message)
5669
message, *@frames = message.split("\n")

lib/mini_racer/truffleruby.rb

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,10 @@ def convert_js_to_ruby(value)
246246
js_map_to_hash(value)
247247
elsif map_iterator?(value)
248248
value.map { |e| convert_js_to_ruby(e) }
249+
elsif Polyglot::ForeignException === value
250+
exc = MiniRacer::ScriptError.new(value.message)
251+
exc.set_backtrace(value.backtrace)
252+
exc
249253
else
250254
object = value
251255
h = {}

test/mini_racer_test.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1149,6 +1149,21 @@ def test_termination_exception
11491149
b.kill
11501150
end
11511151

1152+
def test_ruby_exception
1153+
context = MiniRacer::Context.new
1154+
context.attach("test", proc { raise "boom" })
1155+
line = __LINE__ - 1
1156+
actual = context.eval("try { test() } catch (e) { e }")
1157+
assert_equal(actual.class, MiniRacer::ScriptError)
1158+
assert_equal(actual.message, "boom")
1159+
if RUBY_ENGINE == "truffleruby"
1160+
assert_includes(actual.backtrace[0], "#{__FILE__}:#{line}")
1161+
assert_includes(actual.backtrace[0], "block in MiniRacerTest#test_ruby_exception")
1162+
else
1163+
assert_equal(actual.backtrace, ["JavaScript Error: boom", "JavaScript at <eval>:1:7"])
1164+
end
1165+
end
1166+
11521167
def test_large_integer
11531168
[10_000_000_001, -2**63, 2**63-1].each { |big_int|
11541169
context = MiniRacer::Context.new

0 commit comments

Comments
 (0)