diff --git a/lib/async/task.rb b/lib/async/task.rb index e3c06af..85d126a 100644 --- a/lib/async/task.rb +++ b/lib/async/task.rb @@ -61,7 +61,7 @@ def initialize(message = "Cannot create child task within a task that has finish super end end - + # @deprecated With no replacement. def self.yield Fiber.scheduler.transfer @@ -73,7 +73,33 @@ def self.run(scheduler, *arguments, **options, &block) task.run(*arguments) end end - + + @@unhandled_exception_handler = nil + + # Set the global handler for unhandled exceptions in tasks. + # + # This configured handler is deprioritized below the instance-level handler set by `Async::Task#unhandled_exception_handler`. + # + # This allows you to customize how Async deals with unhandled exceptions, + # such as logging them to a different destination, adding custom metadata, + # or sending them to an error monitoring service. + # + # @example Setting a custom exception handler + # Async::Task.unhandled_exception_handler { |task, exception| + # MyLogger.error("Async task failed: #{exception.message}", + # task_id: task.object_id, + # backtrace: exception.backtrace) + # } + # + # @example Resetting to default behavior + # Async::Task.unhandled_exception_handler(nil) + # + # @param [Proc, nil] block The handler proc to be called with task and exception + # @return [Proc, nil] The current handler + def self.unhandled_exception_handler(guard = nil, &block) + @@unhandled_exception_handler = block || guard + end + # Create a new task. # @parameter reactor [Reactor] the reactor this task will run within. # @parameter parent [Task] the parent task. @@ -125,6 +151,35 @@ def annotation super end end + + # Set the task instance-level handler for unhandled exceptions in tasks. + # + # This configured handler is prioritized over the global handler set by `Async::Task.unhandled_exception_handler`. + # + # This allows you to customize how Async deals with unhandled exceptions, + # such as logging them to a different destination, adding custom metadata, + # or sending them to an error monitoring service. + # + # @example Setting a custom exception handler + # Async do |task| + # task.unhandled_exception_handler { |task, exception| + # MyLogger.error("Async task failed: #{exception.message}", + # task_id: task.object_id, + # backtrace: exception.backtrace) + # } + # end + # + # @example Resetting to default behavior + # Async do |task| + # ... + # task.unhandled_exception_handler(nil) + # end + # + # @param [Proc, nil] block The handler proc to be called with task and exception + # @return [Proc, nil] The current handler + def unhandled_exception_handler(guard = nil, &block) + @unhandled_exception_handler = block || guard + end # @returns [String] A description of the task and it's current status. def to_s @@ -201,7 +256,17 @@ def run(*arguments) rescue => error # I'm not completely happy with this overhead, but the alternative is to not log anything which makes debugging extremely difficult. Maybe we can introduce a debug wrapper which adds extra logging. if @finished.nil? - warn(self, "Task may have ended with unhandled exception.", exception: error) + begin + if @unhandled_exception_handler + @unhandled_exception_handler.call(self, error) + elsif @@unhandled_exception_handler + @@unhandled_exception_handler.call(self, error) + else + warn(self, "Task may have ended with unhandled exception.", exception: error) + end + rescue => e + warn(self, "Exception occurred in unhandled exception handler.", exception: e) + end end raise diff --git a/test/async/task.rb b/test/async/task.rb index 9b12b9f..5e410a7 100644 --- a/test/async/task.rb +++ b/test/async/task.rb @@ -9,6 +9,8 @@ require "async/clock" require "async/queue" +require "console" + require "sus/fixtures/console" require "sus/fixtures/time/quantum" @@ -935,7 +937,121 @@ def sleep_forever message: be == "Task may have ended with unhandled exception." ) end - + + it "does class-level customized error handling if a task fails without being waited on" do + failed_task = nil + + # set a class-level customized error handler + Async::Task.unhandled_exception_handler { |task, error| + Console.warn(task, "class-level customized error handler", exception: error) + } + + reactor.async do |task| + task.async do |task| + failed_task = task + raise "boom" + end + end + + reactor.run + + expect_console.to have_logged( + severity: be == :warn, + subject: be_equal(failed_task), + message: be == "class-level customized error handler" + ) + + # reset the class-level customized error handler + Async::Task.unhandled_exception_handler(nil) + + reactor.async do |task| + task.async do |task| + failed_task = task + raise "boom" + end + end + reactor.run + + expect_console.to have_logged( + severity: be == :warn, + subject: be_equal(failed_task), + message: be == "Task may have ended with unhandled exception." + ) + end + + it "does task instance-level customized error handling if a task fails without being waited on" do + failed_task = nil + + # set a class-level customized error handler, but this should be ignored due to the priority + Async::Task.unhandled_exception_handler { |task, error| + Console.warn(task, "class-level customized error handler", exception: error) + } + + reactor.async do |task| + task.async do |task| + # set a task instance-level customized error handler; this should be respected + task.unhandled_exception_handler { |task, error| + Console.warn(task, "instance-level customized error handler", exception: error) + } + failed_task = task + raise "boom" + end + end + + reactor.run + + expect_console.to have_logged( + severity: be == :warn, + subject: be_equal(failed_task), + message: be == "instance-level customized error handler" + ) + + # reset the customized error handlers + Async::Task.unhandled_exception_handler(nil) + + reactor.async do |task| + task.async do |task| + # set a task instance-level customized error handler; this should be respected + task.unhandled_exception_handler { |task, error| + Console.warn(task, "instance-level customized error handler", exception: error) + } + task.unhandled_exception_handler(nil) # reset + failed_task = task + raise "boom" + end + end + + reactor.run + + expect_console.to have_logged( + severity: be == :warn, + subject: be_equal(failed_task), + message: be == "Task may have ended with unhandled exception." + ) + end + + it "catches the exception from the customized unhandled exception handler" do + failed_task = nil + + reactor.async do |task| + task.async do |task| + task.unhandled_exception_handler { |task, error| + raise "exception in unhandled exception handler" + } + failed_task = task + raise "boom" + end + end + + reactor.run + + expect_console.to have_logged( + severity: be == :warn, + subject: be_equal(failed_task), + message: be == "Exception occurred in unhandled exception handler." + ) + end + it "does not log a warning if a task fails and is waited on" do failed_task = nil