Skip to content

Commit 8500bd0

Browse files
authored
Propagate SkipInfo to child tasks when a test is cancelled. (#1298)
This PR ensures that, when a test is cancelled, its test cases report the same `SkipInfo` structure as they are recursively cancelled. For instance: ```swift @test(arguments: [a ... z]) func foo(arg: T) throws { try Test.cancel("Message") } ``` Prior to this change, the `.testCaseCancelled` events would be posted without the user-supplied comment `"Message"` and each one would need to construct a source context by capturing a backtrace from deep within Swift Testing and the stdlib. With this change in place, said events post with the same comment and source context as is associated with the initial `.testCancelled` event. ### 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 5089e36 commit 8500bd0

File tree

5 files changed

+78
-48
lines changed

5 files changed

+78
-48
lines changed

Sources/Testing/ExitTests/ExitTest.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,7 @@ extension ExitTest {
10481048
backtrace: nil, // A backtrace from the child process will have the wrong address space.
10491049
sourceLocation: event._sourceLocation
10501050
)
1051+
lazy var skipInfo = SkipInfo(comment: comments.first, sourceContext: sourceContext)
10511052
if let issue = event.issue {
10521053
// Translate the issue back into a "real" issue and record it
10531054
// in the parent process. This translation is, of course, lossy
@@ -1075,9 +1076,9 @@ extension ExitTest {
10751076
} else if let attachment = event.attachment {
10761077
Attachment.record(attachment, sourceLocation: event._sourceLocation!)
10771078
} else if case .testCancelled = event.kind {
1078-
_ = try? Test.cancel(comments: comments, sourceContext: sourceContext)
1079+
_ = try? Test.cancel(with: skipInfo)
10791080
} else if case .testCaseCancelled = event.kind {
1080-
_ = try? Test.Case.cancel(comments: comments, sourceContext: sourceContext)
1081+
_ = try? Test.Case.cancel(with: skipInfo)
10811082
}
10821083
}
10831084

Sources/Testing/Running/SkipInfo.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ extension SkipInfo {
6969
self = skipInfo
7070
} else if error is CancellationError, Task.isCancelled {
7171
// Synthesize skip info for this cancellation error.
72-
let backtrace = Backtrace(forFirstThrowOf: error) ?? .current()
72+
let backtrace = Backtrace(forFirstThrowOf: error)
7373
let sourceContext = SourceContext(backtrace: backtrace, sourceLocation: nil)
7474
self.init(comment: nil, sourceContext: sourceContext)
7575
} else {

Sources/Testing/Test+Cancellation.swift

Lines changed: 49 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -16,17 +16,15 @@ protocol TestCancellable: Sendable {
1616
/// Cancel the current instance of this type.
1717
///
1818
/// - Parameters:
19-
/// - comments: Comments describing why you are cancelling the test/case.
20-
/// - sourceContext: The source context to which the testing library will
21-
/// attribute the cancellation.
19+
/// - skipInfo: Information about the cancellation event.
2220
///
2321
/// - Throws: An error indicating that the current instance of this type has
2422
/// been cancelled.
2523
///
2624
/// Note that the public ``Test/cancel(_:sourceLocation:)`` function has a
2725
/// different signature and accepts a source location rather than a source
2826
/// context value.
29-
static func cancel(comments: [Comment], sourceContext: @autoclosure () -> SourceContext) throws -> Never
27+
static func cancel(with skipInfo: SkipInfo) throws -> Never
3028

3129
/// Make an instance of ``Event/Kind`` appropriate for an instance of this
3230
/// type.
@@ -47,8 +45,17 @@ private struct _TaskReference: Sendable {
4745
private nonisolated(unsafe) var _unsafeCurrentTask = Locked<UnsafeCurrentTask?>()
4846

4947
init() {
50-
let unsafeCurrentTask = withUnsafeCurrentTask { $0 }
51-
_unsafeCurrentTask = Locked(rawValue: unsafeCurrentTask)
48+
// WARNING! Normally, allowing an instance of `UnsafeCurrentTask` to escape
49+
// its scope is dangerous because it could be used unsafely after the task
50+
// ends. However, because we take care not to allow the task object to
51+
// escape the task (by only storing it in a task-local value), we can ensure
52+
// these unsafe scenarios won't occur.
53+
//
54+
// TODO: when our deployment targets allow, we should switch to calling the
55+
// `async` overload of `withUnsafeCurrentTask()` from the body of
56+
// `withCancellationHandling(_:)`. That will allow us to use the task object
57+
// in a safely scoped fashion.
58+
_unsafeCurrentTask = withUnsafeCurrentTask { Locked(rawValue: $0) }
5259
}
5360

5461
/// Take this instance's reference to its associated task.
@@ -69,8 +76,14 @@ private struct _TaskReference: Sendable {
6976

7077
/// A dictionary of tracked tasks, keyed by types that conform to
7178
/// ``TestCancellable``.
72-
@TaskLocal
73-
private var _currentTaskReferences = [ObjectIdentifier: _TaskReference]()
79+
@TaskLocal private var _currentTaskReferences = [ObjectIdentifier: _TaskReference]()
80+
81+
/// The instance of ``SkipInfo`` to propagate to children of the current task.
82+
///
83+
/// We set this value while calling `UnsafeCurrentTask.cancel()` so that its
84+
/// value is available in tracked child tasks when their cancellation handlers
85+
/// are called (in ``TestCancellable/withCancellationHandling(_:)`` below).
86+
@TaskLocal private var _currentSkipInfo: SkipInfo?
7487

7588
extension TestCancellable {
7689
/// Call a function while the ``unsafeCurrentTask`` property of this instance
@@ -95,10 +108,9 @@ extension TestCancellable {
95108
} onCancel: {
96109
// The current task was cancelled, so cancel the test case or test
97110
// associated with it.
98-
_ = try? Self.cancel(
99-
comments: [],
100-
sourceContext: SourceContext(backtrace: .current(), sourceLocation: nil)
101-
)
111+
112+
let skipInfo = _currentSkipInfo ?? SkipInfo(sourceContext: SourceContext(backtrace: .current(), sourceLocation: nil))
113+
_ = try? Self.cancel(with: skipInfo)
102114
}
103115
}
104116
}
@@ -112,24 +124,21 @@ extension TestCancellable {
112124
/// - cancellableValue: The test or test case to cancel, or `nil` if neither
113125
/// is set and we need fallback handling.
114126
/// - testAndTestCase: The test and test case to use when posting an event.
115-
/// - comments: Comments describing why you are cancelling the test/case.
116-
/// - sourceContext: The source context to which the testing library will
117-
/// attribute the cancellation.
127+
/// - skipInfo: Information about the cancellation event.
118128
///
119129
/// - Throws: An instance of ``SkipInfo`` describing the cancellation.
120-
private func _cancel<T>(_ cancellableValue: T?, for testAndTestCase: (Test?, Test.Case?), comments: [Comment], sourceContext: @autoclosure () -> SourceContext) throws -> Never where T: TestCancellable {
121-
var skipInfo = SkipInfo(comment: comments.first, sourceContext: .init(backtrace: nil, sourceLocation: nil))
122-
130+
private func _cancel<T>(_ cancellableValue: T?, for testAndTestCase: (Test?, Test.Case?), skipInfo: SkipInfo) throws -> Never where T: TestCancellable {
123131
if cancellableValue != nil {
124-
// If the current test case is still running, cancel its task and clear its
125-
// task property (which signals that it has been cancelled.)
132+
// If the current test case is still running, take its task property (which
133+
// signals to subsequent callers that it has been cancelled.)
126134
let task = _currentTaskReferences[ObjectIdentifier(T.self)]?.takeUnsafeCurrentTask()
127-
task?.cancel()
128135

129136
// If we just cancelled the current test case's task, post a corresponding
130137
// event with the relevant skip info.
131-
if task != nil {
132-
skipInfo.sourceContext = sourceContext()
138+
if let task {
139+
$_currentSkipInfo.withValue(skipInfo) {
140+
task.cancel()
141+
}
133142
Event.post(T.makeCancelledEventKind(with: skipInfo), for: testAndTestCase)
134143
}
135144
} else {
@@ -147,13 +156,18 @@ private func _cancel<T>(_ cancellableValue: T?, for testAndTestCase: (Test?, Tes
147156
// This code is running in an exit test. We don't have a "current test" or
148157
// "current test case" in the child process, so we'll let the parent
149158
// process sort that out.
150-
skipInfo.sourceContext = sourceContext()
151159
Event.post(T.makeCancelledEventKind(with: skipInfo), for: (nil, nil))
152160
} else {
153161
// Record an API misuse issue for trying to cancel the current test/case
154162
// outside of any useful context.
155-
let comments = ["Attempted to cancel the current test or test case, but one is not associated with the current task."] + comments
156-
let issue = Issue(kind: .apiMisused, comments: comments, sourceContext: sourceContext())
163+
let issue = Issue(
164+
kind: .apiMisused,
165+
comments: [
166+
"Attempted to cancel the current test or test case, but one is not associated with the current task.",
167+
skipInfo.comment,
168+
].compactMap(\.self),
169+
sourceContext: skipInfo.sourceContext
170+
)
157171
issue.record()
158172
}
159173
}
@@ -208,15 +222,13 @@ extension Test: TestCancellable {
208222
/// test alone, call ``Test/Case/cancel(_:sourceLocation:)`` instead.
209223
@_spi(Experimental)
210224
public static func cancel(_ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation) throws -> Never {
211-
try Self.cancel(
212-
comments: Array(comment),
213-
sourceContext: SourceContext(backtrace: .current(), sourceLocation: sourceLocation)
214-
)
225+
let skipInfo = SkipInfo(comment: comment, sourceContext: SourceContext(backtrace: nil, sourceLocation: sourceLocation))
226+
try Self.cancel(with: skipInfo)
215227
}
216228

217-
static func cancel(comments: [Comment], sourceContext: @autoclosure () -> SourceContext) throws -> Never {
229+
static func cancel(with skipInfo: SkipInfo) throws -> Never {
218230
let test = Test.current
219-
try _cancel(test, for: (test, nil), comments: comments, sourceContext: sourceContext())
231+
try _cancel(test, for: (test, nil), skipInfo: skipInfo)
220232
}
221233

222234
static func makeCancelledEventKind(with skipInfo: SkipInfo) -> Event.Kind {
@@ -271,23 +283,20 @@ extension Test.Case: TestCancellable {
271283
/// ``Test/cancel(_:sourceLocation:)`` instead.
272284
@_spi(Experimental)
273285
public static func cancel(_ comment: Comment? = nil, sourceLocation: SourceLocation = #_sourceLocation) throws -> Never {
274-
try Self.cancel(
275-
comments: Array(comment),
276-
sourceContext: SourceContext(backtrace: .current(), sourceLocation: sourceLocation)
277-
)
286+
let skipInfo = SkipInfo(comment: comment, sourceContext: SourceContext(backtrace: nil, sourceLocation: sourceLocation))
287+
try Self.cancel(with: skipInfo)
278288
}
279289

280-
static func cancel(comments: [Comment], sourceContext: @autoclosure () -> SourceContext) throws -> Never {
290+
static func cancel(with skipInfo: SkipInfo) throws -> Never {
281291
let test = Test.current
282292
let testCase = Test.Case.current
283-
let sourceContext = sourceContext() // evaluated twice, avoid laziness
284293

285294
do {
286295
// Cancel the current test case (if it's nil, that's the API misuse path.)
287-
try _cancel(testCase, for: (test, testCase), comments: comments, sourceContext: sourceContext)
296+
try _cancel(testCase, for: (test, testCase), skipInfo: skipInfo)
288297
} catch _ where test?.isParameterized == false {
289298
// The current test is not parameterized, so cancel the whole test too.
290-
try _cancel(test, for: (test, nil), comments: comments, sourceContext: sourceContext)
299+
try _cancel(test, for: (test, nil), skipInfo: skipInfo)
291300
}
292301
}
293302

Sources/Testing/Traits/ConditionTrait.swift

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,15 +85,13 @@ public struct ConditionTrait: TestTrait, SuiteTrait {
8585

8686
public func prepare(for test: Test) async throws {
8787
let isEnabled = try await evaluate()
88-
8988
if !isEnabled {
9089
// We don't need to consider including a backtrace here because it will
9190
// primarily contain frames in the testing library, not user code. If an
9291
// error was thrown by a condition evaluated above, the caller _should_
9392
// attempt to get the backtrace of the caught error when creating an issue
9493
// for it, however.
95-
let sourceContext = SourceContext(backtrace: nil, sourceLocation: sourceLocation)
96-
throw SkipInfo(comment: comments.first, sourceContext: sourceContext)
94+
try Test.cancel(comments.first, sourceLocation: sourceLocation)
9795
}
9896
}
9997

Tests/TestingTests/TestCancellationTests.swift

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,13 +11,20 @@
1111
@testable @_spi(Experimental) @_spi(ForToolsIntegrationOnly) import Testing
1212

1313
@Suite(.serialized) struct `Test cancellation tests` {
14-
func testCancellation(testCancelled: Int = 0, testSkipped: Int = 0, testCaseCancelled: Int = 0, issueRecorded: Int = 0, _ body: @Sendable (Configuration) async -> Void) async {
14+
func testCancellation(
15+
testCancelled: Int = 0,
16+
testSkipped: Int = 0,
17+
testCaseCancelled: Int = 0,
18+
issueRecorded: Int = 0,
19+
_ body: @Sendable (Configuration) async -> Void,
20+
eventHandler: @escaping @Sendable (borrowing Event, borrowing Event.Context) -> Void = { _, _ in }
21+
) async {
1522
await confirmation("Test cancelled", expectedCount: testCancelled) { testCancelled in
1623
await confirmation("Test skipped", expectedCount: testSkipped) { testSkipped in
1724
await confirmation("Test case cancelled", expectedCount: testCaseCancelled) { testCaseCancelled in
1825
await confirmation("Issue recorded", expectedCount: issueRecorded) { [issueRecordedCount = issueRecorded] issueRecorded in
1926
var configuration = Configuration()
20-
configuration.eventHandler = { event, _ in
27+
configuration.eventHandler = { event, eventContext in
2128
switch event.kind {
2229
case .testCancelled:
2330
testCancelled()
@@ -33,6 +40,7 @@
3340
default:
3441
break
3542
}
43+
eventHandler(event, eventContext)
3644
}
3745
#if !SWT_NO_EXIT_TESTS
3846
configuration.exitTestHandler = ExitTest.handlerForEntryPoint()
@@ -84,6 +92,20 @@
8492
}
8593
}
8694

95+
@Test func `Cancelling a test propagates its SkipInfo to its test cases`() async {
96+
let sourceLocation = #_sourceLocation
97+
await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in
98+
await Test {
99+
try Test.cancel("Cancelled test", sourceLocation: sourceLocation)
100+
}.run(configuration: configuration)
101+
} eventHandler: { event, _ in
102+
if case let .testCaseCancelled(skipInfo) = event.kind {
103+
#expect(skipInfo.comment?.rawValue == "Cancelled test")
104+
#expect(skipInfo.sourceContext.sourceLocation == sourceLocation)
105+
}
106+
}
107+
}
108+
87109
@Test func `Cancelling a test by cancelling its task (throwing)`() async {
88110
await testCancellation(testCancelled: 1, testCaseCancelled: 1) { configuration in
89111
await Test {

0 commit comments

Comments
 (0)