Skip to content

Commit 0a067be

Browse files
committed
Adopt swift-subprocess when running unit tests
1 parent b16bb8f commit 0a067be

File tree

5 files changed

+94
-113
lines changed

5 files changed

+94
-113
lines changed

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ if(FIND_PM_DEPS)
4747
find_package(SwiftCertificates CONFIG REQUIRED)
4848
find_package(SwiftCrypto CONFIG REQUIRED)
4949
find_package(SwiftBuild CONFIG REQUIRED)
50+
find_package(Subprocess CONFIG REQUIRED)
5051
endif()
5152

5253
find_package(dispatch QUIET)

Package.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -575,6 +575,7 @@ let package = Package(
575575
dependencies: [
576576
.product(name: "ArgumentParser", package: "swift-argument-parser"),
577577
.product(name: "OrderedCollections", package: "swift-collections"),
578+
.product(name: "Subprocess", package: "swift-subprocess"),
578579
"Basics",
579580
"BinarySymbols",
580581
"Build",
@@ -1113,6 +1114,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
11131114
.package(url: "https://github.com/apple/swift-system.git", from: "1.1.1"),
11141115
.package(url: "https://github.com/apple/swift-collections.git", "1.0.1" ..< "1.2.0"),
11151116
.package(url: "https://github.com/apple/swift-certificates.git", "1.0.1" ..< "1.6.0"),
1117+
.package(url: "https://github.com/swiftlang/swift-subprocess.git", .upToNextMinor(from: "0.2.0")),
11161118
.package(url: "https://github.com/swiftlang/swift-toolchain-sqlite.git", from: "1.0.0"),
11171119
// For use in previewing documentation
11181120
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0"),
@@ -1131,6 +1133,7 @@ if ProcessInfo.processInfo.environment["SWIFTCI_USE_LOCAL_DEPS"] == nil {
11311133
.package(path: "../swift-system"),
11321134
.package(path: "../swift-collections"),
11331135
.package(path: "../swift-certificates"),
1136+
.package(path: "../swift-subprocess"),
11341137
.package(path: "../swift-toolchain-sqlite"),
11351138
]
11361139
if !swiftDriverDeps.isEmpty {

Sources/Commands/CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ add_library(Commands
6161
target_link_libraries(Commands PUBLIC
6262
SwiftCollections::OrderedCollections
6363
SwiftSyntax::SwiftRefactor
64+
Subprocess::Subprocess
6465
ArgumentParser
6566
Basics
6667
BinarySymbols

Sources/Commands/SwiftTestCommand.swift

Lines changed: 88 additions & 112 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
import ArgumentParser
14+
import Subprocess
15+
#if canImport(System)
16+
import System
17+
#else
18+
import SystemPackage
19+
#endif
1420

1521
@_spi(SwiftPMInternal)
1622
import Basics
@@ -342,7 +348,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
342348
observabilityScope: swiftCommandState.observabilityScope
343349
)
344350

345-
testResults = try runner.run(tests)
351+
testResults = try await runner.run(tests)
346352
result = runner.ranSuccessfully ? .success : .failure
347353
}
348354

@@ -538,7 +544,7 @@ public struct SwiftTestCommand: AsyncSwiftCommand {
538544
)
539545

540546
// Finally, run the tests.
541-
return runner.test(outputHandler: {
547+
return await runner.test(outputHandler: {
542548
// command's result output goes on stdout
543549
// ie "swift test" should output to stdout
544550
print($0, terminator: "")
@@ -837,7 +843,7 @@ extension SwiftTestCommand {
837843
)
838844

839845
// Finally, run the tests.
840-
let result = runner.test(outputHandler: {
846+
let result = await runner.test(outputHandler: {
841847
// command's result output goes on stdout
842848
// ie "swift test" should output to stdout
843849
print($0, terminator: "")
@@ -911,7 +917,7 @@ final class TestRunner {
911917
// The toolchain to use.
912918
private let toolchain: UserToolchain
913919

914-
private let testEnv: Environment
920+
private let testEnv: Basics.Environment
915921

916922
/// ObservabilityScope to emit diagnostics.
917923
private let observabilityScope: ObservabilityScope
@@ -945,7 +951,7 @@ final class TestRunner {
945951
additionalArguments: [String],
946952
cancellator: Cancellator,
947953
toolchain: UserToolchain,
948-
testEnv: Environment,
954+
testEnv: Basics.Environment,
949955
observabilityScope: ObservabilityScope,
950956
library: TestingLibrary
951957
) {
@@ -974,10 +980,10 @@ final class TestRunner {
974980

975981
/// Executes and returns execution status. Prints test output on standard streams if requested
976982
/// - Returns: Result of spawning and running the test process, and the output stream result
977-
func test(outputHandler: @escaping (String) -> Void) -> Result {
983+
func test(outputHandler: @escaping (String) -> Void) async -> Result {
978984
var results = [Result]()
979985
for path in self.bundlePaths {
980-
let testSuccess = self.test(at: path, outputHandler: outputHandler)
986+
let testSuccess = await self.test(at: path, outputHandler: outputHandler)
981987
results.append(testSuccess)
982988
}
983989
return results.reduce()
@@ -1021,33 +1027,36 @@ final class TestRunner {
10211027
return args
10221028
}
10231029

1024-
private func test(at path: AbsolutePath, outputHandler: @escaping (String) -> Void) -> Result {
1030+
private func test(at path: AbsolutePath, outputHandler: @escaping (String) -> Void) async -> Result {
10251031
let testObservabilityScope = self.observabilityScope.makeChildScope(description: "running test at \(path)")
10261032

10271033
do {
1028-
let outputHandler = { (bytes: [UInt8]) in
1029-
if let output = String(bytes: bytes, encoding: .utf8) {
1030-
outputHandler(output)
1031-
}
1034+
let args = try args(forTestAt: path)
1035+
var env: [Subprocess.Environment.Key: String] = [:]
1036+
for (key, value) in self.testEnv {
1037+
env[.init(rawValue: key.rawValue)!] = value
10321038
}
1033-
let outputRedirection = AsyncProcess.OutputRedirection.stream(
1034-
stdout: outputHandler,
1035-
stderr: outputHandler
1036-
)
1037-
let process = AsyncProcess(arguments: try args(forTestAt: path), environment: self.testEnv, outputRedirection: outputRedirection)
1038-
guard let terminationKey = self.cancellator.register(process) else {
1039-
return .failure // terminating
1039+
let processConfig = Subprocess.Configuration(.path(.init(args[0])), arguments: .init(Array(args.dropFirst())), environment: .custom(env))
1040+
let result = try await Subprocess.run(processConfig, input: .none, error: .standardOutput) { execution, outputSequence in
1041+
let token = cancellator.register(name: "Test Execution", handler: { _ in
1042+
await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(5))])
1043+
})
1044+
defer {
1045+
token.map { cancellator.deregister($0) }
1046+
}
1047+
1048+
for try await line in outputSequence.lines() {
1049+
outputHandler(line)
1050+
}
10401051
}
1041-
defer { self.cancellator.deregister(terminationKey) }
1042-
try process.launch()
1043-
let result = try process.waitUntilExit()
1044-
switch result.exitStatus {
1045-
case .terminated(code: 0):
1052+
1053+
switch result.terminationStatus {
1054+
case .exited(code: 0):
10461055
return .success
1047-
case .terminated(code: EXIT_NO_TESTS_FOUND) where library == .swiftTesting:
1056+
case .exited(code: numericCast(EXIT_NO_TESTS_FOUND)) where library == .swiftTesting:
10481057
return .noMatchingTests
10491058
#if !os(Windows)
1050-
case .signalled(let signal) where ![SIGINT, SIGKILL, SIGTERM].contains(signal):
1059+
case .unhandledException(let signal) where ![SIGINT, SIGKILL, SIGTERM].contains(signal):
10511060
testObservabilityScope.emit(error: "Exited with unexpected signal code \(signal)")
10521061
return .failure
10531062
#endif
@@ -1087,21 +1096,9 @@ final class ParallelTestRunner {
10871096
/// Path to XCTest binaries.
10881097
private let bundlePaths: [AbsolutePath]
10891098

1090-
/// The queue containing list of tests to run (producer).
1091-
private let pendingTests = SynchronizedQueue<UnitTest?>()
1092-
1093-
/// The queue containing tests which are finished running.
1094-
private let finishedTests = SynchronizedQueue<TestResult?>()
1095-
10961099
/// Instance of a terminal progress animation.
10971100
private let progressAnimation: ProgressAnimationProtocol
10981101

1099-
/// Number of tests that will be executed.
1100-
private var numTests = 0
1101-
1102-
/// Number of the current tests that has been executed.
1103-
private var numCurrentTest = 0
1104-
11051102
/// True if all tests executed successfully.
11061103
private(set) var ranSuccessfully = true
11071104

@@ -1160,27 +1157,8 @@ final class ParallelTestRunner {
11601157
assert(numJobs > 0, "num jobs should be > 0")
11611158
}
11621159

1163-
/// Updates the progress bar status.
1164-
private func updateProgress(for test: UnitTest) {
1165-
numCurrentTest += 1
1166-
progressAnimation.update(step: numCurrentTest, total: numTests, text: "Testing \(test.specifier)")
1167-
}
1168-
1169-
private func enqueueTests(_ tests: [UnitTest]) throws {
1170-
// Enqueue all the tests.
1171-
for test in tests {
1172-
pendingTests.enqueue(test)
1173-
}
1174-
self.numTests = tests.count
1175-
self.numCurrentTest = 0
1176-
// Enqueue the sentinels, we stop a thread when it encounters a sentinel in the queue.
1177-
for _ in 0..<numJobs {
1178-
pendingTests.enqueue(nil)
1179-
}
1180-
}
1181-
11821160
/// Executes the tests spawning parallel workers. Blocks calling thread until all workers are finished.
1183-
func run(_ tests: [UnitTest]) throws -> [TestResult] {
1161+
func run(_ tests: [UnitTest]) async throws -> [TestResult] {
11841162
assert(!tests.isEmpty, "There should be at least one test to execute.")
11851163

11861164
let testEnv = try TestingSupport.constructTestEnvironment(
@@ -1190,77 +1168,75 @@ final class ParallelTestRunner {
11901168
library: .xctest // swift-testing does not use ParallelTestRunner
11911169
)
11921170

1193-
// Enqueue all the tests.
1194-
try enqueueTests(tests)
1195-
1196-
// Create the worker threads.
1197-
let workers: [Thread] = (0..<numJobs).map({ _ in
1198-
let thread = Thread {
1199-
// Dequeue a specifier and run it till we encounter nil.
1200-
while let test = self.pendingTests.dequeue() {
1201-
let additionalArguments = TestRunner.xctestArguments(forTestSpecifiers: CollectionOfOne(test.specifier))
1202-
let testRunner = TestRunner(
1203-
bundlePaths: [test.productPath],
1204-
additionalArguments: additionalArguments,
1205-
cancellator: self.cancellator,
1206-
toolchain: self.toolchain,
1207-
testEnv: testEnv,
1208-
observabilityScope: self.observabilityScope,
1209-
library: .xctest // swift-testing does not use ParallelTestRunner
1210-
)
1211-
var output = ""
1212-
let outputLock = NSLock()
1213-
let start = DispatchTime.now()
1214-
let result = testRunner.test(outputHandler: { _output in outputLock.withLock{ output += _output }})
1215-
let duration = start.distance(to: .now())
1216-
if result == .failure {
1217-
self.ranSuccessfully = false
1171+
var pendingTests: [UnitTest] = tests
1172+
var processedTests: [TestResult] = []
1173+
observabilityScope.emit(error: "wefhfehjef")
1174+
await withTaskGroup { group in
1175+
func runTest(_ test: UnitTest) async -> TestResult {
1176+
observabilityScope.emit(error: "enqueuing \(test.specifier)")
1177+
let additionalArguments = TestRunner.xctestArguments(forTestSpecifiers: CollectionOfOne(test.specifier))
1178+
let testRunner = TestRunner(
1179+
bundlePaths: [test.productPath],
1180+
additionalArguments: additionalArguments,
1181+
cancellator: self.cancellator,
1182+
toolchain: self.toolchain,
1183+
testEnv: testEnv,
1184+
observabilityScope: self.observabilityScope,
1185+
library: .xctest // swift-testing does not use ParallelTestRunner
1186+
)
1187+
var output = ""
1188+
let outputLock = NSLock()
1189+
let start = DispatchTime.now()
1190+
let result = await testRunner.test(outputHandler: { _output in outputLock.withLock{ output += _output }})
1191+
let duration = start.distance(to: .now())
1192+
if result == .failure {
1193+
self.ranSuccessfully = false
1194+
}
1195+
return TestResult(
1196+
unitTest: test,
1197+
output: output,
1198+
success: result != .failure,
1199+
duration: duration
1200+
)
1201+
}
1202+
for _ in 0..<numJobs {
1203+
if !pendingTests.isEmpty {
1204+
let test = pendingTests.removeLast()
1205+
group.addTask {
1206+
await runTest(test)
12181207
}
1219-
self.finishedTests.enqueue(TestResult(
1220-
unitTest: test,
1221-
output: output,
1222-
success: result != .failure,
1223-
duration: duration
1224-
))
12251208
}
12261209
}
1227-
thread.start()
1228-
return thread
1229-
})
1230-
1231-
// List of processed tests.
1232-
let processedTests = ThreadSafeArrayStore<TestResult>()
1233-
1234-
// Report (consume) the tests which have finished running.
1235-
while let result = finishedTests.dequeue() {
1236-
updateProgress(for: result.unitTest)
1237-
1238-
// Store the result.
1239-
processedTests.append(result)
1210+
var completedTests = 0
1211+
while let result = await group.next() {
1212+
completedTests += 1
1213+
progressAnimation.update(step: completedTests, total: tests.count, text: "Testing \(result.unitTest.specifier)")
1214+
1215+
if !pendingTests.isEmpty {
1216+
let test = pendingTests.removeLast()
1217+
group.addTask(operation: {
1218+
await runTest(test)
1219+
})
1220+
}
12401221

1241-
// We can't enqueue a sentinel into finished tests queue because we won't know
1242-
// which test is last one so exit this when all the tests have finished running.
1243-
if numCurrentTest == numTests {
1244-
break
1222+
// Store the result.
1223+
processedTests.append(result)
12451224
}
12461225
}
12471226

1248-
// Wait till all threads finish execution.
1249-
workers.forEach { $0.join() }
1250-
12511227
// Report the completion.
1252-
progressAnimation.complete(success: processedTests.get().contains(where: { !$0.success }))
1228+
progressAnimation.complete(success: processedTests.contains(where: { !$0.success }))
12531229

12541230
// Print test results.
1255-
for test in processedTests.get() {
1231+
for test in processedTests {
12561232
if (!test.success || shouldOutputSuccess) && !productsBuildParameters.testingParameters.experimentalTestOutput {
12571233
// command's result output goes on stdout
12581234
// ie "swift test" should output to stdout
12591235
print(test.output)
12601236
}
12611237
}
12621238

1263-
return processedTests.get()
1239+
return processedTests
12641240
}
12651241
}
12661242

Sources/Commands/Utilities/PluginDelegate.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -288,7 +288,7 @@ final class PluginDelegate: PluginInvocationDelegate {
288288

289289
// Run the test — for now we run the sequentially so we can capture accurate timing results.
290290
let startTime = DispatchTime.now()
291-
let result = testRunner.test(outputHandler: { _ in }) // this drops the tests output
291+
let result = await testRunner.test(outputHandler: { _ in }) // this drops the tests output
292292
let duration = Double(startTime.distance(to: .now()).milliseconds() ?? 0) / 1000.0
293293
numFailedTests += (result != .failure) ? 0 : 1
294294
testResults.append(

0 commit comments

Comments
 (0)