From 5881beb3b3f3f1895fd4d154ab0dc4d3823bd940 Mon Sep 17 00:00:00 2001 From: Edouard CHIN Date: Thu, 18 Jun 2026 17:47:54 +0200 Subject: [PATCH] Programatically set the `backtrace_location` on exception: - ### Problem When a Future is rejected, we set the backtrace with an array of string locations. This makes for some inconsistency because `exception.backtrace_locations` return nil but `exception.backtrace` return the backtrace. I'd like to be able to programmatically set the backtrace_location as well. This would allow some libraries that depend on the backtrace_location to correcly detect the backtrace of an error. ### Solution Since Ruby 3.4, it's now possible to set the backtrace_locations programatically. The way do do it is to call `set_backtrace` but with an array of `Thread::Backtrace::Location` objects. --- lib/concurrent-ruby/concurrent/promises.rb | 10 ++++-- spec/concurrent/promises_spec.rb | 38 ++++++++++++++++++++++ 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/lib/concurrent-ruby/concurrent/promises.rb b/lib/concurrent-ruby/concurrent/promises.rb index c5df8fe9c..7010c1951 100644 --- a/lib/concurrent-ruby/concurrent/promises.rb +++ b/lib/concurrent-ruby/concurrent/promises.rb @@ -1014,9 +1014,11 @@ def exception(*args) raise Concurrent::Error, 'it is not rejected' unless rejected? raise ArgumentError unless args.size <= 1 reason = Array(internal_state.reason).flatten.compact + locations_supported = RUBY_VERSION >= '3.4' + callsites = locations_supported ? caller_locations : caller if reason.size > 1 ex = Concurrent::MultipleErrors.new reason - ex.set_backtrace(caller) + ex.set_backtrace(callsites) ex else ex = if reason[0].respond_to? :exception @@ -1024,7 +1026,11 @@ def exception(*args) else RuntimeError.new(reason[0]).exception(*args) end - ex.set_backtrace Array(ex.backtrace) + caller + if locations_supported && (locations = ex.backtrace_locations) + ex.set_backtrace locations + callsites + else + ex.set_backtrace Array(ex.backtrace) + callsites.map(&:to_s) + end ex end end diff --git a/spec/concurrent/promises_spec.rb b/spec/concurrent/promises_spec.rb index 2aa88fdfa..4fd5df976 100644 --- a/spec/concurrent/promises_spec.rb +++ b/spec/concurrent/promises_spec.rb @@ -566,6 +566,44 @@ def behaves_as_delay(delay, value) expect(exception).to be_a Concurrent::MultipleErrors expect(strip_methods[backtrace] - strip_methods[exception.backtrace]).to be_empty end + + it 'sets a consistent backtrace and backtrace_locations for an exception with captured locations' do + skip 'backtrace_locations is only populated on Ruby >= 3.4' if RUBY_VERSION < '3.4' + + raised = (raise TypeError, 'boom' rescue $!) + exception = rejected_future(raised).exception + + expect(exception).to be_a TypeError + expect(exception.backtrace).not_to be_nil + expect(exception.backtrace_locations).not_to be_nil + expect(exception.backtrace_locations.map(&:to_s)).to eq exception.backtrace + end + + it 'preserves a String-only backtrace when no locations are available' do + skip 'backtrace_locations is only populated on Ruby >= 3.4' if RUBY_VERSION < '3.4' + + string_only = TypeError.new + string_only.set_backtrace %W[/a /b /c] + expect(string_only.backtrace_locations).to be_nil + + exception = rejected_future(string_only).exception + + expect(exception).to be_a TypeError + expect(exception.backtrace).not_to be_nil + expect(exception.backtrace_locations).to be_nil + expect(exception.backtrace).to start_with %W[/a /b /c] + end + + it 'sets a consistent backtrace and backtrace_locations for MultipleErrors' do + skip 'backtrace_locations is only populated on Ruby >= 3.4' if RUBY_VERSION < '3.4' + + exception = (rejected_future(TypeError.new) & rejected_future(TypeError.new)).exception + + expect(exception).to be_a Concurrent::MultipleErrors + expect(exception.backtrace).not_to be_nil + expect(exception.backtrace_locations).not_to be_nil + expect(exception.backtrace_locations.map(&:to_s)).to eq exception.backtrace + end end describe 'ResolvableEvent' do