From 53670d16cb43b1f50ac4fd7dea3dd835b0601c73 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Fri, 16 May 2025 01:23:47 +0900 Subject: [PATCH] Add support for `stop(cause:)`. --- lib/async/task.rb | 58 ++++++++++++++++++++++++++++++++++++++-------- test/async/task.rb | 22 +++++++++++++++++- 2 files changed, 69 insertions(+), 11 deletions(-) diff --git a/lib/async/task.rb b/lib/async/task.rb index e3c06afa..54b455cf 100644 --- a/lib/async/task.rb +++ b/lib/async/task.rb @@ -18,13 +18,45 @@ module Async # Raised when a task is explicitly stopped. class Stop < Exception + # Represents the source of the stop operation. + class Cause < Exception + if RUBY_VERSION >= "3.4" + # @returns [Array(Thread::Backtrace::Location)] The backtrace of the caller. + def self.backtrace + caller_locations(2..-1) + end + else + # @returns [Array(String)] The backtrace of the caller. + def self.backtrace + caller(2..-1) + end + end + + # Create a new cause of the stop operation, with the given message. + # + # @parameter message [String] The error message. + # @returns [Cause] The cause of the stop operation. + def self.for(message = "Task was stopped") + instance = self.new(message) + instance.set_backtrace(self.backtrace) + return instance + end + end + + # Create a new stop operation. + def initialize(message = "Task was stopped") + super(message) + end + # Used to defer stopping the current task until later. class Later # Create a new stop later operation. # # @parameter task [Task] The task to stop later. - def initialize(task) + # @parameter cause [Exception] The cause of the stop operation. + def initialize(task, cause = nil) @task = task + @cause = cause end # @returns [Boolean] Whether the task is alive. @@ -34,7 +66,7 @@ def alive? # Transfer control to the operation - this will stop the task. def transfer - @task.stop + @task.stop(false, cause: @cause) end end end @@ -266,7 +298,13 @@ def wait # If `later` is false, it means that `stop` has been invoked directly. When `later` is true, it means that `stop` is invoked by `stop_children` or some other indirect mechanism. In that case, if we encounter the "current" fiber, we can't stop it right away, as it's currently performing `#stop`. Stopping it immediately would interrupt the current stop traversal, so we need to schedule the stop to occur later. # # @parameter later [Boolean] Whether to stop the task later, or immediately. - def stop(later = false) + # @parameter cause [Exception] The cause of the stop operation. + def stop(later = false, cause: $!) + # If no cause is given, we generate one from the current call stack: + unless cause + cause = Stop::Cause.for("Stopping task!") + end + if self.stopped? # If the task is already stopped, a `stop` state transition re-enters the same state which is a no-op. However, we will also attempt to stop any running children too. This can happen if the children did not stop correctly the first time around. Doing this should probably be considered a bug, but it's better to be safe than sorry. return stopped! @@ -280,7 +318,7 @@ def stop(later = false) # If we are deferring stop... if @defer_stop == false # Don't stop now... but update the state so we know we need to stop later. - @defer_stop = true + @defer_stop = cause return false end @@ -288,19 +326,19 @@ def stop(later = false) # If the fiber is current, and later is `true`, we need to schedule the fiber to be stopped later, as it's currently invoking `stop`: if later # If the fiber is the current fiber and we want to stop it later, schedule it: - Fiber.scheduler.push(Stop::Later.new(self)) + Fiber.scheduler.push(Stop::Later.new(self, cause)) else # Otherwise, raise the exception directly: - raise Stop, "Stopping current task!" + raise Stop, "Stopping current task!", cause: cause end else # If the fiber is not curent, we can raise the exception directly: begin # There is a chance that this will stop the fiber that originally called stop. If that happens, the exception handling in `#stopped` will rescue the exception and re-raise it later. - Fiber.scheduler.raise(@fiber, Stop) + Fiber.scheduler.raise(@fiber, Stop, cause: cause) rescue FiberError # In some cases, this can cause a FiberError (it might be resumed already), so we schedule it to be stopped later: - Fiber.scheduler.push(Stop::Later.new(self)) + Fiber.scheduler.push(Stop::Later.new(self, cause)) end end else @@ -340,7 +378,7 @@ def defer_stop # If we were asked to stop, we should do so now: if defer_stop - raise Stop, "Stopping current task (was deferred)!" + raise Stop, "Stopping current task (was deferred)!", cause: defer_stop end end else @@ -351,7 +389,7 @@ def defer_stop # @returns [Boolean] Whether stop has been deferred. def stop_deferred? - @defer_stop + !!@defer_stop end # Lookup the {Task} for the current fiber. Raise `RuntimeError` if none is available. diff --git a/test/async/task.rb b/test/async/task.rb index 9b12b9f5..016c7769 100644 --- a/test/async/task.rb +++ b/test/async/task.rb @@ -541,6 +541,26 @@ expect(transient).to be(:running?) end.wait end + + it "can stop a task and provide a cause" do + error = nil + + cause = Async::Stop::Cause.for("boom") + + task = reactor.async do |task| + begin + task.stop(cause: cause) + rescue Async::Stop => error + raise + end + end + + reactor.run + + expect(task).to be(:stopped?) + expect(error).to be_a(Async::Stop) + expect(error.cause).to be == cause + end end with "#sleep" do @@ -910,7 +930,7 @@ def sleep_forever reactor.run_once(0) - expect(child_task.stop_deferred?).to be == nil + expect(child_task.stop_deferred?).to be == false end end