From aa42561a601f5d9cd2a58499c0ba4ec997dbd713 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 14 Sep 2026 12:08:40 +1200 Subject: [PATCH 1/3] Handle Thread#kill during scheduler shutdown Assisted-By: devx/577dd101-056f-4573-9c18-d205cfd057f4 --- lib/async/task.rb | 3 +++ test/async/scheduler.rb | 27 +++++++++++++++++++++++++++ 2 files changed, 30 insertions(+) diff --git a/lib/async/task.rb b/lib/async/task.rb index ffc2850d..a1939484 100644 --- a/lib/async/task.rb +++ b/lib/async/task.rb @@ -327,6 +327,9 @@ def result # @parameter later [Boolean] Whether to cancel the task later, or immediately. # @parameter cause [Exception] The cause of the cancel operation. def cancel(later = false, cause: $!) + # On affected Ruby versions, `Thread#kill` exposes the internal `TAG_FATAL` value through `$!`, which is not a valid exception cause: + cause = nil unless cause.is_a?(::Exception) + # If no cause is given, we generate one from the current call stack: unless cause cause = Cancel::Cause.for("Cancelling task!") diff --git a/test/async/scheduler.rb b/test/async/scheduler.rb index 6870b356..d845f238 100644 --- a/test/async/scheduler.rb +++ b/test/async/scheduler.rb @@ -85,6 +85,33 @@ end with "#close" do + it "can close when the owning thread is killed" do + ready = Thread::Queue.new + + thread = Thread.new do + reactor = Async::Reactor.new + + reactor.async do + reactor.async do + sleep + end + end + + ready << true + reactor.run + end + thread.report_on_exception = false + + ready.pop + thread.kill + + expect do + thread.join + end.not.to raise_exception + + expect(thread).not.to be(:alive?) + end + it "runs the event loop until terminated" do scheduler = Async::Scheduler.new Async::Node.new(scheduler) From f7fe60ea2099f731b4740c0eaf9370bb29b98b55 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 14 Sep 2026 12:20:45 +1200 Subject: [PATCH 2/3] Expand cancellation cause validation Assisted-By: devx/577dd101-056f-4573-9c18-d205cfd057f4 --- lib/async/task.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/async/task.rb b/lib/async/task.rb index a1939484..6629c0e1 100644 --- a/lib/async/task.rb +++ b/lib/async/task.rb @@ -328,7 +328,9 @@ def result # @parameter cause [Exception] The cause of the cancel operation. def cancel(later = false, cause: $!) # On affected Ruby versions, `Thread#kill` exposes the internal `TAG_FATAL` value through `$!`, which is not a valid exception cause: - cause = nil unless cause.is_a?(::Exception) + unless cause.is_a?(::Exception) + cause = nil + end # If no cause is given, we generate one from the current call stack: unless cause From 835261271082a8f02981d83b70a0a8547bd891f2 Mon Sep 17 00:00:00 2001 From: Samuel Williams Date: Mon, 14 Sep 2026 12:22:22 +1200 Subject: [PATCH 3/3] Add unreleased note for thread shutdown fix Assisted-By: devx/577dd101-056f-4573-9c18-d205cfd057f4 --- releases.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/releases.md b/releases.md index 0510ff3d..362b07e1 100644 --- a/releases.md +++ b/releases.md @@ -1,5 +1,9 @@ # Releases +## Unreleased + + - Fixed `Thread#kill` raising a `TypeError` while shutting down a thread with an active `Async::Reactor`. + ## v2.45.1 - Fixed `Scheduler#io_wait` returning `nil` instead of `false` when an explicit timeout expired. Native callers such as `Socket#connect` with `connect_timeout:` distinguish a timeout by checking for `false`, so the `nil` caused `TypeError: no implicit conversion from nil to integer` instead of the intended `IO::TimeoutError`.