Skip to content

[WIP] Serialization traits for data dependencies #1232

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 1 commit into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Sources/Testing/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ add_library(Testing
Running/Runner.Plan+Dumping.swift
Running/Runner.RuntimeState.swift
Running/Runner.swift
Running/Serializer.swift
Running/SkipInfo.swift
SourceAttribution/Backtrace.swift
SourceAttribution/Backtrace+Symbolication.swift
Expand Down
33 changes: 33 additions & 0 deletions Sources/Testing/Running/Runner.Plan.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,36 @@ extension Runner.Plan {
}
}

/// Recursively deduplicate traits on the given test by calling
/// ``ReducibleTrait/reduce(_:)`` across all nodes in the graph.
///
/// - Parameters:
/// - testGraph: The graph of tests to modify.
private static func _recursivelyReduceTraits(in testGraph: inout Graph<String, Test?>) {
if var test = testGraph.value {
// O(n^2), but we expect n to be small, right?
test.traits = test.traits.reduce(into: []) { traits, trait in
for i in traits.indices {
let other = traits[i]
if let replacement = trait._reduce(into: other) {
traits[i] = replacement
return
}
}

// The trait wasn't reduced into any other traits, so preserve it.
traits.append(trait)
}
testGraph.value = test
}

testGraph.children = testGraph.children.mapValues { child in
var child = child
_recursivelyReduceTraits(in: &child)
return child
}
}

/// Recursively synthesize test instances representing suites for all missing
/// values in the specified test graph.
///
Expand Down Expand Up @@ -250,6 +280,9 @@ extension Runner.Plan {
// filtered out.
_recursivelyApplyTraits(to: &testGraph)

// Recursively reduce traits in the graph.
_recursivelyReduceTraits(in: &testGraph)

// For each test value, determine the appropriate action for it.
//
// FIXME: Parallelize this work. Calling `prepare(...)` on all traits and
Expand Down
124 changes: 74 additions & 50 deletions Sources/Testing/Running/Runner.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,60 @@ extension Runner {
}
}

/// Post `testStarted` and `testEnded` (or `testSkipped`) events for the test
/// at the given plan step.
///
/// - Parameters:
/// - step: The plan step for which events should be posted.
/// - configuration: The configuration to use for running.
/// - body: A function to execute between the started/ended events.
///
/// - Throws: Whatever is thrown by `body` or while handling any issues
/// recorded in the process.
///
/// - Returns: Whatever is returned by `body`.
///
/// This function does _not_ post the `planStepStarted` and `planStepEnded`
/// events.
private static func _postingTestStartedAndEndedEvents<R>(for step: Plan.Step, configuration: Configuration, _ body: @Sendable () async throws -> R) async throws -> R {
// Whether to send a `.testEnded` event at the end of running this step.
// Some steps' actions may not require a final event to be sent — for
// example, a skip event only sends `.testSkipped`.
let shouldSendTestEnded: Bool

// Determine what kind of event to send for this step based on its action.
switch step.action {
case .run:
Event.post(.testStarted, for: (step.test, nil), configuration: configuration)
shouldSendTestEnded = true
case let .skip(skipInfo):
Event.post(.testSkipped(skipInfo), for: (step.test, nil), configuration: configuration)
shouldSendTestEnded = false
case let .recordIssue(issue):
// Scope posting the issue recorded event such that issue handling
// traits have the opportunity to handle it. This ensures that if a test
// has an issue handling trait _and_ some other trait which caused an
// issue to be recorded, the issue handling trait can process the issue
// even though it wasn't recorded by the test function.
try await Test.withCurrent(step.test) {
try await _applyIssueHandlingTraits(for: step.test) {
// Don't specify `configuration` when posting this issue so that
// traits can provide scope and potentially customize the
// configuration.
Event.post(.issueRecorded(issue), for: (step.test, nil))
}
}
shouldSendTestEnded = false
}
defer {
if shouldSendTestEnded {
Event.post(.testEnded, for: (step.test, nil), configuration: configuration)
}
}

return try await body()
}

/// Run this test.
///
/// - Parameters:
Expand All @@ -193,64 +247,34 @@ extension Runner {
// Exit early if the task has already been cancelled.
try Task.checkCancellation()

// Whether to send a `.testEnded` event at the end of running this step.
// Some steps' actions may not require a final event to be sent — for
// example, a skip event only sends `.testSkipped`.
let shouldSendTestEnded: Bool

let configuration = _configuration

// Determine what action to take for this step.
if let step = stepGraph.value {
let configuration = _configuration
Event.post(.planStepStarted(step), for: (step.test, nil), configuration: configuration)

// Determine what kind of event to send for this step based on its action.
switch step.action {
case .run:
Event.post(.testStarted, for: (step.test, nil), configuration: configuration)
shouldSendTestEnded = true
case let .skip(skipInfo):
Event.post(.testSkipped(skipInfo), for: (step.test, nil), configuration: configuration)
shouldSendTestEnded = false
case let .recordIssue(issue):
// Scope posting the issue recorded event such that issue handling
// traits have the opportunity to handle it. This ensures that if a test
// has an issue handling trait _and_ some other trait which caused an
// issue to be recorded, the issue handling trait can process the issue
// even though it wasn't recorded by the test function.
try await Test.withCurrent(step.test) {
try await _applyIssueHandlingTraits(for: step.test) {
// Don't specify `configuration` when posting this issue so that
// traits can provide scope and potentially customize the
// configuration.
Event.post(.issueRecorded(issue), for: (step.test, nil))
}
}
shouldSendTestEnded = false
}
} else {
shouldSendTestEnded = false
}
defer {
if let step = stepGraph.value {
if shouldSendTestEnded {
Event.post(.testEnded, for: (step.test, nil), configuration: configuration)
}
defer {
Event.post(.planStepEnded(step), for: (step.test, nil), configuration: configuration)
}
}

if let step = stepGraph.value, case .run = step.action {
await Test.withCurrent(step.test) {
_ = await Issue.withErrorRecording(at: step.test.sourceLocation, configuration: configuration) {
try await _applyScopingTraits(for: step.test, testCase: nil) {
// Run the test function at this step (if one is present.)
if let testCases = step.test.testCases {
try await _runTestCases(testCases, within: step)
switch step.action {
case .run:
try await _applyScopingTraits(for: step.test, testCase: nil) {
try await _postingTestStartedAndEndedEvents(for: step, configuration: configuration) {
// Run the test function at this step (if one is present.)
if let testCases = step.test.testCases {
try await _runTestCases(testCases, within: step)
}

// Run the children of this test (i.e. the tests in this suite.)
try await _runChildren(of: stepGraph)
}
}
default:
// Skipping this step or otherwise not running it. Post appropriate
// started/ended events for the test and walk any child nodes.
try await _postingTestStartedAndEndedEvents(for: step, configuration: configuration) {
try await _runChildren(of: stepGraph)
}

// Run the children of this test (i.e. the tests in this suite.)
try await _runChildren(of: stepGraph)
}
}
}
Expand Down
62 changes: 62 additions & 0 deletions Sources/Testing/Running/Serializer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// This source file is part of the Swift.org open source project
//
// Copyright (c) 2025 Apple Inc. and the Swift project authors
// Licensed under Apache License v2.0 with Runtime Library Exception
//
// See https://swift.org/LICENSE.txt for license information
// See https://swift.org/CONTRIBUTORS.txt for Swift project authors
//

/// A type whose instances can run a series of work items in strict order.
///
/// When a work item is scheduled on an instance of this type, it runs after any
/// previously-scheduled work items. If it suspends, subsequently-scheduled work
/// items do not start running; they must wait until the suspended work item
/// either returns or throws an error.
final actor Serializer {
/// The number of scheduled work items, including (possibly) the one currently
/// running.
private var scheduledCount = 0

/// Continuations for any scheduled work items that haven't started yet.
private var continuations = [CheckedContinuation<Void, Never>]()

/// Run a work item serially after any previously-scheduled work items.
///
/// - Parameters:
/// - workItem: A closure to run.
///
/// - Returns: Whatever is returned from `workItem`.
///
/// - Throws: Whatever is thrown by `workItem`.
///
/// - Warning: Calling this function recursively on the same instance of
/// ``Serializer`` will cause a deadlock.
func run<R>(_ workItem: @Sendable () async throws -> R) async rethrows -> R {
scheduledCount += 1
defer {
// Resume the next scheduled closure.
if !continuations.isEmpty {
let continuation = continuations.removeFirst()
continuation.resume()
}

scheduledCount -= 1
}

await withCheckedContinuation { continuation in
if scheduledCount == 1 {
// Nothing else was scheduled, so we can resume immediately.
continuation.resume()
} else {
// Something was scheduled, so add the continuation to the list. When it
// resumes, we can run.
continuations.append(continuation)
}
}

return try await workItem()
}
}

35 changes: 27 additions & 8 deletions Sources/Testing/Support/Environment.swift
Original file line number Diff line number Diff line change
Expand Up @@ -89,24 +89,41 @@ enum Environment {
}()
#endif

/// The address of the environment block, if available.
///
/// The value of this property is always `nil` on Windows and on platforms
/// that do not support environment variables.
static var unsafeAddress: UnsafeMutablePointer<UnsafeMutablePointer<CChar>?>? {
#if SWT_NO_ENVIRONMENT_VARIABLES
nil
#elseif SWT_TARGET_OS_APPLE
_NSGetEnviron()?.pointee
#elseif os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
swt_environ()
#elseif os(WASI)
__wasilibc_get_environ()
#elseif os(Windows)
nil
#else
#warning("Platform-specific implementation missing: environment variables unavailable")
nil
#endif
}

/// Get all environment variables in the current process.
///
/// - Returns: A copy of the current process' environment dictionary.
static func get() -> [String: String] {
#if SWT_NO_ENVIRONMENT_VARIABLES
simulatedEnvironment.rawValue
#elseif SWT_TARGET_OS_APPLE
#if !SWT_NO_DYNAMIC_LINKING
#elseif SWT_TARGET_OS_APPLE || os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android) || os(WASI)
#if SWT_TARGET_OS_APPLE && !SWT_NO_DYNAMIC_LINKING
_environ_lock_np?()
defer {
_environ_unlock_np?()
}
#endif
return _get(fromEnviron: _NSGetEnviron()!.pointee!)
#elseif os(Linux) || os(FreeBSD) || os(OpenBSD) || os(Android)
_get(fromEnviron: swt_environ())
#elseif os(WASI)
_get(fromEnviron: __wasilibc_get_environ())
return _get(fromEnviron: Self.unsafeAddress!)
#elseif os(Windows)
guard let environ = GetEnvironmentStringsW() else {
return [:]
Expand Down Expand Up @@ -153,7 +170,9 @@ enum Environment {
defer {
_environ_unlock_np?()
}
let environ = _NSGetEnviron()!.pointee!
guard let environ = Self.unsafeAddress else {
return nil
}

return name.withCString { name in
for i in 0... {
Expand Down
2 changes: 2 additions & 0 deletions Sources/Testing/Testing.docc/Parallelization.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ accomplished by the testing library using task groups, and tests generally all
run in the same process. The number of tests that run concurrently is controlled
by the Swift runtime.

<!-- TODO: discuss .serialized(for:) -->

## Disabling parallelization

Parallelization can be disabled on a per-function or per-suite basis using the
Expand Down
Loading