diff --git a/benchmark/cancel_cause.rb b/benchmark/cancel_cause.rb new file mode 100644 index 00000000..75f48152 --- /dev/null +++ b/benchmark/cancel_cause.rb @@ -0,0 +1,78 @@ +#!/usr/bin/env ruby +# frozen_string_literal: true + +# Released under the MIT License. +# Copyright, 2026, by Shopify Inc. +# Copyright, 2026, by Samuel Williams. + +require "async" + +module CountCancelCauses + attr_accessor :for_calls + + def for(*arguments, **options, &block) + self.for_calls ||= 0 + self.for_calls += 1 + + super(*arguments, **options, &block) + end +end + +Async::Cancel::Cause.singleton_class.prepend(CountCancelCauses) + +def positive_integer(name, default) + Integer(ENV.fetch(name, default)).tap do |value| + abort("#{name} must be positive!") unless value.positive? + end +end + +def measure_cancel(total_tasks) + scheduler = Async::Scheduler.new + ready = Thread::Queue.new + + Fiber.set_scheduler(scheduler) + + begin + total_tasks.times do + scheduler.async do + ready.push(true) + sleep + end + end + + total_tasks.times do + ready.pop + end + + GC.start + Async::Cancel::Cause.for_calls = 0 + + # Measure the synchronous fan-out only; draining cancelled tasks happens in `ensure`. + allocated_before = GC.stat(:total_allocated_objects) + start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC) + + scheduler.cancel + + duration = Process.clock_gettime(Process::CLOCK_MONOTONIC) - start_time + allocated_after = GC.stat(:total_allocated_objects) + + return { + tasks: total_tasks, + cause_for_calls: Async::Cancel::Cause.for_calls, + allocated_objects: allocated_after - allocated_before, + duration_ms: duration * 1000.0 + } + ensure + scheduler.run unless scheduler.closed? + Fiber.set_scheduler(nil) + end +end + +tasks = positive_integer("TASKS", 1365) + +result = measure_cancel(tasks) + +puts "tasks=#{result.fetch(:tasks)}" +puts "cause_for_calls=#{result.fetch(:cause_for_calls)}" +puts "allocated_objects=#{result.fetch(:allocated_objects)}" +puts "duration_ms=#{format('%.2f', result.fetch(:duration_ms))}" diff --git a/lib/async/node.rb b/lib/async/node.rb index 6a6e3196..14ccc41a 100644 --- a/lib/async/node.rb +++ b/lib/async/node.rb @@ -283,9 +283,10 @@ def terminate # Attempt to cancel the current node immediately, including all non-transient children. Invokes {#stop_children} to cancel all children. # # @parameter later [Boolean] Whether to defer cancelling until some point in the future. - def cancel(later = false) + # @parameter cause [Exception | Nil] The cause of the cancel operation. + def cancel(later = false, cause: $!) # The implementation of this method may defer calling `stop_children`. - stop_children(later) + stop_children(later, cause: cause) end # Backward compatibility alias for {#cancel}. @@ -295,9 +296,9 @@ def stop(...) end # Attempt to stop all non-transient children. - private def stop_children(later = false) + private def stop_children(later = false, cause:) @children&.each do |child| - child.cancel(later) unless child.transient? + child.cancel(later, cause: cause) unless child.transient? end end diff --git a/lib/async/promise.rb b/lib/async/promise.rb index 586c0b2c..416083c4 100644 --- a/lib/async/promise.rb +++ b/lib/async/promise.rb @@ -98,7 +98,9 @@ def value end # Wait with deadline tracking: - until @resolved + loop do + break if @resolved + # Get remaining time for this wait iteration: remaining = deadline.remaining diff --git a/lib/async/scheduler.rb b/lib/async/scheduler.rb index fde33e6b..437b490c 100644 --- a/lib/async/scheduler.rb +++ b/lib/async/scheduler.rb @@ -514,15 +514,19 @@ def run_once(timeout = nil) # Cancel all children, including transient children. # # @public Since *Async v1*. - def cancel + def cancel(later = false, cause: $!) + if @children and !cause + cause = Cancel::Cause.for("Cancelling task!") + end + @children&.each do |child| - child.cancel + child.cancel(later, cause: cause) end end # Backward compatibility alias for cancel. - def stop - cancel + def stop(...) + cancel(...) end # Run the event loop, until the scheduler is interrupted or finished. diff --git a/lib/async/task.rb b/lib/async/task.rb index d8a8760f..b520f307 100644 --- a/lib/async/task.rb +++ b/lib/async/task.rb @@ -333,7 +333,7 @@ def cancel(later = false, cause: $!) if self.cancelled? # If the task is already cancelled, a `cancel` state transition re-enters the same state which is a no-op. However, we will also attempt to cancel any running children too. This can happen if the children did not cancel correctly the first time around. Doing this should probably be considered a bug, but it's better to be safe than sorry. - return cancelled! + return cancelled!(cause: cause) end # If the fiber is alive, we need to cancel it: @@ -369,7 +369,7 @@ def cancel(later = false, cause: $!) end else # We are not running, but children might be, so transition directly into cancelled state: - cancel! + cancel!(cause: cause) end end @@ -479,7 +479,7 @@ def failed!(exception = false) @promise.reject(exception) end - def cancelled! + def cancelled!(cause: $!) # Console.info(self, status:) {"Task #{self} was cancelled with #{@children&.size.inspect} children!"} # Cancel the promise, specify nil here so that no exception is raised when waiting on the promise: @@ -489,15 +489,16 @@ def cancelled! begin # We are not running, but children might be so we should stop them: - stop_children(true) - rescue Cancel + stop_children(true, cause: cause) + rescue Cancel => exception cancelled = true + cause ||= exception.cause || exception # If we are cancelling children, and one of them tries to cancel the current task, we should ignore it. We will be cancelled later. retry end if cancelled - raise Cancel, "Cancelling current task!" + raise Cancel, "Cancelling current task!", cause: cause end end @@ -505,8 +506,8 @@ def stopped! cancelled! end - def cancel! - cancelled! + def cancel!(cause: $!) + cancelled!(cause: cause) finish! end @@ -519,8 +520,8 @@ def schedule(&block) @fiber = Fiber.new(annotation: self.annotation) do begin completed!(yield) - rescue Cancel - cancelled! + rescue Cancel => exception + cancelled!(cause: exception.cause || exception) rescue StandardError => error failed!(error) rescue Exception => exception diff --git a/test/async/limited_queue.rb b/test/async/limited_queue.rb index 60ecd353..3c798d0a 100644 --- a/test/async/limited_queue.rb +++ b/test/async/limited_queue.rb @@ -4,16 +4,39 @@ # Copyright, 2025, by Samuel Williams. # Copyright, 2025, by Shopify Inc. -require "async/limited_queue" +require "async/queue" require "sus/fixtures/async" require "async/a_queue" +limited_queue_new = Async::LimitedQueue.method(:new) + +require "async/limited_queue" + describe Async::LimitedQueue do include Sus::Fixtures::Async::ReactorContext it_behaves_like Async::AQueue + it "warns when constructed through async/queue" do + verbose = $VERBOSE + $VERBOSE = true + + warning = nil + Async::LimitedQueue.define_singleton_method(:warn) do |*arguments, **options| + warning = arguments.first + end + + queue = limited_queue_new.call(1) + + expect(queue.limit).to be == 1 + expect(warning).to be =~ /require 'async\/limited_queue'/ + ensure + singleton_class = class << Async::LimitedQueue; self; end + singleton_class.remove_method(:warn) if singleton_class.instance_methods(false).include?(:warn) + $VERBOSE = verbose + end + let(:queue) {subject.new} with "#limit" do @@ -108,4 +131,3 @@ def before end end end - diff --git a/test/async/list.rb b/test/async/list.rb index 8191319a..aaa8b1ef 100644 --- a/test/async/list.rb +++ b/test/async/list.rb @@ -67,6 +67,12 @@ def initialize(value) expect(list.each.map(&:value)).to be(:empty?) end + it "returns nil when removing an item that is not in the list" do + item = Item.new(1) + + expect(list.remove?(item)).to be_nil + end + it "can't remove an item twice" do item = Item.new(1) diff --git a/test/async/node.rb b/test/async/node.rb index ebec0662..4ddee978 100644 --- a/test/async/node.rb +++ b/test/async/node.rb @@ -288,6 +288,17 @@ node.cancel end + + it "forwards the cause to child tasks" do + cause = RuntimeError.new("boom") + + middle = Async::Node.new(node, annotation: "middle") + child = Async::Node.new(middle, annotation: "child") + + expect(child).to receive(:cancel).with(false, cause: cause) + + middle.cancel(cause: cause) + end end with "#terminate" do diff --git a/test/async/promise.rb b/test/async/promise.rb index ac5ca66a..c468a9ef 100644 --- a/test/async/promise.rb +++ b/test/async/promise.rb @@ -52,6 +52,17 @@ end end + with "#wait" do + it "waits until a pending promise resolves before timeout" do + reactor.async do + sleep(0.001) + promise.resolve(:done) + end + + expect(promise.wait(timeout: 1)).to be == :done + end + end + with "#reject" do it "can be rejected with an exception" do error = StandardError.new("test error") diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index 88519126..f8b0efa3 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -70,6 +70,101 @@ end end + with "#load" do + it "normalizes load over a one second window" do + scheduler = Async::Scheduler.new + + scheduler.instance_variable_set(:@busy_time, 2.0) + scheduler.instance_variable_set(:@idle_time, 2.0) + + expect(scheduler.load).to be == 0.5 + ensure + scheduler&.close + end + end + + with "#close" do + it "runs the event loop until terminated" do + scheduler = Async::Scheduler.new + Async::Node.new(scheduler) + + terminations = 0 + scheduler.define_singleton_method(:terminate) do + terminations += 1 + terminations > 1 + end + + runs = 0 + scheduler.define_singleton_method(:run_once!) do + runs += 1 + end + + scheduler.close + + expect(runs).to be == 1 + end + end + + if Async::Scheduler.method_defined?(:io_read) + with "#io_read" do + it "raises IO timeout errors when reads time out" do + skip_unless_constant_defined(:TimeoutError, IO) + + scheduler = Async::Scheduler.new + + io = Object.new + io.define_singleton_method(:timeout) {0} + + selector = Object.new + selector.define_singleton_method(:io_read) do |fiber, io, buffer, length, offset| + scheduler.instance_variable_get(:@timers).fire + end + selector.define_singleton_method(:close) {} + + scheduler.instance_variable_set(:@selector, selector) + + expect do + scheduler.io_read(io, IO::Buffer.new(1024), 1024) + end.to raise_exception(::IO::TimeoutError) + ensure + scheduler&.close + end + end + end + + with "#cancel" do + it "shares the generated cancellation cause with child tasks" do + cause = Async::Cancel::Cause.new("Cancelling task!") + child_errors = [] + + scheduler = Async::Scheduler.new + Fiber.set_scheduler(scheduler) + + expect(Async::Cancel::Cause).to receive(:for).with("Cancelling task!").and_return(cause) + + 2.times do + scheduler.async do + begin + sleep + rescue Async::Cancel => error + child_errors << error + raise + end + end + end + + scheduler.cancel + scheduler.run + + expect(child_errors.size).to be == 2 + expect(child_errors).to be(:all?) do |error| + error.cause.equal?(cause) + end + ensure + Fiber.set_scheduler(nil) + end + end + with "#interrupt" do it "can interrupt a scheduler while it's not running" do scheduler = Async::Scheduler.new diff --git a/test/async/task.rb b/test/async/task.rb index 4a27fcda..f4cf4dd5 100644 --- a/test/async/task.rb +++ b/test/async/task.rb @@ -116,6 +116,22 @@ reactor.run end + it "warns when using deprecated finished values" do + verbose = $VERBOSE + $VERBOSE = true + task_class = Class.new(Async::Task) do + def warn(...) + end + end + + task = task_class.new(reactor, finished: true) do + end + + expect(task.status).to be == :initialized + ensure + $VERBOSE = verbose + end + it "can pass in arguments" do reactor.async do |task| task.async(:arg) do |task, arg| @@ -306,6 +322,7 @@ end task.stop + expect(task.status).to be == :cancelled expect(task).to be(:cancelled?) end @@ -688,6 +705,143 @@ expect(error).to be_a(Async::Stop) expect(error.cause).to be == cause end + + it "defers cancellation if the target fiber cannot be raised into directly" do + cause = RuntimeError.new("boom") + cancelled = [] + + task = reactor.async do |task| + task.yield + end + + reactor.define_singleton_method(:raise) do |fiber, exception, cause:| + Kernel.raise FiberError + end + + reactor.define_singleton_method(:push) do |operation| + cancelled << operation + end + + task.cancel(cause: cause) + + expect(cancelled.size).to be == 1 + expect(cancelled.first).to be_a(Async::Cancel::Later) + + singleton_class = class << reactor; self; end + singleton_class.remove_method(:raise) + singleton_class.remove_method(:push) + + cancelled.first.transfer + reactor.run + + expect(task).to be(:cancelled?) + end + + it "stops initialized tasks through deprecated aliases" do + task = Async::Task.new(nil) do + end + + task.__send__(:stop!) + + expect(task).to be(:cancelled?) + + task = Async::Task.new(nil) do + end + + task.__send__(:stopped!) + + expect(task).to be(:cancelled?) + end + + it "shares the generated cancellation cause with nested children" do + cause = Async::Cancel::Cause.new("Cancelling task!") + ready = Async::Queue.new + parent_error = nil + child_error = nil + grandchild_error = nil + + expect(Async::Cancel::Cause).to receive(:for).with("Cancelling task!").and_return(cause) + + reactor.run do |task| + parent = task.async do |parent_task| + parent_task.async do |child_task| + child_task.async do + begin + ready.enqueue(:grandchild) + sleep + rescue Async::Cancel => error + grandchild_error = error + raise + end + end + + begin + ready.enqueue(:child) + sleep + rescue Async::Cancel => error + child_error = error + raise + end + end + + begin + ready.enqueue(:parent) + sleep + rescue Async::Cancel => error + parent_error = error + raise + end + end + + 3.times{ready.dequeue} + parent.cancel + end + + expect(parent_error.cause).to be == cause + expect(child_error.cause).to be == cause + expect(grandchild_error.cause).to be == cause + end + + it "shares the generated cancellation cause when cancelling initialized task children" do + cause = Async::Cancel::Cause.new("Cancelling task!") + ready = Async::Queue.new + child_error = nil + grandchild_error = nil + + expect(Async::Cancel::Cause).to receive(:for).with("Cancelling task!").and_return(cause) + + reactor.run do |task| + parent = Async::Task.new(task) do + sleep + end + + parent.async do |child_task| + child_task.async do + begin + ready.enqueue(:grandchild) + sleep + rescue Async::Cancel => error + grandchild_error = error + raise + end + end + + begin + ready.enqueue(:child) + sleep + rescue Async::Cancel => error + child_error = error + raise + end + end + + 2.times{ready.dequeue} + parent.cancel + end + + expect(child_error.cause).to be == cause + expect(grandchild_error.cause).to be == cause + end end with "#sleep" do diff --git a/test/traces/provider/async/task.rb b/test/traces/provider/async/task.rb index 3be8efaa..e8de1540 100644 --- a/test/traces/provider/async/task.rb +++ b/test/traces/provider/async/task.rb @@ -17,7 +17,7 @@ Traces.trace("parent") do parent_context = Traces.trace_context - Async do + Async(annotation: "child") do child_context = Traces.trace_context end end