From 15ce7201ef778ecb70a3dffd55847e0afc8a649e Mon Sep 17 00:00:00 2001 From: Jonathan Grynspan Date: Mon, 26 Aug 2024 12:43:00 -0400 Subject: [PATCH] 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. --- Package.swift | 1 + .../Testing/SourceAttribution/Backtrace.swift | 169 ++++++++++++++++-- Sources/_TestingInternals/WillThrow.cpp | 12 ++ Sources/_TestingInternals/include/WillThrow.h | 48 +++++ Tests/TestingTests/BacktraceTests.swift | 70 +++++++- .../shared/AvailabilityDefinitions.cmake | 1 + 6 files changed, 285 insertions(+), 16 deletions(-) diff --git a/Package.swift b/Package.swift index 535345b3c..4034c47e3 100644 --- a/Package.swift +++ b/Package.swift @@ -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"), ] diff --git a/Sources/Testing/SourceAttribution/Backtrace.swift b/Sources/Testing/SourceAttribution/Backtrace.swift index 6e32b3de2..96b57c8bf 100644 --- a/Sources/Testing/SourceAttribution/Backtrace.swift +++ b/Sources/Testing/SourceAttribution/Backtrace.swift @@ -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.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. @@ -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. @@ -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() + /// The previous `swift_willThrowTyped` handler, if any. + private static let _oldWillThrowTypedHandler = Locked() + + /// 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: @@ -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.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(_ 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(_ 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 @@ -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 @@ -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. diff --git a/Sources/_TestingInternals/WillThrow.cpp b/Sources/_TestingInternals/WillThrow.cpp index 47a45a0a0..9ee068ffa 100644 --- a/Sources/_TestingInternals/WillThrow.cpp +++ b/Sources/_TestingInternals/WillThrow.cpp @@ -18,3 +18,15 @@ SWT_IMPORT_FROM_STDLIB std::atomic _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 _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); +} diff --git a/Sources/_TestingInternals/include/WillThrow.h b/Sources/_TestingInternals/include/WillThrow.h index c888385ec..3d5a7c319 100644 --- a/Sources/_TestingInternals/include/WillThrow.h +++ b/Sources/_TestingInternals/include/WillThrow.h @@ -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 diff --git a/Tests/TestingTests/BacktraceTests.swift b/Tests/TestingTests/BacktraceTests.swift index f5a2c497c..8b836de9d 100644 --- a/Tests/TestingTests/BacktraceTests.swift +++ b/Tests/TestingTests/BacktraceTests.swift @@ -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.failure(BacktracedError()).get() + } + let testReferenceType = Test { + try Result.failure(BacktracedRefCountedError()).get() + } + let testAnyType = Test { + try Result.failure(BacktracedError()).get() + } + let testAnyObjectType = Test { + try Result.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.failure(NSError(domain: "", code: 0, userInfo: [:])).get() + } var configuration = Configuration() configuration.eventHandler = { event, _ in if case let .issueRecorded(issue) = event.kind, @@ -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() { diff --git a/cmake/modules/shared/AvailabilityDefinitions.cmake b/cmake/modules/shared/AvailabilityDefinitions.cmake index 24e186aef..0241d2a27 100644 --- a/cmake/modules/shared/AvailabilityDefinitions.cmake +++ b/cmake/modules/shared/AvailabilityDefinitions.cmake @@ -14,4 +14,5 @@ add_compile_options( "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_regexAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_swiftVersionAPI:macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_synchronizationAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0\">" + "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_typedThrowsAPI:macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0\">" "SHELL:$<$:-Xfrontend -define-availability -Xfrontend \"_distantFuture:macOS 99.0, iOS 99.0, watchOS 99.0, tvOS 99.0, visionOS 99.0\">")