async
async copied to clipboard
Add support for `stop(cause:)`.
Add cause: to Async::Task#stop(cause:) so that extra information about the reason for stopping can be provided. It must be an exception suitable for raise Async::Stop, cause: cause.
Types of Changes
- New feature.
Contribution
- [x] I added tests for my changes.
- [x] I tested my changes locally.
- [x] I agree to the Developer's Certificate of Origin 1.1.
I found an issue, Fiber#raise does not seem to support cause: keyword argument correctly. I need to investigate.
I test w/ a sleep, and sending SIGINT to the worker sleeping. Here's what I got:
Async::Stop: {cause: #<Async::Stop: {cause: #<Async::Stop: {cause: #<Async::Stop: {cause: #<Interrupt: Interrupt>}>}>}>}
/Users/ma/.gem/ruby/3.4.4/bundler/gems/async-53670d16cb43/lib/async/scheduler.rb:221:in 'IO::Event::Selector::KQueue#transfer'
/Users/ma/.gem/ruby/3.4.4/bundler/gems/async-53670d16cb43/lib/async/scheduler.rb:221:in 'Async::Scheduler#block'
/Users/ma/.gem/ruby/3.4.4/bundler/gems/async-53670d16cb43/lib/async/scheduler.rb:254:in 'Async::Scheduler#kernel_sleep'
Seems there is too much nesting.
Two issues:
- https://bugs.ruby-lang.org/issues/21359
- https://bugs.ruby-lang.org/issues/21360
@macournoyer I was able to understand the cause of the output you are seeing.
fiber = Fiber.new do
while true
begin
Fiber.yield
rescue Exception => error
puts "error: #{error.inspect}", "error.cause: #{error.cause.inspect}"
end
end
end
fiber.resume
# Example of raising an exception with a cause:
fiber.raise StandardError.new("boom"), cause: StandardError.new("cause")
On 3.4, the cause is not correctly attributed to the error and instead ends up being a keyword argument.
> chruby 3.4
> ruby ./test.rb
error: #<StandardError: {cause: #<StandardError: cause>}>
error.cause: nil
On ruby-head, the cause is correctly attributed to the error (including this PR):
> chruby ruby-head
> ruby ./test.rb
error: #<StandardError: boom>
error.cause: #<StandardError: cause>
Deeply nested cause Async::Stop: {cause: #<Async::Stop: {cause: #<Async::Stop: {cause: #<Async::Stop: {cause: #<Interrupt: Interrupt>}>}>}>} is an artefact of several tasks stopping in sequence. The causality chain is not clearly printed but in 3.5+ it should be.
Another before/after example:
require_relative "lib/async"
Async do
sleep
ensure
Console.error(self, "Task exiting", $!)
end
3.4.4 on main (without this PR)
> chruby 3.4.4
> ruby ./test.rb
^C 0.0s error: Object [oid=0x20] [ec=0x28] [pid=199670] [2025-07-23 18:23:35 +1200]
| Task exiting
| Async::Stop
| Async::Stop: Async::Stop
| → lib/async/scheduler.rb:191 in 'IO::Event::Selector::URing#transfer'
| lib/async/scheduler.rb:191 in 'Async::Scheduler#transfer'
| lib/async/scheduler.rb:281 in 'Async::Scheduler#kernel_sleep'
| ./test.rb:4 in 'Kernel#sleep'
| ./test.rb:4 in 'block in <main>'
| lib/async/task.rb:205 in 'block in Async::Task#run'
| lib/async/task.rb:443 in 'block in Async::Task#schedule'
/home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453:in 'IO::Event::Selector::URing#select': Interrupt
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453:in 'Async::Scheduler#run_once!'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:492:in 'Async::Scheduler#run_once'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:568:in 'block in Async::Scheduler#run'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:528:in 'block in Async::Scheduler#run_loop'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525:in 'Thread.handle_interrupt'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525:in 'Async::Scheduler#run_loop'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:567:in 'Async::Scheduler#run'
from /home/samuel/Developer/socketry/async/lib/kernel/async.rb:34:in 'Kernel#Async'
- The error encountered from within the task (Async::Stop) has no cause attached.
3.4.4 + this PR
> chruby 3.4.4
> ruby ./test.rb
^C 0.0s error: Object [oid=0x20] [ec=0x28] [pid=192112] [2025-07-23 18:12:29 +1200]
| Task exiting
| {cause: Interrupt}
| Async::Stop: {cause: Interrupt}
| → lib/async/scheduler.rb:191 in 'IO::Event::Selector::URing#transfer'
| lib/async/scheduler.rb:191 in 'Async::Scheduler#transfer'
| lib/async/scheduler.rb:281 in 'Async::Scheduler#kernel_sleep'
| ./test.rb:4 in 'Kernel#sleep'
| ./test.rb:4 in 'block in <main>'
| lib/async/task.rb:237 in 'block in Async::Task#run'
| lib/async/task.rb:481 in 'block in Async::Task#schedule'
/home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453:in 'IO::Event::Selector::URing#select': Interrupt
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453:in 'Async::Scheduler#run_once!'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:492:in 'Async::Scheduler#run_once'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:568:in 'block in Async::Scheduler#run'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:528:in 'block in Async::Scheduler#run_loop'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525:in 'Thread.handle_interrupt'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525:in 'Async::Scheduler#run_loop'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:567:in 'Async::Scheduler#run'
from /home/samuel/Developer/socketry/async/lib/kernel/async.rb:34:in 'Kernel#Async'
from ./test.rb:3:in '<main>'
- The error encountered from within the task has an odd keyword argument attached.
HEAD + this PR
> chruby ruby-head
> ruby ./test.rb
^C 0.0s error: Object [oid=0x20] [ec=0x28] [pid=192193] [2025-07-23 18:12:34 +1200]
| Task exiting
| Task was stopped
| Async::Stop: Task was stopped
| → lib/async/scheduler.rb:191 in 'IO::Event::Selector::URing#transfer'
| lib/async/scheduler.rb:191 in 'Async::Scheduler#transfer'
| lib/async/scheduler.rb:281 in 'Async::Scheduler#kernel_sleep'
| ./test.rb:4 in 'Kernel#sleep'
| ./test.rb:4 in 'block in <main>'
| lib/async/task.rb:237 in 'block in Async::Task#run'
| lib/async/task.rb:481 in 'block in Async::Task#schedule'
| Caused by Interrupt: Interrupt
| → /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453 in 'IO::Event::Selector::URing#select'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453 in 'Async::Scheduler#run_once!'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:492 in 'Async::Scheduler#run_once'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:568 in 'block in Async::Scheduler#run'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:528 in 'block in Async::Scheduler#run_loop'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525 in 'Thread.handle_interrupt'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525 in 'Async::Scheduler#run_loop'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:567 in 'Async::Scheduler#run'
| /home/samuel/Developer/socketry/async/lib/kernel/async.rb:34 in 'Kernel#Async'
| ./test.rb:3 in '<main>'
/home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453:in 'IO::Event::Selector::URing#select': Interrupt
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453:in 'Async::Scheduler#run_once!'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:492:in 'Async::Scheduler#run_once'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:568:in 'block in Async::Scheduler#run'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:528:in 'block in Async::Scheduler#run_loop'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525:in 'Thread.handle_interrupt'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525:in 'Async::Scheduler#run_loop'
from /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:567:in 'Async::Scheduler#run'
from /home/samuel/Developer/socketry/async/lib/kernel/async.rb:34:in 'Kernel#Async'
from ./test.rb:3:in '<main>'
- The cause is correctly attached.
HEAD (without this PR)
Interestingly enough, Fiber#raise automatically uses the current exception ($!) as cause, the same as Kernel#raise. Therefore, while this PR has some ergonomic advantages, it's not strictly needed:
> chruby ruby-head
> git checkout main
> ruby ./test.rb
^C 0.0s error: Object [oid=0x20] [ec=0x28] [pid=201905] [2025-07-23 18:26:59 +1200]
| Task exiting
| Async::Stop
| Async::Stop: Async::Stop
| → lib/async/scheduler.rb:191 in 'IO::Event::Selector::URing#transfer'
| lib/async/scheduler.rb:191 in 'Async::Scheduler#transfer'
| lib/async/scheduler.rb:281 in 'Async::Scheduler#kernel_sleep'
| ./test.rb:4 in 'Kernel#sleep'
| ./test.rb:4 in 'block in <main>'
| lib/async/task.rb:205 in 'block in Async::Task#run'
| lib/async/task.rb:443 in 'block in Async::Task#schedule'
| Caused by Interrupt: Interrupt
| → /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453 in 'IO::Event::Selector::URing#select'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:453 in 'Async::Scheduler#run_once!'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:492 in 'Async::Scheduler#run_once'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:568 in 'block in Async::Scheduler#run'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:528 in 'block in Async::Scheduler#run_loop'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525 in 'Thread.handle_interrupt'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:525 in 'Async::Scheduler#run_loop'
| /home/samuel/Developer/socketry/async/lib/async/scheduler.rb:567 in 'Async::Scheduler#run'
| /home/samuel/Developer/socketry/async/lib/kernel/async.rb:34 in 'Kernel#Async'
| ./test.rb:3 in '<main>'
- Cause is correctly attached in this case (but there are some cases where it won't be).
Okay, the changes to CRuby were merged. We just need to make this work as best we can across different Ruby versions.