Skip to content

Commit c8cbbf0

Browse files
committed
Add support for capturing backtraces from typed throws.
Swift 6 introduces the typed throws feature to replace `rethrows` and to support error handling in Embedded Swift. This feature uses an ABI that differs from the one used by untyped (traditional) `throws` and as such, our hook into the runtime (`_swift_willThrows`) is insufficient to provide backtrace information for errors thrown this way. This PR adds support for capturing said backtraces _if and only if_ the thrown error is of reference type. Such errors have stable addresses that we can track over time, whereas errors of value type will be copied when used with typed throws.
1 parent 0ca5c8d commit c8cbbf0

File tree

6 files changed

+285
-16
lines changed

6 files changed

+285
-16
lines changed

Package.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ extension Array where Element == PackageDescription.SwiftSetting {
149149
.enableExperimentalFeature("AvailabilityMacro=_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"),
150150
.enableExperimentalFeature("AvailabilityMacro=_swiftVersionAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"),
151151
.enableExperimentalFeature("AvailabilityMacro=_synchronizationAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"),
152+
.enableExperimentalFeature("AvailabilityMacro=_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"),
152153

153154
.enableExperimentalFeature("AvailabilityMacro=_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0"),
154155
]

Sources/Testing/SourceAttribution/Backtrace.swift

Lines changed: 156 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,55 @@ extension Backtrace: Codable {
119119
// MARK: - Backtraces for thrown errors
120120

121121
extension Backtrace {
122+
// MARK: - Error cache keys
123+
124+
/// A type used as a cache key that uniquely identifies error existential
125+
/// boxes.
126+
private struct _ErrorMappingCacheKey: Sendable, Equatable, Hashable {
127+
private nonisolated(unsafe) var _rawValue: UnsafeMutableRawPointer?
128+
129+
/// Initialize an instance of this type from a pointer to an error
130+
/// existential box.
131+
///
132+
/// - Parameters:
133+
/// - errorAddress: The address of the error existential box.
134+
init(_ errorAddress: UnsafeMutableRawPointer) {
135+
_rawValue = errorAddress
136+
#if SWT_TARGET_OS_APPLE
137+
let error = Unmanaged<AnyObject>.fromOpaque(errorAddress).takeUnretainedValue() as! any Error
138+
if type(of: error) is AnyObject.Type {
139+
_rawValue = Unmanaged.passUnretained(error as AnyObject).toOpaque()
140+
}
141+
#else
142+
withUnsafeTemporaryAllocation(of: SWTErrorValueResult.self, capacity: 1) { buffer in
143+
var scratch: UnsafeMutableRawPointer?
144+
return withExtendedLifetime(scratch) {
145+
swift_getErrorValue(errorAddress, &scratch, buffer.baseAddress!)
146+
let result = buffer.baseAddress!.move()
147+
148+
if unsafeBitCast(result.type, to: Any.Type.self) is AnyObject.Type {
149+
let errorObject = result.value.load(as: AnyObject.self)
150+
_rawValue = Unmanaged.passUnretained(errorObject).toOpaque()
151+
}
152+
}
153+
}
154+
#endif
155+
}
156+
157+
/// Initialize an instance of this type from an error existential box.
158+
///
159+
/// - Parameters:
160+
/// - error: The error existential box.
161+
///
162+
/// - Note: Care must be taken to avoid unboxing and re-boxing `error`. This
163+
/// initializer cannot be made an instance method or property of `Error`
164+
/// because doing so will cause Swift-native errors to be unboxed into
165+
/// existential containers with different addresses.
166+
init(_ error: any Error) {
167+
self.init(unsafeBitCast(error as any Error, to: UnsafeMutableRawPointer.self))
168+
}
169+
}
170+
122171
/// An entry in the error-mapping cache.
123172
private struct _ErrorMappingCacheEntry: Sendable {
124173
/// The error object (`SwiftError` or `NSError`) that was thrown.
@@ -133,9 +182,9 @@ extension Backtrace {
133182
/// object (abandoning memory until the process exits.)
134183
/// ([swift-#62985](https://github.com/swiftlang/swift/issues/62985))
135184
#if os(Windows)
136-
var errorObject: (any AnyObject & Sendable)?
185+
nonisolated(unsafe) var errorObject: AnyObject?
137186
#else
138-
weak var errorObject: (any AnyObject & Sendable)?
187+
nonisolated(unsafe) weak var errorObject: AnyObject?
139188
#endif
140189

141190
/// The backtrace captured when `errorObject` was thrown.
@@ -158,11 +207,34 @@ extension Backtrace {
158207
/// same location.)
159208
///
160209
/// Access to this dictionary is guarded by a lock.
161-
private static let _errorMappingCache = Locked<[ObjectIdentifier: _ErrorMappingCacheEntry]>()
210+
private static let _errorMappingCache = Locked<[_ErrorMappingCacheKey: _ErrorMappingCacheEntry]>()
162211

163212
/// The previous `swift_willThrow` handler, if any.
164213
private static let _oldWillThrowHandler = Locked<SWTWillThrowHandler?>()
165214

215+
/// The previous `swift_willThrowTyped` handler, if any.
216+
private static let _oldWillThrowTypedHandler = Locked<SWTWillThrowTypedHandler?>()
217+
218+
/// Handle a thrown error.
219+
///
220+
/// - Parameters:
221+
/// - errorObject: The error that is about to be thrown.
222+
/// - backtrace: The backtrace from where the error was thrown.
223+
/// - errorID: The ID under which the thrown error should be tracked.
224+
///
225+
/// This function serves as the bottleneck for the various callbacks below.
226+
private static func _willThrow(_ errorObject: AnyObject, from backtrace: Backtrace, forKey errorKey: _ErrorMappingCacheKey) {
227+
let newEntry = _ErrorMappingCacheEntry(errorObject: errorObject, backtrace: backtrace)
228+
229+
_errorMappingCache.withLock { cache in
230+
let oldEntry = cache[errorKey]
231+
if oldEntry?.errorObject == nil {
232+
// Either no entry yet, or its weak reference was zeroed.
233+
cache[errorKey] = newEntry
234+
}
235+
}
236+
}
237+
166238
/// Handle a thrown error.
167239
///
168240
/// - Parameters:
@@ -173,17 +245,81 @@ extension Backtrace {
173245
private static func _willThrow(_ errorAddress: UnsafeMutableRawPointer, from backtrace: Backtrace) {
174246
_oldWillThrowHandler.rawValue?(errorAddress)
175247

176-
let errorObject = unsafeBitCast(errorAddress, to: (any AnyObject & Sendable).self)
177-
let errorID = ObjectIdentifier(errorObject)
178-
let newEntry = _ErrorMappingCacheEntry(errorObject: errorObject, backtrace: backtrace)
248+
let errorObject = Unmanaged<AnyObject>.fromOpaque(errorAddress).takeUnretainedValue()
249+
_willThrow(errorObject, from: backtrace, forKey: .init(errorAddress))
250+
}
179251

180-
_errorMappingCache.withLock { cache in
181-
let oldEntry = cache[errorID]
182-
if oldEntry?.errorObject == nil {
183-
// Either no entry yet, or its weak reference was zeroed.
184-
cache[errorID] = newEntry
252+
/// Handle a typed thrown error.
253+
///
254+
/// - Parameters:
255+
/// - error: The error that is about to be thrown. If the error is of
256+
/// reference type, it is forwarded to `_willThrow()`. Otherwise, it is
257+
/// (currently) discarded because its identity cannot be tracked.
258+
/// - backtrace: The backtrace from where the error was thrown.
259+
@available(_typedThrowsAPI, *)
260+
private static func _willThrowTyped<E>(_ error: borrowing E, from backtrace: Backtrace) where E: Error {
261+
if E.self is AnyObject.Type {
262+
// The error has a stable address and can be tracked as an object.
263+
let error = copy error
264+
_willThrow(error as AnyObject, from: backtrace, forKey: .init(error))
265+
} else if E.self == (any Error).self {
266+
// The thrown error has non-specific type (any Error). In this case,
267+
// the runtime produces a temporary existential box to contain the
268+
// error, but discards the box immediately after we return so there's
269+
// no stability provided by the error's address. Unbox the error and
270+
// recursively call this function in case it contains an instance of a
271+
// reference-counted error type.
272+
//
273+
// This dance through Any lets us unbox the error's existential box
274+
// correctly. Skipping it and calling _willThrowTyped() will fail to open
275+
// the existential and will result in an infinite recursion. The copy is
276+
// unfortunate but necessary due to casting being a consuming operation.
277+
let error = ((copy error) as Any) as! any Error
278+
_willThrowTyped(error, from: backtrace)
279+
} else {
280+
// The error does _not_ have a stable address. The Swift runtime does
281+
// not give us an opportunity to insert additional information into
282+
// arbitrary error values. Thus, we won't attempt to capture any
283+
// backtrace for such an error.
284+
//
285+
// We could, in the future, attempt to track such errors if they conform
286+
// to Identifiable, Equatable, etc., but that would still be imperfect.
287+
// Perhaps the compiler or runtime could assign a unique ID to each error
288+
// at throw time that could be looked up later. SEE: rdar://122824443.
289+
}
290+
}
291+
292+
/// Handle a typed thrown error.
293+
///
294+
/// - Parameters:
295+
/// - error: The error that is about to be thrown. This pointer points
296+
/// directly to the unboxed error in memory. For errors of reference type,
297+
/// the pointer points to the object and is not the object's address
298+
/// itself.
299+
/// - errorType: The metatype of `error`.
300+
/// - errorConformance: The witness table for `error`'s conformance to the
301+
/// `Error` protocol.
302+
/// - backtrace: The backtrace from where the error was thrown.
303+
@available(_typedThrowsAPI, *)
304+
private static func _willThrowTyped(_ errorAddress: UnsafeMutableRawPointer, _ errorType: UnsafeRawPointer, _ errorConformance: UnsafeRawPointer, from backtrace: Backtrace) {
305+
_oldWillThrowTypedHandler.rawValue?(errorAddress, errorType, errorConformance)
306+
307+
// Get a thick protocol type back from the C pointer arguments. Ideally we
308+
// would specify this function as generic, but then the Swift calling
309+
// convention would force us to specialize it immediately in order to pass
310+
// it to the C++ thunk that sets the runtime's function pointer.
311+
let errorType = unsafeBitCast((errorType, errorConformance), to: (any Error.Type).self)
312+
313+
// Open `errorType` as an existential. Rebind the memory at `errorAddress`
314+
// to the correct type and then pass the error to the fully Swiftified
315+
// handler function. Don't call load(as:) to avoid copying the error
316+
// (ideally this is a zero-copy operation.) The callee borrows its argument.
317+
func forward<E>(_ errorType: E.Type) where E: Error {
318+
errorAddress.withMemoryRebound(to: E.self, capacity: 1) { errorAddress in
319+
_willThrowTyped(errorAddress.pointee, from: backtrace)
185320
}
186321
}
322+
forward(errorType)
187323
}
188324

189325
/// The implementation of ``Backtrace/startCachingForThrownErrors()``, run
@@ -198,6 +334,14 @@ extension Backtrace {
198334
_willThrow(errorAddress, from: backtrace)
199335
}
200336
}
337+
if #available(_typedThrowsAPI, *) {
338+
_oldWillThrowTypedHandler.withLock { oldWillThrowTypedHandler in
339+
oldWillThrowTypedHandler = swt_setWillThrowTypedHandler { errorAddress, errorType, errorConformance in
340+
let backtrace = Backtrace.current()
341+
_willThrowTyped(errorAddress, errorType, errorConformance, from: backtrace)
342+
}
343+
}
344+
}
201345
}()
202346

203347
/// Configure the Swift runtime to allow capturing backtraces when errors are
@@ -236,9 +380,8 @@ extension Backtrace {
236380
/// existential containers with different addresses.
237381
@inline(never)
238382
init?(forFirstThrowOf error: any Error) {
239-
let errorID = ObjectIdentifier(unsafeBitCast(error as any Error, to: AnyObject.self))
240383
let entry = Self._errorMappingCache.withLock { cache in
241-
cache[errorID]
384+
cache[.init(error)]
242385
}
243386
if let entry, entry.errorObject != nil {
244387
// There was an entry and its weak reference is still valid.

Sources/_TestingInternals/WillThrow.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,15 @@ SWT_IMPORT_FROM_STDLIB std::atomic<SWTWillThrowHandler> _swift_willThrow;
1818
SWTWillThrowHandler swt_setWillThrowHandler(SWTWillThrowHandler handler) {
1919
return _swift_willThrow.exchange(handler, std::memory_order_acq_rel);
2020
}
21+
22+
/// The Swift runtime typed-error-handling hook.
23+
SWT_IMPORT_FROM_STDLIB __attribute__((weak_import)) std::atomic<SWTWillThrowTypedHandler> _swift_willThrowTypedImpl;
24+
25+
SWTWillThrowTypedHandler swt_setWillThrowTypedHandler(SWTWillThrowTypedHandler handler) {
26+
#if defined(__APPLE__)
27+
if (&_swift_willThrowTypedImpl == nullptr) {
28+
return nullptr;
29+
}
30+
#endif
31+
return _swift_willThrowTypedImpl.exchange(handler, std::memory_order_acq_rel);
32+
}

Sources/_TestingInternals/include/WillThrow.h

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,54 @@ typedef void (* SWT_SENDABLE SWTWillThrowHandler)(void *error);
4141
/// ``SWTWillThrowHandler``
4242
SWT_EXTERN SWTWillThrowHandler SWT_SENDABLE _Nullable swt_setWillThrowHandler(SWTWillThrowHandler SWT_SENDABLE _Nullable handler);
4343

44+
/// The type of handler that is called by `swift_willThrowTyped()`.
45+
///
46+
/// - Parameters:
47+
/// - error: The error that is about to be thrown. This pointer points
48+
/// directly to the unboxed error in memory. For errors of reference type,
49+
/// the pointer points to the object and is not the object's address itself.
50+
/// - errorType: The metatype of `error`.
51+
/// - errorConformance: The witness table for `error`'s conformance to the
52+
/// `Error` protocol.
53+
typedef void (* SWT_SENDABLE SWTWillThrowTypedHandler)(void *error, const void *errorType, const void *errorConformance);
54+
55+
/// Set the callback function that fires when an instance of `Swift.Error` is
56+
/// thrown using the typed throws mechanism.
57+
///
58+
/// - Parameters:
59+
/// - handler: The handler function to set, or `nil` to clear the handler
60+
/// function.
61+
///
62+
/// - Returns: The previously-set handler function, if any.
63+
///
64+
/// This function sets the global `_swift_willThrowTypedImpl()` variable in the
65+
/// Swift runtime, which is reserved for use by the testing framework. If
66+
/// another testing framework such as XCTest has already set a handler, it is
67+
/// returned.
68+
///
69+
/// ## See Also
70+
///
71+
/// ``SWTWillThrowTypedHandler``
72+
SWT_EXTERN SWTWillThrowTypedHandler SWT_SENDABLE _Nullable swt_setWillThrowTypedHandler(SWTWillThrowTypedHandler SWT_SENDABLE _Nullable handler);
73+
74+
#if !defined(__APPLE__)
75+
/// The result of `swift__getErrorValue()`.
76+
///
77+
/// For more information, see this type's declaration
78+
/// [in the Swift repository](https://github.com/swiftlang/swift/blob/main/include/swift/Runtime/Error.h).
79+
typedef struct SWTErrorValueResult {
80+
void *value;
81+
const void *type;
82+
const void *errorConformance;
83+
} SWTErrorValueResult;
84+
85+
/// Unbox an error existential and get its type and protocol conformance.
86+
///
87+
/// This function is provided by the Swift runtime. For more information, see
88+
/// this function's declaration [in the Swift repository](https://github.com/swiftlang/swift/blob/main/include/swift/Runtime/Error.h).
89+
SWT_IMPORT_FROM_STDLIB void swift_getErrorValue(void *error, void *_Nullable *_Nonnull scratch, SWTErrorValueResult *out);
90+
#endif
91+
4492
SWT_ASSUME_NONNULL_END
4593

4694
#endif

Tests/TestingTests/BacktraceTests.swift

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,80 @@
99
//
1010

1111
@testable @_spi(ForToolsIntegrationOnly) import Testing
12+
#if SWT_TARGET_OS_APPLE && canImport(Foundation)
13+
import Foundation
14+
#endif
1215

1316
struct BacktracedError: Error {}
17+
final class BacktracedRefCountedError: Error {}
1418

1519
@Suite("Backtrace Tests")
1620
struct BacktraceTests {
1721
@Test("Thrown error captures backtrace")
1822
func thrownErrorCapturesBacktrace() async throws {
19-
await confirmation("Backtrace found") { hadBacktrace in
20-
let test = Test {
23+
await confirmation("Backtrace found", expectedCount: 2) { hadBacktrace in
24+
let testValueType = Test {
2125
throw BacktracedError()
2226
}
27+
let testReferenceType = Test {
28+
throw BacktracedRefCountedError()
29+
}
30+
var configuration = Configuration()
31+
configuration.eventHandler = { event, _ in
32+
if case let .issueRecorded(issue) = event.kind,
33+
let backtrace = issue.sourceContext.backtrace,
34+
!backtrace.addresses.isEmpty {
35+
hadBacktrace()
36+
}
37+
}
38+
let runner = await Runner(testing: [testValueType, testReferenceType], configuration: configuration)
39+
await runner.run()
40+
}
41+
}
42+
43+
@available(_typedThrowsAPI, *)
44+
@Test("Typed thrown error captures backtrace")
45+
func typedThrownErrorCapturesBacktrace() async throws {
46+
await confirmation("Error recorded", expectedCount: 4) { errorRecorded in
47+
await confirmation("Backtrace found", expectedCount: 2) { hadBacktrace in
48+
let testValueType = Test {
49+
try Result<Never, _>.failure(BacktracedError()).get()
50+
}
51+
let testReferenceType = Test {
52+
try Result<Never, _>.failure(BacktracedRefCountedError()).get()
53+
}
54+
let testAnyType = Test {
55+
try Result<Never, any Error>.failure(BacktracedError()).get()
56+
}
57+
let testAnyObjectType = Test {
58+
try Result<Never, any Error>.failure(BacktracedRefCountedError()).get()
59+
}
60+
var configuration = Configuration()
61+
configuration.eventHandler = { event, _ in
62+
if case let .issueRecorded(issue) = event.kind {
63+
errorRecorded()
64+
if let backtrace = issue.sourceContext.backtrace, !backtrace.addresses.isEmpty {
65+
hadBacktrace()
66+
}
67+
}
68+
}
69+
let runner = await Runner(testing: [testValueType, testReferenceType, testAnyType, testAnyObjectType], configuration: configuration)
70+
await runner.run()
71+
}
72+
}
73+
}
74+
75+
#if SWT_TARGET_OS_APPLE && canImport(Foundation)
76+
@available(_typedThrowsAPI, *)
77+
@Test("Thrown NSError captures backtrace")
78+
func thrownNSErrorCapturesBacktrace() async throws {
79+
await confirmation("Backtrace found", expectedCount: 2) { hadBacktrace in
80+
let testValueType = Test {
81+
throw NSError(domain: "", code: 0, userInfo: [:])
82+
}
83+
let testReferenceType = Test {
84+
try Result<Never, any Error>.failure(NSError(domain: "", code: 0, userInfo: [:])).get()
85+
}
2386
var configuration = Configuration()
2487
configuration.eventHandler = { event, _ in
2588
if case let .issueRecorded(issue) = event.kind,
@@ -28,10 +91,11 @@ struct BacktraceTests {
2891
hadBacktrace()
2992
}
3093
}
31-
let runner = await Runner(testing: [test], configuration: configuration)
94+
let runner = await Runner(testing: [testValueType, testReferenceType], configuration: configuration)
3295
await runner.run()
3396
}
3497
}
98+
#endif
3599

36100
@Test("Backtrace.current() is populated")
37101
func currentBacktrace() {

0 commit comments

Comments
 (0)