Support cause: in Thread#raise and Fiber#raise. (#13967)

* Add support for `cause:` argument to `Fiber#raise` and `Thread#raise`.

The implementation behaviour is consistent with `Kernel#raise` and
`Exception#initialize` methods, allowing the `cause:` argument to be
passed to `Fiber#raise` and `Thread#raise`. This change ensures that
the `cause:` argument is handled correctly, providing a more consistent
and expected behavior when raising exceptions in fibers and threads.

[Feature #21360]

* Shared specs for Fiber/Thread/Kernel raise.

---------

Co-authored-by: Samuel Williams <samuel.williams@shopify.com>
This commit is contained in:
Samuel Williams 2025-07-24 14:45:43 +12:00 committed by GitHub
parent 2e0a782936
commit 64f508ade8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 450 additions and 74 deletions

11
NEWS.md
View file

@ -104,6 +104,16 @@ Note: We're only listing outstanding class updates.
* Update Unicode to Version 16.0.0 and Emoji Version 16.0. * Update Unicode to Version 16.0.0 and Emoji Version 16.0.
[[Feature #19908]][[Feature #20724]] (also applies to Regexp) [[Feature #19908]][[Feature #20724]] (also applies to Regexp)
* Thread
* Introduce support for `Thread#raise(cause:)` argument similar to
`Kernel#raise`. [[Feature #21360]]
* Fiber
* Introduce support for `Fiber#raise(cause:)` argument similar to
`Kernel#raise`. [[Feature #21360]]
* Fiber::Scheduler * Fiber::Scheduler
* Introduce `Fiber::Scheduler#fiber_interrupt` to interrupt a fiber with a * Introduce `Fiber::Scheduler#fiber_interrupt` to interrupt a fiber with a
@ -243,3 +253,4 @@ The following bundled gems are updated.
[Feature #21262]: https://bugs.ruby-lang.org/issues/21262 [Feature #21262]: https://bugs.ruby-lang.org/issues/21262
[Feature #21287]: https://bugs.ruby-lang.org/issues/21287 [Feature #21287]: https://bugs.ruby-lang.org/issues/21287
[Feature #21347]: https://bugs.ruby-lang.org/issues/21347 [Feature #21347]: https://bugs.ruby-lang.org/issues/21347
[Feature #21360]: https://bugs.ruby-lang.org/issues/21360

View file

@ -4094,6 +4094,7 @@ cont.$(OBJEXT): $(top_srcdir)/internal/basic_operators.h
cont.$(OBJEXT): $(top_srcdir)/internal/compilers.h cont.$(OBJEXT): $(top_srcdir)/internal/compilers.h
cont.$(OBJEXT): $(top_srcdir)/internal/cont.h cont.$(OBJEXT): $(top_srcdir)/internal/cont.h
cont.$(OBJEXT): $(top_srcdir)/internal/error.h cont.$(OBJEXT): $(top_srcdir)/internal/error.h
cont.$(OBJEXT): $(top_srcdir)/internal/eval.h
cont.$(OBJEXT): $(top_srcdir)/internal/gc.h cont.$(OBJEXT): $(top_srcdir)/internal/gc.h
cont.$(OBJEXT): $(top_srcdir)/internal/imemo.h cont.$(OBJEXT): $(top_srcdir)/internal/imemo.h
cont.$(OBJEXT): $(top_srcdir)/internal/namespace.h cont.$(OBJEXT): $(top_srcdir)/internal/namespace.h
@ -19292,6 +19293,7 @@ thread.$(OBJEXT): $(top_srcdir)/internal/class.h
thread.$(OBJEXT): $(top_srcdir)/internal/compilers.h thread.$(OBJEXT): $(top_srcdir)/internal/compilers.h
thread.$(OBJEXT): $(top_srcdir)/internal/cont.h thread.$(OBJEXT): $(top_srcdir)/internal/cont.h
thread.$(OBJEXT): $(top_srcdir)/internal/error.h thread.$(OBJEXT): $(top_srcdir)/internal/error.h
thread.$(OBJEXT): $(top_srcdir)/internal/eval.h
thread.$(OBJEXT): $(top_srcdir)/internal/gc.h thread.$(OBJEXT): $(top_srcdir)/internal/gc.h
thread.$(OBJEXT): $(top_srcdir)/internal/hash.h thread.$(OBJEXT): $(top_srcdir)/internal/hash.h
thread.$(OBJEXT): $(top_srcdir)/internal/imemo.h thread.$(OBJEXT): $(top_srcdir)/internal/imemo.h

5
cont.c
View file

@ -30,6 +30,7 @@ extern int madvise(caddr_t, size_t, int);
#include "internal/cont.h" #include "internal/cont.h"
#include "internal/thread.h" #include "internal/thread.h"
#include "internal/error.h" #include "internal/error.h"
#include "internal/eval.h"
#include "internal/gc.h" #include "internal/gc.h"
#include "internal/proc.h" #include "internal/proc.h"
#include "internal/sanitizers.h" #include "internal/sanitizers.h"
@ -3218,9 +3219,9 @@ fiber_raise(rb_fiber_t *fiber, VALUE exception)
} }
VALUE VALUE
rb_fiber_raise(VALUE fiber, int argc, const VALUE *argv) rb_fiber_raise(VALUE fiber, int argc, VALUE *argv)
{ {
VALUE exception = rb_make_exception(argc, argv); VALUE exception = rb_exception_setup(argc, argv);
return fiber_raise(fiber_ptr(fiber), exception); return fiber_raise(fiber_ptr(fiber), exception);
} }

139
eval.c
View file

@ -703,49 +703,142 @@ rb_interrupt(void)
rb_exc_raise(rb_exc_new(rb_eInterrupt, 0, 0)); rb_exc_raise(rb_exc_new(rb_eInterrupt, 0, 0));
} }
enum {raise_opt_cause, raise_max_opt}; /*< \private */
static int static int
extract_raise_opts(int argc, VALUE *argv, VALUE *opts) extract_raise_options(int argc, VALUE *argv, VALUE *cause)
{ {
int i; // Keyword arguments:
if (argc > 0) { static ID keywords[1] = {0};
VALUE opt; if (!keywords[0]) {
argc = rb_scan_args(argc, argv, "*:", NULL, &opt);
if (!NIL_P(opt)) {
if (!RHASH_EMPTY_P(opt)) {
ID keywords[1];
CONST_ID(keywords[0], "cause"); CONST_ID(keywords[0], "cause");
rb_get_kwargs(opt, keywords, 0, -1-raise_max_opt, opts); }
if (!RHASH_EMPTY_P(opt)) argv[argc++] = opt;
if (argc > 0) {
VALUE options;
argc = rb_scan_args(argc, argv, "*:", NULL, &options);
if (!NIL_P(options)) {
if (!RHASH_EMPTY_P(options)) {
// Extract optional cause keyword argument, leaving any other options alone:
rb_get_kwargs(options, keywords, 0, -2, cause);
// If there were any other options, add them back to the arguments:
if (!RHASH_EMPTY_P(options)) argv[argc++] = options;
}
}
}
return argc; return argc;
}
/**
* Complete exception setup for cross-context raises (Thread#raise, Fiber#raise).
* Handles keyword extraction, validation, exception creation, and cause assignment.
*
* @param[in] argc Number of arguments
* @param[in] argv Argument array (will be modified for keyword extraction)
* @return Prepared exception object with cause applied
*/
VALUE
rb_exception_setup(int argc, VALUE *argv)
{
rb_execution_context_t *ec = GET_EC();
// Extract cause keyword argument:
VALUE cause = Qundef;
argc = extract_raise_options(argc, argv, &cause);
// Validate cause-only case:
if (argc == 0 && !UNDEF_P(cause)) {
rb_raise(rb_eArgError, "only cause is given with no arguments");
}
// Create exception:
VALUE exception;
if (argc == 0) {
exception = rb_exc_new(rb_eRuntimeError, 0, 0);
}
else {
exception = rb_make_exception(argc, argv);
}
VALUE resolved_cause = Qnil;
// Resolve cause with validation:
if (UNDEF_P(cause)) {
// No explicit cause - use automatic cause chaining from calling context:
resolved_cause = rb_ec_get_errinfo(ec);
// Prevent self-referential cause (e.g. `raise $!`):
if (resolved_cause == exception) {
resolved_cause = Qnil;
} }
} }
else if (NIL_P(cause)) {
// Explicit nil cause - prevent chaining:
resolved_cause = Qnil;
} }
for (i = 0; i < raise_max_opt; ++i) { else {
opts[i] = Qundef; // Explicit cause - validate and assign:
if (!rb_obj_is_kind_of(cause, rb_eException)) {
rb_raise(rb_eTypeError, "exception object expected");
} }
return argc;
if (cause == exception) {
// Prevent self-referential cause (e.g. `raise error, cause: error`) - although I'm not sure this is good behaviour, it's inherited from `Kernel#raise`.
resolved_cause = Qnil;
}
else {
// Check for circular causes:
VALUE current_cause = cause;
while (!NIL_P(current_cause)) {
// We guarantee that the cause chain is always terminated. Then, creating an exception with an existing cause is not circular as long as exception is not an existing cause of any other exception.
if (current_cause == exception) {
rb_raise(rb_eArgError, "circular causes");
}
if (THROW_DATA_P(current_cause)) {
break;
}
current_cause = rb_attr_get(current_cause, id_cause);
}
resolved_cause = cause;
}
}
// Apply cause to exception object (duplicate if frozen):
if (!UNDEF_P(resolved_cause)) {
if (OBJ_FROZEN(exception)) {
exception = rb_obj_dup(exception);
}
rb_ivar_set(exception, id_cause, resolved_cause);
}
return exception;
} }
VALUE VALUE
rb_f_raise(int argc, VALUE *argv) rb_f_raise(int argc, VALUE *argv)
{ {
VALUE err; VALUE cause = Qundef;
VALUE opts[raise_max_opt], *const cause = &opts[raise_opt_cause]; argc = extract_raise_options(argc, argv, &cause);
argc = extract_raise_opts(argc, argv, opts); VALUE exception;
// Bare re-raise case:
if (argc == 0) { if (argc == 0) {
if (!UNDEF_P(*cause)) { // Cause was extracted, but no arguments were provided:
if (!UNDEF_P(cause)) {
rb_raise(rb_eArgError, "only cause is given with no arguments"); rb_raise(rb_eArgError, "only cause is given with no arguments");
} }
err = get_errinfo();
if (!NIL_P(err)) { // Otherwise, re-raise the current exception:
exception = get_errinfo();
if (!NIL_P(exception)) {
argc = 1; argc = 1;
argv = &err; argv = &exception;
} }
} }
rb_raise_jump(rb_make_exception(argc, argv), *cause);
rb_raise_jump(rb_make_exception(argc, argv), cause);
UNREACHABLE_RETURN(Qnil); UNREACHABLE_RETURN(Qnil);
} }

View file

@ -275,7 +275,7 @@ VALUE rb_fiber_transfer_kw(VALUE fiber, int argc, const VALUE *argv, int kw_spla
* @exception rb_eFiberError `fiber` is terminated etc. * @exception rb_eFiberError `fiber` is terminated etc.
* @return (See rb_fiber_resume() for details) * @return (See rb_fiber_resume() for details)
*/ */
VALUE rb_fiber_raise(VALUE fiber, int argc, const VALUE *argv); VALUE rb_fiber_raise(VALUE fiber, int argc, VALUE *argv);
RBIMPL_SYMBOL_EXPORT_END() RBIMPL_SYMBOL_EXPORT_END()

View file

@ -26,6 +26,7 @@ extern ID ruby_static_id_status;
VALUE rb_refinement_module_get_refined_class(VALUE module); VALUE rb_refinement_module_get_refined_class(VALUE module);
void rb_class_modify_check(VALUE); void rb_class_modify_check(VALUE);
NORETURN(VALUE rb_f_raise(int argc, VALUE *argv)); NORETURN(VALUE rb_f_raise(int argc, VALUE *argv));
VALUE rb_exception_setup(int argc, VALUE *argv);
void rb_refinement_setup(struct rb_refinements_data *data, VALUE module, VALUE klass); void rb_refinement_setup(struct rb_refinements_data *data, VALUE module, VALUE klass);
void rb_vm_using_module(VALUE module); void rb_vm_using_module(VALUE module);
VALUE rb_top_main_class(const char *method); VALUE rb_top_main_class(const char *method);

View file

@ -1,10 +1,20 @@
module FiberSpecs module FiberSpecs
class NewFiberToRaise class NewFiberToRaise
def self.raise(*args) def self.raise(*args, **kwargs, &block)
fiber = Fiber.new { Fiber.yield } fiber = Fiber.new do
if block_given?
block.call do
Fiber.yield
end
else
Fiber.yield
end
end
fiber.resume fiber.resume
fiber.raise(*args)
fiber.raise(*args, **kwargs)
end end
end end

View file

@ -4,6 +4,7 @@ require_relative '../../shared/kernel/raise'
describe "Fiber#raise" do describe "Fiber#raise" do
it_behaves_like :kernel_raise, :raise, FiberSpecs::NewFiberToRaise it_behaves_like :kernel_raise, :raise, FiberSpecs::NewFiberToRaise
it_behaves_like :kernel_raise_across_contexts, :raise, FiberSpecs::NewFiberToRaise
end end
describe "Fiber#raise" do describe "Fiber#raise" do

View file

@ -6,6 +6,30 @@ module ThreadSpecs
end end
end end
class NewThreadToRaise
def self.raise(*args, **kwargs, &block)
thread = Thread.new do
Thread.current.report_on_exception = false
if block_given?
block.call do
sleep
end
else
sleep
end
end
Thread.pass until thread.stop?
thread.raise(*args, **kwargs)
thread.join
ensure
thread.kill if thread.alive?
end
end
class Status class Status
attr_reader :thread, :inspect, :status, :to_s attr_reader :thread, :inspect, :status, :to_s
def initialize(thread) def initialize(thread)

View file

@ -3,6 +3,9 @@ require_relative 'fixtures/classes'
require_relative '../../shared/kernel/raise' require_relative '../../shared/kernel/raise'
describe "Thread#raise" do describe "Thread#raise" do
it_behaves_like :kernel_raise, :raise, ThreadSpecs::NewThreadToRaise
it_behaves_like :kernel_raise_across_contexts, :raise, ThreadSpecs::NewThreadToRaise
it "ignores dead threads and returns nil" do it "ignores dead threads and returns nil" do
t = Thread.new { :dead } t = Thread.new { :dead }
Thread.pass while t.alive? Thread.pass while t.alive?

View file

@ -104,43 +104,24 @@ describe :kernel_raise, shared: true do
end end
it "re-raises a previously rescued exception without overwriting the backtrace" do it "re-raises a previously rescued exception without overwriting the backtrace" do
# This spec is written using #backtrace and matching the line number exception = nil
# from the string, as backtrace_locations is a more advanced
# method that is not always supported by implementations.
#
initial_raise_line = nil
raise_again_line = nil
raised_again = nil
if defined?(FiberSpecs::NewFiberToRaise) and @object == FiberSpecs::NewFiberToRaise
fiber = Fiber.new do
begin begin
initial_raise_line = __LINE__; Fiber.yield raise "raised"
rescue => raised rescue => exception
begin # Ignore.
raise_again_line = __LINE__; Fiber.yield raised
rescue => raised_again
raised_again
end
end
end
fiber.resume
raised = fiber.raise 'raised'
raised_again = fiber.raise raised
else
begin
initial_raise_line = __LINE__; @object.raise 'raised'
rescue => raised
begin
raise_again_line = __LINE__; @object.raise raised
rescue => raised_again
raised_again
end
end
end end
raised_again.backtrace.first.should include("#{__FILE__}:#{initial_raise_line}:") backtrace = exception.backtrace
raised_again.backtrace.first.should_not include("#{__FILE__}:#{raise_again_line}:")
begin
raised_exception = @object.raise(exception)
rescue => raised_exception
# Ignore.
end
raised_exception.backtrace.should == backtrace
raised_exception.should == exception
end end
it "allows Exception, message, and backtrace parameters" do it "allows Exception, message, and backtrace parameters" do
@ -159,4 +140,259 @@ describe :kernel_raise, shared: true do
} }
end end
end end
ruby_version_is "3.5" do
it "allows cause keyword argument" do
cause = StandardError.new("original error")
result = nil
-> do
@object.raise("new error", cause: cause)
end.should raise_error(RuntimeError, "new error") do |error|
error.cause.should == cause
end
end
it "raises an ArgumentError when only cause is given" do
cause = StandardError.new("cause")
-> do
@object.raise(cause: cause)
end.should raise_error(ArgumentError, "only cause is given with no arguments")
end
it "raises an ArgumentError when only cause is given and is nil" do
-> do
@object.raise(cause: nil)
end.should raise_error(ArgumentError, "only cause is given with no arguments")
end
it "raises a TypeError when given cause is not an instance of Exception" do
cause = Object.new
-> do
@object.raise("message", cause: cause)
end.should raise_error(TypeError, "exception object expected")
end
it "doesn't set given cause when it equals the raised exception" do
cause = StandardError.new("cause")
result = nil
-> do
@object.raise(cause, cause: cause)
end.should raise_error(StandardError, "cause") do |error|
error.should == cause
error.cause.should == nil
end
end
it "accepts cause equal an exception" do
error = RuntimeError.new("message")
result = nil
-> do
@object.raise(error, cause: error)
end.should raise_error(RuntimeError, "message") do |error|
error.cause.should == nil
end
end
it "rejects circular causes" do
-> {
begin
raise "Error 1"
rescue => error1
begin
raise "Error 2"
rescue => error2
begin
raise "Error 3"
rescue => error3
@object.raise(error1, cause: error3)
end
end
end
}.should raise_error(ArgumentError, "circular causes")
end
it "supports exception class with message and cause" do
cause = StandardError.new("cause message")
result = nil
-> do
@object.raise(ArgumentError, "argument error message", cause: cause)
end.should raise_error(ArgumentError, "argument error message") do |error|
error.should be_kind_of(ArgumentError)
error.message.should == "argument error message"
error.cause.should == cause
end
end
it "supports exception class with message, backtrace and cause" do
cause = StandardError.new("cause message")
backtrace = ["line1", "line2"]
result = nil
-> do
@object.raise(ArgumentError, "argument error message", backtrace, cause: cause)
end.should raise_error(ArgumentError, "argument error message") do |error|
error.should be_kind_of(ArgumentError)
error.message.should == "argument error message"
error.cause.should == cause
error.backtrace.should == backtrace
end
end
it "supports automatic cause chaining" do
-> do
begin
raise "first error"
rescue
# No explicit cause - should chain automatically:
@object.raise("second error")
end
end.should raise_error(RuntimeError, "second error") do |error|
error.cause.should be_kind_of(RuntimeError)
error.cause.message.should == "first error"
end
end
it "supports cause: nil to prevent automatic cause chaining" do
-> do
begin
raise "first error"
rescue
# Explicit nil prevents chaining:
@object.raise("second error", cause: nil)
end
end.should raise_error(RuntimeError, "second error") do |error|
error.cause.should == nil
end
end
end
end
describe :kernel_raise_across_contexts, shared: true do
ruby_version_is "3.5" do
describe "with cause keyword argument" do
it "uses the cause from the calling context" do
original_cause = nil
result = nil
# We have no cause ($!) and we don't specify one explicitly either:
@object.raise("second error") do |&block|
begin
begin
raise "first error"
rescue => original_cause
# We have a cause here ($!) but we should ignore it:
block.call
end
rescue => result
# Ignore.
end
end
result.should be_kind_of(RuntimeError)
result.message.should == "second error"
result.cause.should == nil
end
it "accepts a cause keyword argument that overrides the last exception" do
original_cause = nil
override_cause = StandardError.new("override cause")
result = nil
begin
raise "outer error"
rescue
# We have an existing cause, but we want to override it:
@object.raise("second error", cause: override_cause) do |&block|
begin
begin
raise "first error"
rescue => original_cause
# We also have an existing cause here:
block.call
end
rescue => result
# Ignore.
end
end
end
result.should be_kind_of(RuntimeError)
result.message.should == "second error"
result.cause.should == override_cause
end
it "supports automatic cause chaining from calling context" do
result = nil
@object.raise("new error") do |&block|
begin
begin
raise "original error"
rescue
block.call # Let the context yield/sleep
end
rescue => result
# Ignore.
end
end
result.should be_kind_of(RuntimeError)
result.message.should == "new error"
# Calling context has no current exception:
result.cause.should == nil
end
it "supports explicit cause: nil to prevent cause chaining" do
result = nil
begin
raise "calling context error"
rescue
@object.raise("new error", cause: nil) do |&block|
begin
begin
raise "target context error"
rescue
block.call # Let the context yield/sleep
end
rescue => result
# Ignore.
end
end
result.should be_kind_of(RuntimeError)
result.message.should == "new error"
result.cause.should == nil
end
end
it "raises TypeError when cause is not an Exception" do
-> {
@object.raise("error", cause: "not an exception") do |&block|
begin
block.call # Let the context yield/sleep
rescue
# Ignore - we expect the TypeError to be raised in the calling context
end
end
}.should raise_error(TypeError, "exception object expected")
end
it "raises ArgumentError when only cause is given with no arguments" do
-> {
@object.raise(cause: StandardError.new("cause")) do |&block|
begin
block.call # Let the context yield/sleep
rescue
# Ignore - we expect the ArgumentError to be raised in the calling context
end
end
}.should raise_error(ArgumentError, "only cause is given with no arguments")
end
end
end
end end

View file

@ -78,6 +78,7 @@
#include "internal/class.h" #include "internal/class.h"
#include "internal/cont.h" #include "internal/cont.h"
#include "internal/error.h" #include "internal/error.h"
#include "internal/eval.h"
#include "internal/gc.h" #include "internal/gc.h"
#include "internal/hash.h" #include "internal/hash.h"
#include "internal/io.h" #include "internal/io.h"
@ -2710,18 +2711,11 @@ rb_threadptr_ready(rb_thread_t *th)
static VALUE static VALUE
rb_threadptr_raise(rb_thread_t *target_th, int argc, VALUE *argv) rb_threadptr_raise(rb_thread_t *target_th, int argc, VALUE *argv)
{ {
VALUE exc;
if (rb_threadptr_dead(target_th)) { if (rb_threadptr_dead(target_th)) {
return Qnil; return Qnil;
} }
if (argc == 0) { VALUE exception = rb_exception_setup(argc, argv);
exc = rb_exc_new(rb_eRuntimeError, 0, 0);
}
else {
exc = rb_make_exception(argc, argv);
}
/* making an exception object can switch thread, /* making an exception object can switch thread,
so we need to check thread deadness again */ so we need to check thread deadness again */
@ -2729,9 +2723,9 @@ rb_threadptr_raise(rb_thread_t *target_th, int argc, VALUE *argv)
return Qnil; return Qnil;
} }
rb_ec_setup_exception(GET_EC(), exc, Qundef); rb_threadptr_pending_interrupt_enque(target_th, exception);
rb_threadptr_pending_interrupt_enque(target_th, exc);
rb_threadptr_interrupt(target_th); rb_threadptr_interrupt(target_th);
return Qnil; return Qnil;
} }