Skip to content

Commit 987fed7

Browse files
authored
Simplify the configuration option for synchronous test isolation. (#747)
This PR replaces `Configuration.isMainActorIsolationEnforced` with a new property `Configuration.defaultIsolationContext` which can be set to any actor, not just the main actor. This property is then used as the isolation context for synchronous test functions that are not otherwise actor-isolated. The typical use case for this property would be to set it to `MainActor.shared` so that synchronous test functions run on the main actor, but a testing tool might want to instead run them all on another actor or serial executor for performance (or other) reasons. This change isn't just speculative: it allows us to simplify the macro-generated thunk for synchronous test functions so that it does not need to emit the body of the thunk twice (once for the main actor, once for non-isolated.) This change wasn't possible before `#isolation` was introduced, but we'd already added `Configuration.isMainActorIsolationEnforced` when that happened so the timing didn't line up. By rewriting this part of the macro expansion, we're able to toss a bunch of support code that isn't needed anymore. Yay! ### 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 83b0b04 commit 987fed7

File tree

7 files changed

+113
-124
lines changed

7 files changed

+113
-124
lines changed

Sources/Testing/Running/Configuration.swift

+25-6
Original file line numberDiff line numberDiff line change
@@ -105,14 +105,13 @@ public struct Configuration: Sendable {
105105
/// By default, the value of this property allows for a single iteration.
106106
public var repetitionPolicy: RepetitionPolicy = .once
107107

108-
// MARK: - Main actor isolation
108+
// MARK: - Isolation context for synchronous tests
109109

110-
#if !SWT_NO_GLOBAL_ACTORS
111-
/// Whether or not synchronous test functions need to run on the main actor.
110+
/// The isolation context to use for synchronous test functions.
112111
///
113-
/// This property is available on platforms where UI testing is implemented.
114-
public var isMainActorIsolationEnforced = false
115-
#endif
112+
/// If the value of this property is `nil`, synchronous test functions run in
113+
/// an unspecified isolation context.
114+
public var defaultSynchronousIsolationContext: (any Actor)? = nil
116115

117116
// MARK: - Time limits
118117

@@ -233,3 +232,23 @@ public struct Configuration: Sendable {
233232
/// The test case filter to which test cases should be filtered when run.
234233
public var testCaseFilter: TestCaseFilter = { _, _ in true }
235234
}
235+
236+
// MARK: - Deprecated
237+
238+
extension Configuration {
239+
#if !SWT_NO_GLOBAL_ACTORS
240+
@available(*, deprecated, message: "Set defaultSynchronousIsolationContext instead.")
241+
public var isMainActorIsolationEnforced: Bool {
242+
get {
243+
defaultSynchronousIsolationContext === MainActor.shared
244+
}
245+
set {
246+
if newValue {
247+
defaultSynchronousIsolationContext = MainActor.shared
248+
} else {
249+
defaultSynchronousIsolationContext = nil
250+
}
251+
}
252+
}
253+
#endif
254+
}

Sources/Testing/Test+Macro.swift

+4-49
Original file line numberDiff line numberDiff line change
@@ -504,58 +504,13 @@ extension Test {
504504
value
505505
}
506506

507-
#if !SWT_NO_GLOBAL_ACTORS
508-
/// Invoke a function isolated to the main actor if appropriate.
507+
/// The current default isolation context.
509508
///
510-
/// - Parameters:
511-
/// - thenBody: The function to invoke, isolated to the main actor, if actor
512-
/// isolation is required.
513-
/// - elseBody: The function to invoke if actor isolation is not required.
514-
///
515-
/// - Returns: Whatever is returned by `thenBody` or `elseBody`.
516-
///
517-
/// - Throws: Whatever is thrown by `thenBody` or `elseBody`.
518-
///
519-
/// `thenBody` and `elseBody` should represent the same function with differing
520-
/// actor isolation. Which one is invoked depends on whether or not synchronous
521-
/// test functions need to run on the main actor.
522-
///
523-
/// - Warning: This function is used to implement the `@Test` macro. Do not call
524-
/// it directly.
525-
public func __ifMainActorIsolationEnforced<R>(
526-
_ thenBody: @Sendable @MainActor () async throws -> R,
527-
else elseBody: @Sendable () async throws -> R
528-
) async throws -> R where R: Sendable {
529-
if Configuration.current?.isMainActorIsolationEnforced == true {
530-
try await thenBody()
531-
} else {
532-
try await elseBody()
533-
}
534-
}
535-
#else
536-
/// Invoke a function.
537-
///
538-
/// - Parameters:
539-
/// - body: The function to invoke.
540-
///
541-
/// - Returns: Whatever is returned by `body`.
542-
///
543-
/// - Throws: Whatever is thrown by `body`.
544-
///
545-
/// This function simply invokes `body`. Its signature matches that of the same
546-
/// function when `SWT_NO_GLOBAL_ACTORS` is not defined so that it can be used
547-
/// during expansion of the `@Test` macro without knowing the value of that
548-
/// compiler conditional on the target platform.
549-
///
550-
/// - Warning: This function is used to implement the `@Test` macro. Do not call
509+
/// - Warning: This property is used to implement the `@Test` macro. Do not call
551510
/// it directly.
552-
@inlinable public func __ifMainActorIsolationEnforced<R>(
553-
_: @Sendable () async throws -> R,
554-
else body: @Sendable () async throws -> R
555-
) async throws -> R where R: Sendable {
556-
try await body()
511+
public var __defaultSynchronousIsolationContext: (any Actor)? {
512+
Configuration.current?.defaultSynchronousIsolationContext ?? #isolation
557513
}
558-
#endif
559514

560515
/// Run a test function as an `XCTestCase`-compatible method.
561516
///

Sources/TestingMacros/Support/Additions/FunctionDeclSyntaxAdditions.swift

+7
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ extension FunctionDeclSyntax {
2626
.contains(.keyword(.mutating))
2727
}
2828

29+
/// Whether or not this function is a `nonisolated` function.
30+
var isNonisolated: Bool {
31+
modifiers.lazy
32+
.map(\.name.tokenKind)
33+
.contains(.keyword(.nonisolated))
34+
}
35+
2936
/// The name of this function including parentheses, parameter labels, and
3037
/// colons.
3138
var completeName: String {

Sources/TestingMacros/TestDeclarationMacro.swift

+38-66
Original file line numberDiff line numberDiff line change
@@ -172,62 +172,6 @@ public struct TestDeclarationMacro: PeerMacro, Sendable {
172172
return FunctionParameterClauseSyntax(parameters: parameterList)
173173
}
174174

175-
/// Create a closure capture list used to capture the arguments to a function
176-
/// when calling it from its corresponding thunk function.
177-
///
178-
/// - Parameters:
179-
/// - parametersWithLabels: A sequence of tuples containing parameters to
180-
/// the original function and their corresponding identifiers as used by
181-
/// the thunk function.
182-
///
183-
/// - Returns: A closure capture list syntax node representing the arguments
184-
/// to the thunk function.
185-
///
186-
/// We need to construct a capture list when calling a synchronous test
187-
/// function because of the call to `__ifMainActorIsolationEnforced(_:else:)`
188-
/// that we insert. That function theoretically captures all arguments twice,
189-
/// which is not allowed for arguments marked `borrowing` or `consuming`. The
190-
/// capture list forces those arguments to be copied, side-stepping the issue.
191-
///
192-
/// - Note: We do not support move-only types as arguments yet. Instances of
193-
/// move-only types cannot be used with generics, so they cannot be elements
194-
/// of a `Collection`.
195-
private static func _createCaptureListExpr(
196-
from parametersWithLabels: some Sequence<(DeclReferenceExprSyntax, FunctionParameterSyntax)>
197-
) -> ClosureCaptureClauseSyntax {
198-
let specifierKeywordsNeedingCopy: [TokenKind] = [.keyword(.borrowing), .keyword(.consuming),]
199-
let closureCaptures = parametersWithLabels.lazy.map { label, parameter in
200-
var needsCopy = false
201-
if let parameterType = parameter.type.as(AttributedTypeSyntax.self) {
202-
needsCopy = parameterType.specifiers.contains { specifier in
203-
guard case let .simpleTypeSpecifier(specifier) = specifier else {
204-
return false
205-
}
206-
return specifierKeywordsNeedingCopy.contains(specifier.specifier.tokenKind)
207-
}
208-
}
209-
210-
if needsCopy {
211-
return ClosureCaptureSyntax(
212-
name: label.baseName,
213-
equal: .equalToken(),
214-
expression: CopyExprSyntax(
215-
copyKeyword: .keyword(.copy).with(\.trailingTrivia, .space),
216-
expression: label
217-
)
218-
)
219-
} else {
220-
return ClosureCaptureSyntax(expression: label)
221-
}
222-
}
223-
224-
return ClosureCaptureClauseSyntax {
225-
for closureCapture in closureCaptures {
226-
closureCapture
227-
}
228-
}
229-
}
230-
231175
/// The `static` keyword, if `typeName` is not `nil`.
232176
///
233177
/// - Parameters:
@@ -269,7 +213,6 @@ public struct TestDeclarationMacro: PeerMacro, Sendable {
269213
// needed, so it's lazy.
270214
let forwardedParamsExpr = _createForwardedParamsExpr(from: parametersWithLabels)
271215
let thunkParamsExpr = _createThunkParamsExpr(from: parametersWithLabels)
272-
lazy var captureListExpr = _createCaptureListExpr(from: parametersWithLabels)
273216

274217
// How do we call a function if we don't know whether it's `async` or
275218
// `throws`? Yes, we know if the keywords are on the function, but it could
@@ -290,7 +233,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable {
290233
// If the function is noasync *and* main-actor-isolated, we'll call through
291234
// MainActor.run to invoke it. We do not have a general mechanism for
292235
// detecting isolation to other global actors.
293-
lazy var isMainActorIsolated = !functionDecl.attributes(named: "MainActor", inModuleNamed: "Swift").isEmpty
236+
lazy var isMainActorIsolated = !functionDecl.attributes(named: "MainActor", inModuleNamed: "_Concurrency").isEmpty
294237
var forwardCall: (ExprSyntax) -> ExprSyntax = {
295238
"try await Testing.__requiringTry(Testing.__requiringAwait(\($0)))"
296239
}
@@ -315,7 +258,7 @@ public struct TestDeclarationMacro: PeerMacro, Sendable {
315258
if functionDecl.isStaticOrClass {
316259
thunkBody = "_ = \(forwardCall("\(typeName).\(functionDecl.name.trimmed)\(forwardedParamsExpr)"))"
317260
} else {
318-
let instanceName = context.makeUniqueName(thunking: functionDecl)
261+
let instanceName = context.makeUniqueName("")
319262
let varOrLet = functionDecl.isMutating ? "var" : "let"
320263
thunkBody = """
321264
\(raw: varOrLet) \(raw: instanceName) = \(forwardInit("\(typeName)()"))
@@ -344,16 +287,45 @@ public struct TestDeclarationMacro: PeerMacro, Sendable {
344287
thunkBody = "_ = \(forwardCall("\(functionDecl.name.trimmed)\(forwardedParamsExpr)"))"
345288
}
346289

347-
// If this function is synchronous and is not explicitly isolated to the
348-
// main actor, it may still need to run main-actor-isolated depending on the
349-
// runtime configuration in the test process.
350-
if functionDecl.signature.effectSpecifiers?.asyncSpecifier == nil && !isMainActorIsolated {
290+
// If this function is synchronous, is not explicitly nonisolated, and is
291+
// not explicitly isolated to some actor, it should run in the configured
292+
// default isolation context. If the suite type is an actor, this will cause
293+
// a hop off the actor followed by an immediate hop back on, but otherwise
294+
// should be harmless. Note that we do not support specifying an `isolated`
295+
// parameter on a test function at this time.
296+
//
297+
// We use a second, inner thunk function here instead of just adding the
298+
// isolation parameter to the "real" thunk because adding it there prevents
299+
// correct tuple desugaring of the "real" arguments to the thunk.
300+
if functionDecl.signature.effectSpecifiers?.asyncSpecifier == nil && !isMainActorIsolated && !functionDecl.isNonisolated {
301+
// Get a unique name for this secondary thunk. We don't need it to be
302+
// uniqued against functionDecl because it's interior to the "real" thunk,
303+
// so its name can't conflict with any other names visible in this scope.
304+
let isolationThunkName = context.makeUniqueName("")
305+
306+
// Insert a (defaulted) isolated argument. If we emit a closure (or inner
307+
// function) that captured the arguments to the "real" thunk, the compiler
308+
// has trouble reasoning about the lifetime of arguments to that closure
309+
// especially if those arguments are borrowed or consumed, which results
310+
// in hard-to-avoid compile-time errors. Fortunately, forwarding the full
311+
// argument list is straightforward.
312+
let thunkParamsExprCopy = FunctionParameterClauseSyntax {
313+
for thunkParam in thunkParamsExpr.parameters {
314+
thunkParam
315+
}
316+
FunctionParameterSyntax(
317+
modifiers: [DeclModifierSyntax(name: .keyword(.isolated))],
318+
firstName: .wildcardToken(),
319+
type: "isolated (any Actor)?" as TypeSyntax,
320+
defaultValue: InitializerClauseSyntax(value: "Testing.__defaultSynchronousIsolationContext" as ExprSyntax)
321+
)
322+
}
323+
351324
thunkBody = """
352-
try await Testing.__ifMainActorIsolationEnforced { \(captureListExpr) in
353-
\(thunkBody)
354-
} else: { \(captureListExpr) in
325+
@Sendable func \(isolationThunkName)\(thunkParamsExprCopy) async throws {
355326
\(thunkBody)
356327
}
328+
try await \(isolationThunkName)\(forwardedParamsExpr)
357329
"""
358330
}
359331

Tests/TestingMacrosTests/TestDeclarationMacroTests.swift

-2
Original file line numberDiff line numberDiff line change
@@ -325,8 +325,6 @@ struct TestDeclarationMacroTests {
325325
("@Test @_unavailableFromAsync @MainActor func f() {}", nil, "MainActor.run"),
326326
("@Test @available(*, noasync) func f() {}", nil, "__requiringTry"),
327327
("@Test @_unavailableFromAsync func f() {}", nil, "__requiringTry"),
328-
("@Test(arguments: []) func f(i: borrowing Int) {}", nil, "copy"),
329-
("@Test(arguments: []) func f(_ i: borrowing Int) {}", nil, "copy"),
330328
("@Test(arguments: []) func f(f: () -> String) {}", "(() -> String).self", nil),
331329
("struct S {\n\t@Test func testF() {} }", nil, "__invokeXCTestCaseMethod"),
332330
("struct S {\n\t@Test func testF() throws {} }", nil, "__invokeXCTestCaseMethod"),

Tests/TestingTests/MiscellaneousTests.swift

+16
Original file line numberDiff line numberDiff line change
@@ -99,12 +99,28 @@ struct SendableTests: Sendable {
9999
@Test(.hidden, arguments: FixtureData.zeroUpTo100)
100100
func parameterizedBorrowingAsync(i: borrowing Int) async {}
101101

102+
@MainActor
103+
@Test(.hidden, arguments: FixtureData.zeroUpTo100)
104+
func parameterizedBorrowingMainActor(i: borrowing Int) {}
105+
106+
@available(*, noasync)
107+
@Test(.hidden, arguments: FixtureData.zeroUpTo100)
108+
func parameterizedBorrowingNoasync(i: borrowing Int) {}
109+
102110
@Test(.hidden, arguments: FixtureData.zeroUpTo100)
103111
func parameterizedConsuming(i: consuming Int) {}
104112

105113
@Test(.hidden, arguments: FixtureData.zeroUpTo100)
106114
func parameterizedConsumingAsync(i: consuming Int) async { }
107115

116+
@MainActor
117+
@Test(.hidden, arguments: FixtureData.zeroUpTo100)
118+
func parameterizedConsumingMainActor(i: consuming Int) {}
119+
120+
@available(*, noasync)
121+
@Test(.hidden, arguments: FixtureData.zeroUpTo100)
122+
func parameterizedConsumingNoasync(i: consuming Int) {}
123+
108124
@Test(.hidden, arguments: FixtureData.stringReturningClosureArray)
109125
func parameterizedAcceptingFunction(f: @Sendable () -> String) {}
110126
}

Tests/TestingTests/RunnerTests.swift

+23-1
Original file line numberDiff line numberDiff line change
@@ -807,7 +807,11 @@ final class RunnerTests: XCTestCase {
807807
@TaskLocal static var isMainActorIsolationEnforced = false
808808

809809
@Suite(.hidden) struct MainActorIsolationTests {
810-
@Test(.hidden) func mustRunOnMainActor() {
810+
@Test(.hidden) func mightRunOnMainActor() {
811+
XCTAssertEqual(Thread.isMainThread, isMainActorIsolationEnforced)
812+
}
813+
814+
@Test(.hidden, arguments: 0 ..< 10) func mightRunOnMainActor(arg: Int) {
811815
XCTAssertEqual(Thread.isMainThread, isMainActorIsolationEnforced)
812816
}
813817

@@ -822,8 +826,13 @@ final class RunnerTests: XCTestCase {
822826
@Test(.hidden) @MainActor func asyncButRunsOnMainActor() async {
823827
XCTAssertTrue(Thread.isMainThread)
824828
}
829+
830+
@Test(.hidden) nonisolated func runsNonisolated() {
831+
XCTAssertFalse(Thread.isMainThread)
832+
}
825833
}
826834

835+
@available(*, deprecated)
827836
func testSynchronousTestFunctionRunsOnMainActorWhenEnforced() async {
828837
var configuration = Configuration()
829838
configuration.isMainActorIsolationEnforced = true
@@ -836,6 +845,19 @@ final class RunnerTests: XCTestCase {
836845
await runTest(for: MainActorIsolationTests.self, configuration: configuration)
837846
}
838847
}
848+
849+
func testSynchronousTestFunctionRunsInDefaultIsolationContext() async {
850+
var configuration = Configuration()
851+
configuration.defaultSynchronousIsolationContext = MainActor.shared
852+
await Self.$isMainActorIsolationEnforced.withValue(true) {
853+
await runTest(for: MainActorIsolationTests.self, configuration: configuration)
854+
}
855+
856+
configuration.defaultSynchronousIsolationContext = nil
857+
await Self.$isMainActorIsolationEnforced.withValue(false) {
858+
await runTest(for: MainActorIsolationTests.self, configuration: configuration)
859+
}
860+
}
839861
#endif
840862

841863
@Suite(.hidden) struct DeprecatedVersionTests {

0 commit comments

Comments
 (0)