Skip to content

fix(rb_loader): return TYPE_THROWABLE from invoke path on Ruby exception#689

Draft
devs6186 wants to merge 1 commit intometacall:developfrom
devs6186:fix/issue-141-rb-loader-exception-propagation
Draft

fix(rb_loader): return TYPE_THROWABLE from invoke path on Ruby exception#689
devs6186 wants to merge 1 commit intometacall:developfrom
devs6186:fix/issue-141-rb-loader-exception-propagation

Conversation

@devs6186
Copy link
Copy Markdown
Contributor

@devs6186 devs6186 commented Mar 15, 2026

Description

When a Ruby function raises an exception during a MetaCall invocation, the Ruby loader caught it via rb_protect() but then fell through to rb_type_deserialize() on Qnil — returning a TYPE_NULL value to the caller as if the function returned nothing. Four separate // TODO: Throw exception? comments have marked this as unfixed since issue #141 was opened.

This also explains issue #516 where calling a Ruby function that throws produces [null, 0] in the Node.js caller: the Node loader receives TYPE_NULL instead of a structured error it can re-raise.

Fixes #141

Root Cause

In function_rb_interface_invoke, after rb_protect() returns state != 0, the existing rb_loader_impl_print_last_exception() macro only logged the error to console. Control then fell to rb_type_deserialize(rb_function->impl, result_value, &v) where result_value is Qnil (the fallback from rb_protect), producing TYPE_NULL. The calling port (e.g. node_loader) had no way to distinguish "function returned nil" from "function threw an exception".

Solution

Replace the log-only macro with a static helper rb_loader_impl_exception_value() that builds a TYPE_THROWABLE value from the pending exception and returns it immediately, following the exact same pattern as py_loader_impl_error_value_from_exception() in py_loader_impl.c.

The helper:

  1. Reads the exception from rb_errinfo()
  2. Extracts class name (rb_obj_classname), message (#message), and backtrace entry (#backtrace[0])
  3. Creates exception_create_const / throwable_create / value_create_throwable
  4. Clears the pending exception with rb_set_errinfo(Qnil) before returning

All four rb_protect branches (TYPED, DUCKTYPED, MIXED, zero-args) now return early with the throwable value instead of continuing to rb_type_deserialize.

Changes Made

  • source/loaders/rb_loader/source/rb_loader_impl.c
    • Removed rb_loader_impl_print_last_exception macro (lines 348–363)
    • Added rb_loader_impl_exception_value() static function using the same exception extraction pattern as the Python loader
    • Replaced all four state != 0 blocks with return rb_loader_impl_exception_value()

Testing

The change follows the pattern already tested by metacall_python_exception_test (which calls metacall_error_from_value() on a TYPE_THROWABLE return from the Python loader). The equivalent Ruby test would load a script with a function that calls raise "something" and verify that metacall_error_from_value(ret, &ex) returns 0 and ex.label == "RuntimeError". The existing metacall_node_python_exception_test also exercises the Node loader's TYPE_THROWABLE reception path that now applies to Ruby as well.

Edge Cases

  • If rb_errinfo() returns Qnil (exception already cleared or state was 0), the helper returns NULL — same behavior as before
  • rb_set_errinfo(Qnil) is called before building the exception value to avoid the pending error interfering with subsequent Ruby API calls
  • The TYPE_THROWABLE value is allocated on the C heap; the caller owns it and must call metacall_value_destroy() when done, consistent with other metacall return values

Type of change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Checklist

  • Self-review performed
  • Code commented in hard-to-understand areas
  • No new warnings
  • Tests: existing metacall_python_exception_test validates the TYPE_THROWABLE infrastructure; Ruby-specific exception test pattern can be added as follow-up

Assisted with CLAUDE.md

When a Ruby function raises an exception, rb_protect() returns a non-zero
state but the invoke path fell through to rb_type_deserialize() on Qnil,
silently returning a TYPE_NULL value to the caller. The four TODO comments
marking this were unfixed since the issue was opened.

Replace the rb_loader_impl_print_last_exception macro (which only logged)
with a static helper rb_loader_impl_exception_value() that extracts the
pending exception from rb_errinfo(), builds a TYPE_THROWABLE value using
exception_create_const/throwable_create/value_create_throwable — the same
pattern used by py_loader_impl_error_value_from_exception() — and returns
it immediately. The error is cleared with rb_set_errinfo(Qnil) so it does
not propagate further up the Ruby stack.

All four invoke branches (TYPED, DUCKTYPED, MIXED, zero-args) now return a
structured throwable carrying the exception class name, message, and first
backtrace line. Node.js callers receive a native JS Error via the existing
TYPE_THROWABLE handling in node_loader_impl_value_to_napi(), and C callers
can extract the exception with metacall_error_from_value().

Fixes metacall#141
@devs6186 devs6186 force-pushed the fix/issue-141-rb-loader-exception-propagation branch from df1f45c to 2fef942 Compare March 15, 2026 14:50
@viferga
Copy link
Copy Markdown
Member

viferga commented Mar 16, 2026

Add tests and I will merge it.

@viferga
Copy link
Copy Markdown
Member

viferga commented Mar 17, 2026

Can you show a screenshot of how the error looks now?

@viferga viferga marked this pull request as draft March 17, 2026 01:07
@viferga
Copy link
Copy Markdown
Member

viferga commented Mar 19, 2026

Can you show a screenshot of how the error looks now?

@devs6186

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.

Exception & Error handling is non-existent

2 participants