Skip to content

Add support for capturing backtraces from typed throws. #642

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 1 commit into from
Sep 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,7 @@ extension Array where Element == PackageDescription.SwiftSetting {
.enableExperimentalFeature("AvailabilityMacro=_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"),
.enableExperimentalFeature("AvailabilityMacro=_swiftVersionAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0"),
.enableExperimentalFeature("AvailabilityMacro=_synchronizationAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"),
.enableExperimentalFeature("AvailabilityMacro=_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0"),

.enableExperimentalFeature("AvailabilityMacro=_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0"),
]
Expand Down
169 changes: 156 additions & 13 deletions Sources/Testing/SourceAttribution/Backtrace.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,55 @@ extension Backtrace: Codable {
// MARK: - Backtraces for thrown errors

extension Backtrace {
// MARK: - Error cache keys

/// A type used as a cache key that uniquely identifies error existential
/// boxes.
private struct _ErrorMappingCacheKey: Sendable, Equatable, Hashable {
private nonisolated(unsafe) var _rawValue: UnsafeMutableRawPointer?

/// Initialize an instance of this type from a pointer to an error
/// existential box.
///
/// - Parameters:
/// - errorAddress: The address of the error existential box.
init(_ errorAddress: UnsafeMutableRawPointer) {
_rawValue = errorAddress
#if SWT_TARGET_OS_APPLE
let error = Unmanaged<AnyObject>.fromOpaque(errorAddress).takeUnretainedValue() as! any Error
if type(of: error) is AnyObject.Type {
_rawValue = Unmanaged.passUnretained(error as AnyObject).toOpaque()
}
#else
withUnsafeTemporaryAllocation(of: SWTErrorValueResult.self, capacity: 1) { buffer in
var scratch: UnsafeMutableRawPointer?
return withExtendedLifetime(scratch) {
swift_getErrorValue(errorAddress, &scratch, buffer.baseAddress!)
let result = buffer.baseAddress!.move()

if unsafeBitCast(result.type, to: Any.Type.self) is AnyObject.Type {
let errorObject = result.value.load(as: AnyObject.self)
_rawValue = Unmanaged.passUnretained(errorObject).toOpaque()
}
}
}
#endif
}

/// Initialize an instance of this type from an error existential box.
///
/// - Parameters:
/// - error: The error existential box.
///
/// - Note: Care must be taken to avoid unboxing and re-boxing `error`. This
/// initializer cannot be made an instance method or property of `Error`
/// because doing so will cause Swift-native errors to be unboxed into
/// existential containers with different addresses.
init(_ error: any Error) {
self.init(unsafeBitCast(error as any Error, to: UnsafeMutableRawPointer.self))
}
}

/// An entry in the error-mapping cache.
private struct _ErrorMappingCacheEntry: Sendable {
/// The error object (`SwiftError` or `NSError`) that was thrown.
Expand All @@ -133,9 +182,9 @@ extension Backtrace {
/// object (abandoning memory until the process exits.)
/// ([swift-#62985](https://github.com/swiftlang/swift/issues/62985))
#if os(Windows)
var errorObject: (any AnyObject & Sendable)?
nonisolated(unsafe) var errorObject: AnyObject?
#else
weak var errorObject: (any AnyObject & Sendable)?
nonisolated(unsafe) weak var errorObject: AnyObject?
#endif

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

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

/// The previous `swift_willThrowTyped` handler, if any.
private static let _oldWillThrowTypedHandler = Locked<SWTWillThrowTypedHandler?>()

/// Handle a thrown error.
///
/// - Parameters:
/// - errorObject: The error that is about to be thrown.
/// - backtrace: The backtrace from where the error was thrown.
/// - errorID: The ID under which the thrown error should be tracked.
///
/// This function serves as the bottleneck for the various callbacks below.
private static func _willThrow(_ errorObject: AnyObject, from backtrace: Backtrace, forKey errorKey: _ErrorMappingCacheKey) {
let newEntry = _ErrorMappingCacheEntry(errorObject: errorObject, backtrace: backtrace)

_errorMappingCache.withLock { cache in
let oldEntry = cache[errorKey]
if oldEntry?.errorObject == nil {
// Either no entry yet, or its weak reference was zeroed.
cache[errorKey] = newEntry
}
}
}

/// Handle a thrown error.
///
/// - Parameters:
Expand All @@ -173,17 +245,81 @@ extension Backtrace {
private static func _willThrow(_ errorAddress: UnsafeMutableRawPointer, from backtrace: Backtrace) {
_oldWillThrowHandler.rawValue?(errorAddress)

let errorObject = unsafeBitCast(errorAddress, to: (any AnyObject & Sendable).self)
let errorID = ObjectIdentifier(errorObject)
let newEntry = _ErrorMappingCacheEntry(errorObject: errorObject, backtrace: backtrace)
let errorObject = Unmanaged<AnyObject>.fromOpaque(errorAddress).takeUnretainedValue()
_willThrow(errorObject, from: backtrace, forKey: .init(errorAddress))
}

_errorMappingCache.withLock { cache in
let oldEntry = cache[errorID]
if oldEntry?.errorObject == nil {
// Either no entry yet, or its weak reference was zeroed.
cache[errorID] = newEntry
/// Handle a typed thrown error.
///
/// - Parameters:
/// - error: The error that is about to be thrown. If the error is of
/// reference type, it is forwarded to `_willThrow()`. Otherwise, it is
/// (currently) discarded because its identity cannot be tracked.
/// - backtrace: The backtrace from where the error was thrown.
@available(_typedThrowsAPI, *)
private static func _willThrowTyped<E>(_ error: borrowing E, from backtrace: Backtrace) where E: Error {
if E.self is AnyObject.Type {
// The error has a stable address and can be tracked as an object.
let error = copy error
_willThrow(error as AnyObject, from: backtrace, forKey: .init(error))
} else if E.self == (any Error).self {
// The thrown error has non-specific type (any Error). In this case,
// the runtime produces a temporary existential box to contain the
// error, but discards the box immediately after we return so there's
// no stability provided by the error's address. Unbox the error and
// recursively call this function in case it contains an instance of a
// reference-counted error type.
//
// This dance through Any lets us unbox the error's existential box
// correctly. Skipping it and calling _willThrowTyped() will fail to open
// the existential and will result in an infinite recursion. The copy is
// unfortunate but necessary due to casting being a consuming operation.
let error = ((copy error) as Any) as! any Error
_willThrowTyped(error, from: backtrace)
} else {
// The error does _not_ have a stable address. The Swift runtime does
// not give us an opportunity to insert additional information into
// arbitrary error values. Thus, we won't attempt to capture any
// backtrace for such an error.
//
// We could, in the future, attempt to track such errors if they conform
// to Identifiable, Equatable, etc., but that would still be imperfect.
// Perhaps the compiler or runtime could assign a unique ID to each error
// at throw time that could be looked up later. SEE: rdar://122824443.
}
}

/// Handle a typed thrown error.
///
/// - Parameters:
/// - error: The error that is about to be thrown. This pointer points
/// directly to the unboxed error in memory. For errors of reference type,
/// the pointer points to the object and is not the object's address
/// itself.
/// - errorType: The metatype of `error`.
/// - errorConformance: The witness table for `error`'s conformance to the
/// `Error` protocol.
/// - backtrace: The backtrace from where the error was thrown.
@available(_typedThrowsAPI, *)
private static func _willThrowTyped(_ errorAddress: UnsafeMutableRawPointer, _ errorType: UnsafeRawPointer, _ errorConformance: UnsafeRawPointer, from backtrace: Backtrace) {
_oldWillThrowTypedHandler.rawValue?(errorAddress, errorType, errorConformance)

// Get a thick protocol type back from the C pointer arguments. Ideally we
// would specify this function as generic, but then the Swift calling
// convention would force us to specialize it immediately in order to pass
// it to the C++ thunk that sets the runtime's function pointer.
let errorType = unsafeBitCast((errorType, errorConformance), to: (any Error.Type).self)

// Open `errorType` as an existential. Rebind the memory at `errorAddress`
// to the correct type and then pass the error to the fully Swiftified
// handler function. Don't call load(as:) to avoid copying the error
// (ideally this is a zero-copy operation.) The callee borrows its argument.
func forward<E>(_ errorType: E.Type) where E: Error {
errorAddress.withMemoryRebound(to: E.self, capacity: 1) { errorAddress in
_willThrowTyped(errorAddress.pointee, from: backtrace)
}
}
forward(errorType)
}

/// The implementation of ``Backtrace/startCachingForThrownErrors()``, run
Expand All @@ -198,6 +334,14 @@ extension Backtrace {
_willThrow(errorAddress, from: backtrace)
}
}
if #available(_typedThrowsAPI, *) {
_oldWillThrowTypedHandler.withLock { oldWillThrowTypedHandler in
oldWillThrowTypedHandler = swt_setWillThrowTypedHandler { errorAddress, errorType, errorConformance in
let backtrace = Backtrace.current()
_willThrowTyped(errorAddress, errorType, errorConformance, from: backtrace)
}
}
}
}()

/// Configure the Swift runtime to allow capturing backtraces when errors are
Expand Down Expand Up @@ -236,9 +380,8 @@ extension Backtrace {
/// existential containers with different addresses.
@inline(never)
init?(forFirstThrowOf error: any Error) {
let errorID = ObjectIdentifier(unsafeBitCast(error as any Error, to: AnyObject.self))
let entry = Self._errorMappingCache.withLock { cache in
cache[errorID]
cache[.init(error)]
}
if let entry, entry.errorObject != nil {
// There was an entry and its weak reference is still valid.
Expand Down
12 changes: 12 additions & 0 deletions Sources/_TestingInternals/WillThrow.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,15 @@ SWT_IMPORT_FROM_STDLIB std::atomic<SWTWillThrowHandler> _swift_willThrow;
SWTWillThrowHandler swt_setWillThrowHandler(SWTWillThrowHandler handler) {
return _swift_willThrow.exchange(handler, std::memory_order_acq_rel);
}

/// The Swift runtime typed-error-handling hook.
SWT_IMPORT_FROM_STDLIB __attribute__((weak_import)) std::atomic<SWTWillThrowTypedHandler> _swift_willThrowTypedImpl;

SWTWillThrowTypedHandler swt_setWillThrowTypedHandler(SWTWillThrowTypedHandler handler) {
#if defined(__APPLE__)
if (&_swift_willThrowTypedImpl == nullptr) {
return nullptr;
}
#endif
return _swift_willThrowTypedImpl.exchange(handler, std::memory_order_acq_rel);
}
48 changes: 48 additions & 0 deletions Sources/_TestingInternals/include/WillThrow.h
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,54 @@ typedef void (* SWT_SENDABLE SWTWillThrowHandler)(void *error);
/// ``SWTWillThrowHandler``
SWT_EXTERN SWTWillThrowHandler SWT_SENDABLE _Nullable swt_setWillThrowHandler(SWTWillThrowHandler SWT_SENDABLE _Nullable handler);

/// The type of handler that is called by `swift_willThrowTyped()`.
///
/// - Parameters:
/// - error: The error that is about to be thrown. This pointer points
/// directly to the unboxed error in memory. For errors of reference type,
/// the pointer points to the object and is not the object's address itself.
/// - errorType: The metatype of `error`.
/// - errorConformance: The witness table for `error`'s conformance to the
/// `Error` protocol.
typedef void (* SWT_SENDABLE SWTWillThrowTypedHandler)(void *error, const void *errorType, const void *errorConformance);

/// Set the callback function that fires when an instance of `Swift.Error` is
/// thrown using the typed throws mechanism.
///
/// - Parameters:
/// - handler: The handler function to set, or `nil` to clear the handler
/// function.
///
/// - Returns: The previously-set handler function, if any.
///
/// This function sets the global `_swift_willThrowTypedImpl()` variable in the
/// Swift runtime, which is reserved for use by the testing framework. If
/// another testing framework such as XCTest has already set a handler, it is
/// returned.
///
/// ## See Also
///
/// ``SWTWillThrowTypedHandler``
SWT_EXTERN SWTWillThrowTypedHandler SWT_SENDABLE _Nullable swt_setWillThrowTypedHandler(SWTWillThrowTypedHandler SWT_SENDABLE _Nullable handler);

#if !defined(__APPLE__)
/// The result of `swift__getErrorValue()`.
///
/// For more information, see this type's declaration
/// [in the Swift repository](https://github.com/swiftlang/swift/blob/main/include/swift/Runtime/Error.h).
typedef struct SWTErrorValueResult {
void *value;
const void *type;
const void *errorConformance;
} SWTErrorValueResult;

/// Unbox an error existential and get its type and protocol conformance.
///
/// This function is provided by the Swift runtime. For more information, see
/// this function's declaration [in the Swift repository](https://github.com/swiftlang/swift/blob/main/include/swift/Runtime/Error.h).
SWT_IMPORT_FROM_STDLIB void swift_getErrorValue(void *error, void *_Nullable *_Nonnull scratch, SWTErrorValueResult *out);
#endif

SWT_ASSUME_NONNULL_END

#endif
70 changes: 67 additions & 3 deletions Tests/TestingTests/BacktraceTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,17 +9,80 @@
//

@testable @_spi(ForToolsIntegrationOnly) import Testing
#if SWT_TARGET_OS_APPLE && canImport(Foundation)
import Foundation
#endif

struct BacktracedError: Error {}
final class BacktracedRefCountedError: Error {}

@Suite("Backtrace Tests")
struct BacktraceTests {
@Test("Thrown error captures backtrace")
func thrownErrorCapturesBacktrace() async throws {
await confirmation("Backtrace found") { hadBacktrace in
let test = Test {
await confirmation("Backtrace found", expectedCount: 2) { hadBacktrace in
let testValueType = Test {
throw BacktracedError()
}
let testReferenceType = Test {
throw BacktracedRefCountedError()
}
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case let .issueRecorded(issue) = event.kind,
let backtrace = issue.sourceContext.backtrace,
!backtrace.addresses.isEmpty {
hadBacktrace()
}
}
let runner = await Runner(testing: [testValueType, testReferenceType], configuration: configuration)
await runner.run()
}
}

@available(_typedThrowsAPI, *)
@Test("Typed thrown error captures backtrace")
func typedThrownErrorCapturesBacktrace() async throws {
await confirmation("Error recorded", expectedCount: 4) { errorRecorded in
await confirmation("Backtrace found", expectedCount: 2) { hadBacktrace in
let testValueType = Test {
try Result<Never, _>.failure(BacktracedError()).get()
}
let testReferenceType = Test {
try Result<Never, _>.failure(BacktracedRefCountedError()).get()
}
let testAnyType = Test {
try Result<Never, any Error>.failure(BacktracedError()).get()
}
let testAnyObjectType = Test {
try Result<Never, any Error>.failure(BacktracedRefCountedError()).get()
}
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case let .issueRecorded(issue) = event.kind {
errorRecorded()
if let backtrace = issue.sourceContext.backtrace, !backtrace.addresses.isEmpty {
hadBacktrace()
}
}
}
let runner = await Runner(testing: [testValueType, testReferenceType, testAnyType, testAnyObjectType], configuration: configuration)
await runner.run()
}
}
}

#if SWT_TARGET_OS_APPLE && canImport(Foundation)
@available(_typedThrowsAPI, *)
@Test("Thrown NSError captures backtrace")
func thrownNSErrorCapturesBacktrace() async throws {
await confirmation("Backtrace found", expectedCount: 2) { hadBacktrace in
let testValueType = Test {
throw NSError(domain: "", code: 0, userInfo: [:])
}
let testReferenceType = Test {
try Result<Never, any Error>.failure(NSError(domain: "", code: 0, userInfo: [:])).get()
}
var configuration = Configuration()
configuration.eventHandler = { event, _ in
if case let .issueRecorded(issue) = event.kind,
Expand All @@ -28,10 +91,11 @@ struct BacktraceTests {
hadBacktrace()
}
}
let runner = await Runner(testing: [test], configuration: configuration)
let runner = await Runner(testing: [testValueType, testReferenceType], configuration: configuration)
await runner.run()
}
}
#endif

@Test("Backtrace.current() is populated")
func currentBacktrace() {
Expand Down
Loading