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\">")