Skip to content

Commit 1b04184

Browse files
authored
Inherit isolation in #expect(exitsWith:). (#736)
This PR ensures the body of _the implementation of_ `#expect(exitsWith:)` inherits isolation from the caller. The actual body closure passed to the macro cannot inherit isolation as it runs in a separate process, but the glue code we emit needs to inherit so that if the caller is actor-isolated, the code compiles cleanly. Without this change, the following test fails to compile: ```swift @mainactor @test func f() async { await #expect(exitsWith: .failure) { /* ... */ } // 🛑 ^ ^ ^ sending main actor-isolated value of type '() -> [Comment]' with later accesses to nonisolated context risks causing data races } ``` ### 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 bd66853 commit 1b04184

File tree

4 files changed

+53
-24
lines changed

4 files changed

+53
-24
lines changed

Sources/Testing/ExitTests/ExitTest.swift

+2-1
Original file line numberDiff line numberDiff line change
@@ -163,17 +163,18 @@ extension ExitTest {
163163
/// - isRequired: Whether or not the expectation is required. The value of
164164
/// this argument does not affect whether or not an error is thrown on
165165
/// failure.
166+
/// - isolation: The actor to which the exit test is isolated, if any.
166167
/// - sourceLocation: The source location of the expectation.
167168
///
168169
/// This function contains the common implementation for all
169170
/// `await #expect(exitsWith:) { }` invocations regardless of calling
170171
/// convention.
171172
func callExitTest(
172173
exitsWith expectedExitCondition: ExitCondition,
173-
performing _: @escaping @Sendable () async throws -> Void,
174174
expression: __Expression,
175175
comments: @autoclosure () -> [Comment],
176176
isRequired: Bool,
177+
isolation: isolated (any Actor)? = #isolation,
177178
sourceLocation: SourceLocation
178179
) async -> Result<Void, any Error> {
179180
guard let configuration = Configuration.current ?? Configuration.all.first else {

Sources/Testing/Expectations/ExpectationChecking+Macro.swift

+2-2
Original file line numberDiff line numberDiff line change
@@ -1142,15 +1142,15 @@ public func __checkClosureCall<R>(
11421142
@_spi(Experimental)
11431143
public func __checkClosureCall(
11441144
exitsWith expectedExitCondition: ExitCondition,
1145-
performing body: @convention(thin) () async throws -> Void,
1145+
performing body: @convention(thin) () -> Void,
11461146
expression: __Expression,
11471147
comments: @autoclosure () -> [Comment],
11481148
isRequired: Bool,
1149+
isolation: isolated (any Actor)? = #isolation,
11491150
sourceLocation: SourceLocation
11501151
) async -> Result<Void, any Error> {
11511152
await callExitTest(
11521153
exitsWith: expectedExitCondition,
1153-
performing: { try await body() },
11541154
expression: expression,
11551155
comments: comments(),
11561156
isRequired: isRequired,

Sources/TestingMacros/ConditionMacro.swift

+35-21
Original file line numberDiff line numberDiff line change
@@ -376,32 +376,46 @@ extension ExitTestConditionMacro {
376376

377377
let bodyArgumentExpr = arguments[trailingClosureIndex].expression
378378

379+
var decls = [DeclSyntax]()
380+
381+
// Implement the body of the exit test outside the enum we're declaring so
382+
// that `Self` resolves to the type containing the exit test, not the enum.
383+
let bodyThunkName = context.makeUniqueName("")
384+
decls.append(
385+
"""
386+
@Sendable func \(bodyThunkName)() async throws -> Void {
387+
return try await Testing.__requiringTry(Testing.__requiringAwait(\(bodyArgumentExpr.trimmed)))()
388+
}
389+
"""
390+
)
391+
379392
// Create a local type that can be discovered at runtime and which contains
380393
// the exit test body.
381394
let enumName = context.makeUniqueName("__🟠$exit_test_body__")
382-
let enumDecl: DeclSyntax = """
383-
@available(*, deprecated, message: "This type is an implementation detail of the testing library. Do not use it directly.")
384-
enum \(enumName): Testing.__ExitTestContainer {
385-
static var __sourceLocation: Testing.SourceLocation {
386-
\(createSourceLocationExpr(of: macro, context: context))
387-
}
388-
static var __body: @Sendable () async throws -> Void {
389-
\(bodyArgumentExpr.trimmed)
395+
decls.append(
396+
"""
397+
@available(*, deprecated, message: "This type is an implementation detail of the testing library. Do not use it directly.")
398+
enum \(enumName): Testing.__ExitTestContainer, Sendable {
399+
static var __sourceLocation: Testing.SourceLocation {
400+
\(createSourceLocationExpr(of: macro, context: context))
401+
}
402+
static var __body: @Sendable () async throws -> Void {
403+
\(bodyThunkName)
404+
}
405+
static var __expectedExitCondition: Testing.ExitCondition {
406+
\(arguments[expectedExitConditionIndex].expression.trimmed)
407+
}
390408
}
391-
static var __expectedExitCondition: Testing.ExitCondition {
392-
\(arguments[expectedExitConditionIndex].expression.trimmed)
409+
"""
410+
)
411+
412+
arguments[trailingClosureIndex].expression = ExprSyntax(
413+
ClosureExprSyntax {
414+
for decl in decls {
415+
CodeBlockItemSyntax(item: .decl(decl))
416+
}
393417
}
394-
}
395-
"""
396-
397-
// Explicitly include a closure signature to work around a compiler bug
398-
// type-checking thin throwing functions after macro expansion.
399-
// SEE: rdar://133979438
400-
arguments[trailingClosureIndex].expression = """
401-
{ () async throws in
402-
\(enumDecl)
403-
}
404-
"""
418+
)
405419

406420
// Replace the exit test body (as an argument to the macro) with a stub
407421
// closure that hosts the type we created above.

Tests/TestingTests/ExitTestTests.swift

+14
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,20 @@ private import _TestingInternals
280280
#expect(ExitCondition.signal(SIGTERM) !== .signal(SIGINT))
281281
#endif
282282
}
283+
284+
@MainActor static func someMainActorFunction() {
285+
MainActor.assertIsolated()
286+
}
287+
288+
@Test("Exit test can be main-actor-isolated")
289+
@MainActor
290+
func mainActorIsolation() async {
291+
await #expect(exitsWith: .success) {
292+
await Self.someMainActorFunction()
293+
_ = 0
294+
exit(EXIT_SUCCESS)
295+
}
296+
}
283297
}
284298

285299
// MARK: - Fixtures

0 commit comments

Comments
 (0)