Skip to content

Commit f6af3d8

Browse files
authored
Ask Foundation to capture NSError/CFError backtraces for us. (#673)
On Apple platforms, when an error occurs in an Objective-C method or C function, convention is to return the error as an instance of `NSError`/`CFError` via an out-parameter. When that Objective-C method or C function is called by a Swift function, the Swift function detects the error, then effectively rethrows it, at which point our `swift_willThrow` hook is triggered. This can obscure the real origin of the error which may be many stack frames down from the point Swift takes over. This PR asks Foundation, via a relatively new internal function, to capture backtraces for instances of `NSError` and `CFError` at the point they are created. Then, when Swift Testing attempts to look up the backtrace for an error it has caught, if Foundation has generated one, then Swift Testing can substitute it in place of the one it generated in the `swift_willThrow` hook. Resolves rdar://114386243. ### Checklist: - [x] Code and documentation should follow the style of the [Style Guide](https://github.com/apple/swift-testing/blob/main/Documentation/StyleGuide.md). - [x] If public symbols are renamed or modified, DocC references should be updated.
1 parent e7a7e23 commit f6af3d8

File tree

2 files changed

+71
-1
lines changed

2 files changed

+71
-1
lines changed

Sources/Testing/SourceAttribution/Backtrace.swift

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,12 +322,39 @@ extension Backtrace {
322322
forward(errorType)
323323
}
324324

325+
/// Whether or not Foundation provides a function that triggers the capture of
326+
/// backtaces when instances of `NSError` or `CFError` are created.
327+
///
328+
/// A backtrace created by said function represents the point in execution
329+
/// where the error was created by an Objective-C or C stack frame. For an
330+
/// error thrown from Objective-C or C through Swift before being caught by
331+
/// the testing library, that backtrace is closer to the point of failure than
332+
/// the one that would be captured at the point `swift_willThrow()` is called.
333+
///
334+
/// On non-Apple platforms, the value of this property is always `false`.
335+
///
336+
/// - Note: The underlying Foundation function is called (if present) the
337+
/// first time the value of this property is read.
338+
static let isFoundationCaptureEnabled = {
339+
#if SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING
340+
let _CFErrorSetCallStackCaptureEnabled = symbol(named: "_CFErrorSetCallStackCaptureEnabled").map {
341+
unsafeBitCast($0, to: (@convention(c) (DarwinBoolean) -> DarwinBoolean).self)
342+
}
343+
_ = _CFErrorSetCallStackCaptureEnabled?(true)
344+
return _CFErrorSetCallStackCaptureEnabled != nil
345+
#else
346+
false
347+
#endif
348+
}()
349+
325350
/// The implementation of ``Backtrace/startCachingForThrownErrors()``, run
326351
/// only once.
327352
///
328353
/// This value is named oddly so that it shows up clearly in symbolicated
329354
/// backtraces.
330355
private static let __SWIFT_TESTING_IS_CAPTURING_A_BACKTRACE_FOR_A_THROWN_ERROR__: Void = {
356+
_ = isFoundationCaptureEnabled
357+
331358
_oldWillThrowHandler.withLock { oldWillThrowHandler in
332359
oldWillThrowHandler = swt_setWillThrowHandler { errorAddress in
333360
let backtrace = Backtrace.current()
@@ -369,6 +396,9 @@ extension Backtrace {
369396
///
370397
/// - Parameters:
371398
/// - error: The error for which a backtrace is needed.
399+
/// - checkFoundation: Whether or not to check for a backtrace created by
400+
/// Foundation with `_CFErrorSetCallStackCaptureEnabled()`. On non-Apple
401+
/// platforms, this argument has no effect.
372402
///
373403
/// If no backtrace information is available for the specified error, this
374404
/// initializer returns `nil`. To start capturing backtraces, call
@@ -379,7 +409,14 @@ extension Backtrace {
379409
/// because doing so will cause Swift-native errors to be unboxed into
380410
/// existential containers with different addresses.
381411
@inline(never)
382-
init?(forFirstThrowOf error: any Error) {
412+
init?(forFirstThrowOf error: any Error, checkFoundation: Bool = true) {
413+
if checkFoundation && Self.isFoundationCaptureEnabled,
414+
let userInfo = error._userInfo as? [String: Any],
415+
let addresses = userInfo["NSCallStackReturnAddresses"] as? [Address], !addresses.isEmpty {
416+
self.init(addresses: addresses)
417+
return
418+
}
419+
383420
let entry = Self._errorMappingCache.withLock { cache in
384421
cache[.init(error)]
385422
}

Tests/TestingTests/BacktraceTests.swift

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,39 @@ struct BacktraceTests {
9595
await runner.run()
9696
}
9797
}
98+
99+
@inline(never)
100+
func throwNSError() throws {
101+
let error = NSError(domain: "Oh no!", code: 123, userInfo: [:])
102+
throw error
103+
}
104+
105+
@inline(never)
106+
func throwBacktracedRefCountedError() throws {
107+
throw BacktracedRefCountedError()
108+
}
109+
110+
@Test("Thrown NSError has a different backtrace than we generated", .enabled(if: Backtrace.isFoundationCaptureEnabled))
111+
func foundationGeneratedNSError() {
112+
do {
113+
try throwNSError()
114+
} catch {
115+
let backtrace1 = Backtrace(forFirstThrowOf: error, checkFoundation: true)
116+
let backtrace2 = Backtrace(forFirstThrowOf: error, checkFoundation: false)
117+
#expect(backtrace1 != backtrace2)
118+
}
119+
120+
// Foundation won't capture backtraces for reference-counted errors that
121+
// don't inherit from NSError (even though the existential error box itself
122+
// is of an NSError subclass.)
123+
do {
124+
try throwBacktracedRefCountedError()
125+
} catch {
126+
let backtrace1 = Backtrace(forFirstThrowOf: error, checkFoundation: true)
127+
let backtrace2 = Backtrace(forFirstThrowOf: error, checkFoundation: false)
128+
#expect(backtrace1 == backtrace2)
129+
}
130+
}
98131
#endif
99132

100133
@Test("Backtrace.current() is populated")

0 commit comments

Comments
 (0)