diff --git a/CMakeLists.txt b/CMakeLists.txt index ed60c850..fe3eef89 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -80,6 +80,7 @@ add_compile_definitions(USE_STATIC_PLUGIN_INITIALIZATION) find_package(ArgumentParser) find_package(LLBuild) +find_package(Subprocess) find_package(SwiftDriver) find_package(SwiftSystem) find_package(TSC) diff --git a/Package.swift b/Package.swift index 42e0d705..9cd085c9 100644 --- a/Package.swift +++ b/Package.swift @@ -201,6 +201,7 @@ let package = Package( "SWBCSupport", "SWBLibc", .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Subprocess", package: "swift-subprocess"), .product(name: "SystemPackage", package: "swift-system", condition: .when(platforms: [.linux, .openbsd, .android, .windows, .custom("freebsd")])), ], exclude: ["CMakeLists.txt"], @@ -447,6 +448,7 @@ for target in package.targets { if useLocalDependencies { package.dependencies += [ .package(path: "../swift-driver"), + .package(path: "../swift-subprocess"), .package(path: "../swift-system"), .package(path: "../swift-argument-parser"), ] @@ -456,6 +458,7 @@ if useLocalDependencies { } else { package.dependencies += [ .package(url: "https://github.com/swiftlang/swift-driver.git", branch: "main"), + .package(url: "https://github.com/swiftlang/swift-subprocess.git", branch: "main"), .package(url: "https://github.com/apple/swift-system.git", .upToNextMajor(from: "1.5.0")), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.3"), ] diff --git a/Sources/SWBCore/ProcessExecutionCache.swift b/Sources/SWBCore/ProcessExecutionCache.swift index 1030561c..20755e23 100644 --- a/Sources/SWBCore/ProcessExecutionCache.swift +++ b/Sources/SWBCore/ProcessExecutionCache.swift @@ -17,11 +17,6 @@ public final class ProcessExecutionCache: Sendable { private let workingDirectory: Path? public init(workingDirectory: Path? = .root) { - // FIXME: Work around lack of thread-safe working directory support in Foundation (Amazon Linux 2, OpenBSD). Executing processes in the current working directory is less deterministic, but all of the clients which use this class are generally not expected to be sensitive to the working directory anyways. This workaround can be removed once we drop support for Amazon Linux 2 and/or adopt swift-subprocess and/or Foundation.Process's working directory support is made thread safe. - if try! Process.hasUnsafeWorkingDirectorySupport { - self.workingDirectory = nil - return - } self.workingDirectory = workingDirectory } diff --git a/Sources/SWBTestSupport/Misc.swift b/Sources/SWBTestSupport/Misc.swift index 9e77ad92..eb0b4d41 100644 --- a/Sources/SWBTestSupport/Misc.swift +++ b/Sources/SWBTestSupport/Misc.swift @@ -75,7 +75,7 @@ package func runProcessWithDeveloperDirectory(_ args: [String], workingDirectory package func runHostProcess(_ args: [String], workingDirectory: Path? = nil, interruptible: Bool = true, redirectStderr: Bool = true) async throws -> String { switch try ProcessInfo.processInfo.hostOperatingSystem() { case .macOS: - return try await InstalledXcode.currentlySelected().xcrun(args, workingDirectory: workingDirectory, redirectStderr: redirectStderr) + return try await InstalledXcode.currentlySelected().xcrun(args, workingDirectory: workingDirectory, interruptible: interruptible, redirectStderr: redirectStderr) default: return try await runProcess(args, workingDirectory: workingDirectory, environment: .current, interruptible: interruptible, redirectStderr: redirectStderr) } diff --git a/Sources/SWBTestSupport/SkippedTestSupport.swift b/Sources/SWBTestSupport/SkippedTestSupport.swift index bef3a66e..6c787240 100644 --- a/Sources/SWBTestSupport/SkippedTestSupport.swift +++ b/Sources/SWBTestSupport/SkippedTestSupport.swift @@ -152,11 +152,6 @@ extension Trait where Self == Testing.ConditionTrait { }) } - /// Constructs a condition trait that causes a test to be disabled if the Foundation process spawning implementation is not thread-safe. - package static var requireThreadSafeWorkingDirectory: Self { - disabled(if: try Process.hasUnsafeWorkingDirectorySupport, "Foundation.Process working directory support is not thread-safe.") - } - /// Constructs a condition trait that causes a test to be disabled if the specified llbuild API version requirement is not met. package static func requireLLBuild(apiVersion version: Int32) -> Self { let llbuildVersion = llb_get_api_version() diff --git a/Sources/SWBTestSupport/Xcode.swift b/Sources/SWBTestSupport/Xcode.swift index 969fc923..bea30914 100644 --- a/Sources/SWBTestSupport/Xcode.swift +++ b/Sources/SWBTestSupport/Xcode.swift @@ -31,8 +31,8 @@ package struct InstalledXcode: Sendable { return try await Path(xcrun(["-f", tool] + toolchainArgs).trimmingCharacters(in: .whitespacesAndNewlines)) } - package func xcrun(_ args: [String], workingDirectory: Path? = nil, redirectStderr: Bool = true) async throws -> String { - return try await runProcessWithDeveloperDirectory(["/usr/bin/xcrun"] + args, workingDirectory: workingDirectory, overrideDeveloperDirectory: self.developerDirPath.str, redirectStderr: redirectStderr) + package func xcrun(_ args: [String], workingDirectory: Path? = nil, interruptible: Bool = true, redirectStderr: Bool = true) async throws -> String { + return try await runProcessWithDeveloperDirectory(["/usr/bin/xcrun"] + args, workingDirectory: workingDirectory, overrideDeveloperDirectory: self.developerDirPath.str, interruptible: interruptible, redirectStderr: redirectStderr) } package func productBuildVersion() throws -> ProductBuildVersion { diff --git a/Sources/SWBUtil/CMakeLists.txt b/Sources/SWBUtil/CMakeLists.txt index 9d2d611d..4cd10dad 100644 --- a/Sources/SWBUtil/CMakeLists.txt +++ b/Sources/SWBUtil/CMakeLists.txt @@ -77,6 +77,7 @@ add_library(SWBUtil POSIX.swift Process+Async.swift Process.swift + ProcessController.swift ProcessInfo.swift Promise.swift PropertyList.swift @@ -113,6 +114,7 @@ target_link_libraries(SWBUtil PUBLIC SWBCSupport SWBLibc ArgumentParser + Subprocess::Subprocess $<$>:SwiftSystem::SystemPackage>) set_target_properties(SWBUtil PROPERTIES diff --git a/Sources/SWBUtil/Process+Async.swift b/Sources/SWBUtil/Process+Async.swift index 93cecc3e..90976001 100644 --- a/Sources/SWBUtil/Process+Async.swift +++ b/Sources/SWBUtil/Process+Async.swift @@ -90,7 +90,7 @@ extension Process { /// - note: This method sets the process's termination handler, if one is set. /// - throws: ``CancellationError`` if the task was cancelled. Applies only when `interruptible` is true. /// - throws: Rethrows the error from ``Process/run`` if the task could not be launched. - public func run(interruptible: Bool = true) async throws { + public func run(interruptible: Bool = true, onStarted: () -> () = { }) async throws { @Sendable func cancelIfRunning() { // Only send the termination signal if the process is already running. // We might have created the termination monitoring continuation at this @@ -115,6 +115,7 @@ extension Process { } try run() + onStarted() } catch { terminationHandler = nil diff --git a/Sources/SWBUtil/Process.swift b/Sources/SWBUtil/Process.swift index 12434bf2..09f3e3f4 100644 --- a/Sources/SWBUtil/Process.swift +++ b/Sources/SWBUtil/Process.swift @@ -11,18 +11,21 @@ //===----------------------------------------------------------------------===// public import Foundation -import SWBLibc +public import SWBLibc +import Synchronization -#if os(Windows) -public typealias pid_t = Int32 +#if canImport(Subprocess) +import Subprocess #endif -#if !canImport(Darwin) -extension ProcessInfo { - public var isMacCatalystApp: Bool { - false - } -} +#if canImport(System) +public import System +#else +public import SystemPackage +#endif + +#if os(Windows) +public typealias pid_t = Int32 #endif #if (!canImport(Foundation.NSTask) || targetEnvironment(macCatalyst)) && canImport(Darwin) @@ -64,7 +67,7 @@ public typealias Process = Foundation.Process #endif extension Process { - public static var hasUnsafeWorkingDirectorySupport: Bool { + fileprivate static var hasUnsafeWorkingDirectorySupport: Bool { get throws { switch try ProcessInfo.processInfo.hostOperatingSystem() { case .linux: @@ -81,6 +84,36 @@ extension Process { extension Process { public static func getOutput(url: URL, arguments: [String], currentDirectoryURL: URL? = nil, environment: Environment? = nil, interruptible: Bool = true) async throws -> Processes.ExecutionResult { + #if canImport(Subprocess) + #if !canImport(Darwin) || os(macOS) + let result = try await Subprocess.run(.path(FilePath(url.filePath.str)), arguments: .init(arguments), environment: environment.map { .custom(.init($0)) } ?? .inherit, workingDirectory: (currentDirectoryURL?.filePath.str).map { FilePath($0) } ?? nil, body: { execution, inputWriter, outputReader, errorReader in + try await inputWriter.finish() + let cancellationPromise = Promise() + return try await withTaskCancellationHandler { + async let cancellationListener: () = { + if await cancellationPromise.value, interruptible { + await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(5))]) + } + }() + async let stdoutBytesAsync = outputReader.collect().flatMap { $0.withUnsafeBytes(Array.init) } + async let stderrBytesAsync = errorReader.collect().flatMap { $0.withUnsafeBytes(Array.init) } + let stdoutBytes = try await stdoutBytesAsync + let stderrBytes = try await stderrBytesAsync + cancellationPromise.fulfill(with: false) + await cancellationListener + if interruptible { + try Task.checkCancellation() + } + return (stdoutBytes, stderrBytes) + } onCancel: { + cancellationPromise.fulfill(with: true) + } + }) + return Processes.ExecutionResult(exitStatus: .init(result.terminationStatus), stdout: Data(result.value.0), stderr: Data(result.value.1)) + #else + throw StubError.error("Process spawning is unavailable") + #endif + #else if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { // Extend the lifetime of the pipes to avoid file descriptors being closed until the AsyncStream is finished being consumed. return try await withExtendedLifetime((Pipe(), Pipe())) { (stdoutPipe, stderrPipe) in @@ -110,9 +143,45 @@ extension Process { return Processes.ExecutionResult(exitStatus: exitStatus, stdout: Data(output.stdoutData), stderr: Data(output.stderrData)) } } + #endif } public static func getMergedOutput(url: URL, arguments: [String], currentDirectoryURL: URL? = nil, environment: Environment? = nil, interruptible: Bool = true) async throws -> (exitStatus: Processes.ExitStatus, output: Data) { + #if canImport(Subprocess) + #if !canImport(Darwin) || os(macOS) + let (readEnd, writeEnd) = try FileDescriptor.pipe() + return try await readEnd.closeAfter { + // Direct both stdout and stderr to the same fd. Only set `closeAfterSpawningProcess` on one of the outputs so it isn't double-closed (similarly avoid using closeAfter for the same reason). + let result = try await Subprocess.run(.path(FilePath(url.filePath.str)), arguments: .init(arguments), environment: environment.map { .custom(.init($0)) } ?? .inherit, workingDirectory: (currentDirectoryURL?.filePath.str).map { FilePath($0) } ?? nil, output: .fileDescriptor(writeEnd, closeAfterSpawningProcess: true), error: .fileDescriptor(writeEnd, closeAfterSpawningProcess: false), body: { execution in + let cancellationPromise = Promise() + return try await withTaskCancellationHandler { + async let cancellationListener: () = { + if await cancellationPromise.value, interruptible { + await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(5))]) + } + }() + let bytes: [UInt8] + if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { + bytes = try await Array(Data(DispatchFD(fileDescriptor: readEnd).dataStream().collect())) + } else { + bytes = try await Array(Data(DispatchFD(fileDescriptor: readEnd)._dataStream().collect())) + } + cancellationPromise.fulfill(with: false) + await cancellationListener + if interruptible { + try Task.checkCancellation() + } + return bytes + } onCancel: { + cancellationPromise.fulfill(with: true) + } + }) + return (.init(result.terminationStatus), Data(result.value)) + } + #else + throw StubError.error("Process spawning is unavailable") + #endif + #else if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) { // Extend the lifetime of the pipe to avoid file descriptors being closed until the AsyncStream is finished being consumed. return try await withExtendedLifetime(Pipe()) { pipe in @@ -138,6 +207,7 @@ extension Process { return (exitStatus: exitStatus, output: Data(output)) } } + #endif } private static func _getOutput(url: URL, arguments: [String], currentDirectoryURL: URL?, environment: Environment?, interruptible: Bool, setup: (Process) -> T, collect: (T) async throws -> U) async throws -> (exitStatus: Processes.ExitStatus, output: U) { @@ -294,6 +364,19 @@ public enum Processes: Sendable { } } +#if canImport(Subprocess) +extension Processes.ExitStatus { + init(_ terminationStatus: TerminationStatus) { + switch terminationStatus { + case let .exited(code): + self = .exit(code) + case let .unhandledException(code): + self = .uncaughtSignal(code) + } + } +} +#endif + extension Processes.ExitStatus { public init(_ process: Process) throws { assert(!process.isRunning) diff --git a/Sources/SWBUtil/ProcessController.swift b/Sources/SWBUtil/ProcessController.swift new file mode 100644 index 00000000..f2a90d01 --- /dev/null +++ b/Sources/SWBUtil/ProcessController.swift @@ -0,0 +1,206 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See http://swift.org/LICENSE.txt for license information +// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +//===----------------------------------------------------------------------===// + +public import Foundation + +#if canImport(Subprocess) +import Subprocess +#endif + +import Synchronization + +#if canImport(System) +public import System +#else +public import SystemPackage +#endif + +public final class ProcessController: Sendable { + public let path: Path + public let arguments: [String] + public let environment: Environment? + public let workingDirectory: Path? + private let state = SWBMutex(.unstarted) + private let done = WaitCondition() + + private enum Command { + case terminate + } + + private struct RunningState { + var task: Task + var commandContinuation: AsyncStream.Continuation + var pid: pid_t? + } + + private enum State { + case unstarted + case running(_ runningState: RunningState) + case exited(exitStatus: Result) + } + + public init(path: Path, arguments: [String], environment: Environment?, workingDirectory: Path?) { + self.path = path + self.arguments = arguments + self.environment = environment + self.workingDirectory = workingDirectory + } + + public func start(input: FileDescriptor, output: FileDescriptor, error: FileDescriptor, highPriority: Bool) { + state.withLock { state in + guard case .unstarted = state else { + fatalError("API misuse: process was already started") + } + + let (commandStream, commandContinuation) = AsyncStream.makeStream(of: Command.self) + + func listenForTermination(of pid: pid_t?, _ body: @Sendable @escaping () async -> ()) { + self.state.withLock { state in + guard case var .running(runningState) = state, runningState.pid == nil else { + preconditionFailure() // unreachable + } + runningState.pid = pid + state = .running(runningState) + } + + Task.detached { + for await command in commandStream { + switch command { + case .terminate: + let shouldTerminate = self.state.withLock { state in + if case let .running(state) = state, state.pid == pid { + return true + } + return false + } + + if shouldTerminate { + await body() + } + } + } + } + } + + let task = Task.detached { [path, arguments, environment, workingDirectory, done] in + defer { done.signal() } + let result = await Result.catching { + defer { commandContinuation.finish() } + #if !canImport(Darwin) || os(macOS) + #if canImport(Subprocess) + var platformOptions = PlatformOptions() + #if os(macOS) + if highPriority { + platformOptions.qualityOfService = .userInitiated + } + #endif + return try await Processes.ExitStatus(Subprocess.run(.path(FilePath(path.str)), arguments: .init(arguments), environment: environment.map { .custom([String: String]($0)) } ?? .inherit, workingDirectory: (workingDirectory?.str).map { FilePath($0) } ?? nil, platformOptions: platformOptions, input: .fileDescriptor(input, closeAfterSpawningProcess: false), output: .fileDescriptor(output, closeAfterSpawningProcess: false), error: .fileDescriptor(error, closeAfterSpawningProcess: false), body: { execution in + listenForTermination(of: execution.processIdentifier.value) { + await execution.teardown(using: [.gracefulShutDown(allowedDurationToNextStep: .seconds(5))]) + } + }).terminationStatus) + #else + let process = Process() + process.executableURL = URL(fileURLWithPath: path.str) + process.arguments = arguments + process.environment = environment.map { .init($0) } ?? nil + if let workingDirectory { + process.currentDirectoryURL = URL(fileURLWithPath: workingDirectory.str) + } + if highPriority { + process.qualityOfService = .userInitiated + } + process.standardInput = FileHandle(fileDescriptor: input.rawValue, closeOnDealloc: false) + process.standardOutput = FileHandle(fileDescriptor: output.rawValue, closeOnDealloc: false) + process.standardError = FileHandle(fileDescriptor: error.rawValue, closeOnDealloc: false) + try await process.run { + listenForTermination(of: process.processIdentifier) { + process.terminate() + } + } + return try Processes.ExitStatus(process) + #endif + #else + throw StubError.error("Process spawning is unavailable") + #endif + } + + self.state.withLock { state in + switch state { + case .unstarted, .running: + state = .exited(exitStatus: result) + case .exited: + preconditionFailure() // unreachable + } + } + } + + state = .running(.init(task: task, commandContinuation: commandContinuation)) + } + } + + public func waitUntilExit() async { + await done.wait() + } + + public func terminate() { + state.withLock { state in + if case let .running(state) = state { + state.commandContinuation.yield(.terminate) + } + } + } + + public var processIdentifier: pid_t? { + get { + state.withLock { state in + switch state { + case .unstarted, .exited: + nil + case let .running(state): + state.pid + } + } + } + } + + public var exitStatus: Processes.ExitStatus? { + get throws { + try state.withLock { state in + switch state { + case .unstarted: + nil + case .running: + nil + case let .exited(exitStatus): + try exitStatus.get() + } + } + } + } +} + +extension RunProcessNonZeroExitError { + public init?(_ process: ProcessController) throws { + self.args = [process.path.str] + process.arguments + self.workingDirectory = process.workingDirectory + self.environment = process.environment ?? .init() + guard let exitStatus = try process.exitStatus else { + return nil + } + self.status = exitStatus + self.output = nil + if self.status.isSuccess { + return nil + } + } +} diff --git a/Sources/SWBUtil/ProcessInfo.swift b/Sources/SWBUtil/ProcessInfo.swift index e96f8631..f66c317d 100644 --- a/Sources/SWBUtil/ProcessInfo.swift +++ b/Sources/SWBUtil/ProcessInfo.swift @@ -125,6 +125,14 @@ extension ProcessInfo { } } +#if !canImport(Darwin) +extension ProcessInfo { + public var isMacCatalystApp: Bool { + false + } +} +#endif + public enum OperatingSystem: Hashable, Sendable { case macOS case iOS(simulator: Bool) diff --git a/Sources/SwiftBuild/SWBBuildServiceConnection.swift b/Sources/SwiftBuild/SWBBuildServiceConnection.swift index 67265583..9a630a83 100644 --- a/Sources/SwiftBuild/SWBBuildServiceConnection.swift +++ b/Sources/SwiftBuild/SWBBuildServiceConnection.swift @@ -850,13 +850,12 @@ fileprivate final class InProcessConnection: ConnectionTransport { #if os(macOS) || targetEnvironment(macCatalyst) || !canImport(Darwin) fileprivate final class OutOfProcessConnection: ConnectionTransport { - private let task: SWBUtil.Process + private let task: ProcessController private let done = WaitCondition() + private let stdinPipe: IOPipe + private let stdoutputPipe: IOPipe init(variant: SWBBuildServiceVariant, serviceBundleURL: URL?, stdinPipe: IOPipe, stdoutPipe: IOPipe) throws { - /// Create and configure an NSTask for launching the Swift Build subprocess. - task = Process() - // Compute the launch path and environment. var updatedEnvironment = ProcessInfo.processInfo.environment // Add the contents of the SWBBuildServiceEnvironmentOverrides user default. @@ -891,32 +890,28 @@ fileprivate final class OutOfProcessConnection: ConnectionTransport { } #endif - task.executableURL = launchURL - task.currentDirectoryURL = launchURL.deletingLastPathComponent() - task.environment = environment - - // Similar to the rationale for giving 'userInitiated' QoS for the 'SWBBuildService.ServiceHostConnection.receiveQueue' queue (see comments for that). - // Start the service subprocess with the max QoS so it is setup to service 'userInitiated' requests if required. - task.qualityOfService = .userInitiated + self.stdinPipe = stdinPipe + self.stdoutputPipe = stdoutPipe - task.standardInput = FileHandle(fileDescriptor: stdinPipe.readEnd.rawValue) - task.standardOutput = FileHandle(fileDescriptor: stdoutPipe.writeEnd.rawValue) + task = try ProcessController( + path: launchURL.filePath, + arguments: [], + environment: .init(environment), + workingDirectory: launchURL.deletingLastPathComponent().filePath) } var state: SWBBuildServiceConnection.State { - if task.isRunning { - return .running - } else { - switch task.terminationReason { + do { + switch try task.exitStatus { case .exit: return .exited case .uncaughtSignal: return .crashed - #if canImport(Foundation.NSTask) || !canImport(Darwin) - @unknown default: - preconditionFailure() - #endif + case nil: + return .running } + } catch { + return .crashed } } @@ -925,10 +920,12 @@ fileprivate final class OutOfProcessConnection: ConnectionTransport { } func start(terminationHandler: (@Sendable ((any Error)?) -> Void)?) throws { - // Install a termination handler that suspends us if we detect the termination of the subprocess. - task.terminationHandler = { [self] task in - defer { done.signal() } + // Similar to the rationale for giving 'userInitiated' QoS for the 'SWBBuildService.ServiceHostConnection.receiveQueue' queue (see comments for that). + // Start the service subprocess with the max QoS so it is setup to service 'userInitiated' requests if required. + task.start(input: stdinPipe.readEnd, output: stdoutputPipe.writeEnd, error: .standardError, highPriority: true) + Task { + await task.waitUntilExit() do { try terminationHandler?(RunProcessNonZeroExitError(task)) } catch { @@ -936,38 +933,27 @@ fileprivate final class OutOfProcessConnection: ConnectionTransport { } } - do { - // Launch the Swift Build subprocess. - try task.run() - } catch { - // terminationHandler isn't going to be called if `run()` throws. - done.signal() - throw error - } - #if os(macOS) - do { - // If IBAutoAttach is enabled, send the message so Xcode will attach to the inferior. - try Debugger.requestXcodeAutoAttachIfEnabled(task.processIdentifier) - } catch { - // Terminate the subprocess if start() is going to throw, so that close() will not get stuck. - task.terminate() + if let processIdentifier = task.processIdentifier { + do { + // If IBAutoAttach is enabled, send the message so Xcode will attach to the inferior. + try Debugger.requestXcodeAutoAttachIfEnabled(processIdentifier) + } catch { + // Terminate the subprocess if start() is going to throw, so that close() will not get stuck. + task.terminate() + } } #endif } func terminate() async { - assert(task.processIdentifier > 0) task.terminate() - await done.wait() - assert(!task.isRunning) + await task.waitUntilExit() } /// Wait for the subprocess to terminate. func close() async { - assert(task.processIdentifier > 0) - await done.wait() - assert(!task.isRunning) + await task.waitUntilExit() } } #endif diff --git a/Tests/SWBCoreTests/ClangSerializedDiagnosticsTests.swift b/Tests/SWBCoreTests/ClangSerializedDiagnosticsTests.swift index 3f37e39e..e9f8eaac 100644 --- a/Tests/SWBCoreTests/ClangSerializedDiagnosticsTests.swift +++ b/Tests/SWBCoreTests/ClangSerializedDiagnosticsTests.swift @@ -28,7 +28,6 @@ fileprivate struct ClangSerializedDiagnosticsTests: CoreBasedTests { } /// Test that Clang serialized diagnostics are supported. - @Test(.requireThreadSafeWorkingDirectory) func clangSerializedDiagnosticSupported() async throws { try await withTemporaryDirectory { tmpDir in let diagnosticsPath = tmpDir.join("foo.diag") @@ -42,7 +41,6 @@ fileprivate struct ClangSerializedDiagnosticsTests: CoreBasedTests { } /// Test that Clang serialized diagnostics handle relative paths. - @Test(.requireThreadSafeWorkingDirectory) func clangSerializedDiagnosticRelativePaths() async throws { try await withTemporaryDirectory { tmpDir in let diagnosticsPath = tmpDir.join("foo.diag") @@ -71,7 +69,6 @@ fileprivate struct ClangSerializedDiagnosticsTests: CoreBasedTests { } /// Test some of the the details serialized diagnostics from Clang. - @Test(.requireThreadSafeWorkingDirectory) func clangSerializedClangDiagnosticClangsDetails() async throws { try await withTemporaryDirectory { tmpDir in let diagnosticsPath = tmpDir.join("foo.diag") @@ -98,7 +95,6 @@ fileprivate struct ClangSerializedDiagnosticsTests: CoreBasedTests { } /// Test some of the the details serialized diagnostics from SwiftC. - @Test(.requireThreadSafeWorkingDirectory) func clangSerializedSwiftDiagnosticsDetails() async throws { try await withTemporaryDirectory { tmpDir in try localFS.createDirectory(tmpDir.join("dir"), recursive: true) diff --git a/Tests/SWBUtilTests/ProcessTests.swift b/Tests/SWBUtilTests/ProcessTests.swift index 9c7e837d..9a960dde 100644 --- a/Tests/SWBUtilTests/ProcessTests.swift +++ b/Tests/SWBUtilTests/ProcessTests.swift @@ -51,7 +51,6 @@ fileprivate struct ProcessTests { } } - @Test(.requireThreadSafeWorkingDirectory) func workingDirectory() async throws { let previous = Path.currentDirectory.str diff --git a/Tests/SwiftBuildTests/ConsoleCommands/CLIConnection.swift b/Tests/SwiftBuildTests/ConsoleCommands/CLIConnection.swift index 7de7fb62..12a0e8f1 100644 --- a/Tests/SwiftBuildTests/ConsoleCommands/CLIConnection.swift +++ b/Tests/SwiftBuildTests/ConsoleCommands/CLIConnection.swift @@ -19,19 +19,19 @@ import SwiftBuild #if os(Windows) import WinSDK +#endif + #if canImport(System) import System #else import SystemPackage #endif -#endif /// Helper class for talking to 'swbuild' the tool final class CLIConnection { - private let task: SWBUtil.Process + private let task: ProcessController private let monitorHandle: FileHandle private let temporaryDirectory: NamedTemporaryDirectory - private let exitPromise: Promise private let outputStream: AsyncThrowingStream private var outputStreamIterator: AsyncCLIConnectionResponseSequence>>.AsyncIterator @@ -122,18 +122,17 @@ final class CLIConnection { let sessionHandle = FileHandle(fileDescriptor: sessionFD, closeOnDealloc: true) // Launch the tool. - task = Process() - task.executableURL = try CLIConnection.swiftbuildToolURL - task.currentDirectoryURL = URL(fileURLWithPath: (currentDirectory ?? temporaryDirectory.path).str) - task.standardInput = sessionHandle - task.standardOutput = sessionHandle - task.standardError = sessionHandle - task.environment = .init(Self.environment) - do { - exitPromise = try task.launch() - } catch { - throw StubError.error("Failed to launch the CLI connection: \(error)") - } + task = try ProcessController( + path: CLIConnection.swiftbuildToolURL.filePath, + arguments: [], + environment: Self.environment, + workingDirectory: currentDirectory ?? temporaryDirectory.path) + + task.start( + input: FileDescriptor(rawValue: sessionFD), + output: FileDescriptor(rawValue: sessionFD), + error: FileDescriptor(rawValue: sessionFD), + highPriority: false) // Close the session handle, so the FD will close once the service stops. try sessionHandle.close() @@ -145,7 +144,7 @@ final class CLIConnection { func shutdown() async { // If the task is still running, ensure orderly shutdown. - if task.isRunning { + if (try? task.exitStatus) == nil { try? send(command: "quit") _ = try? await getResponse() _ = try? await exitStatus @@ -162,7 +161,8 @@ final class CLIConnection { try Self.terminate(processIdentifier: processIdentifier) } - static func terminate(processIdentifier: Int32) throws { + static func terminate(processIdentifier: Int32?) throws { + guard let processIdentifier else { return } #if os(Windows) guard let proc = OpenProcess(DWORD(PROCESS_TERMINATE), false, DWORD(processIdentifier)) else { throw Win32Error(GetLastError()) @@ -193,13 +193,17 @@ final class CLIConnection { try await outputStreamIterator.next() ?? "" } - var processIdentifier: Int32 { + var processIdentifier: Int32? { task.processIdentifier } var exitStatus: Processes.ExitStatus { get async throws { - try await exitPromise.value + await task.waitUntilExit() + guard let exitStatus = try task.exitStatus else { + throw StubError.error("Task is still running") + } + return exitStatus } } } diff --git a/Tests/SwiftBuildTests/ConsoleCommands/ServiceConsoleTests.swift b/Tests/SwiftBuildTests/ConsoleCommands/ServiceConsoleTests.swift index 26985a79..c848f0ed 100644 --- a/Tests/SwiftBuildTests/ConsoleCommands/ServiceConsoleTests.swift +++ b/Tests/SwiftBuildTests/ConsoleCommands/ServiceConsoleTests.swift @@ -24,24 +24,14 @@ fileprivate struct ServiceConsoleTests { @Test func emptyInput() async throws { // Test against a non-pty. - let task = SWBUtil.Process() - task.executableURL = try CLIConnection.swiftbuildToolURL - task.environment = .init(CLIConnection.environment) + let result = try await Process.getOutput(url: CLIConnection.swiftbuildToolURL, arguments: [], environment: CLIConnection.environment) + let output = String(decoding: result.stdout, as: UTF8.self) - task.standardInput = FileHandle.nullDevice - try await withExtendedLifetime(Pipe()) { outputPipe in - let standardOutput = task._makeStream(for: \.standardOutputPipe, using: outputPipe) - let promise: Promise = try task.launch() - - let data = try await standardOutput.reduce(into: [], { $0.append(contentsOf: $1) }) - let output = String(decoding: data, as: UTF8.self) - - // Verify there were no errors. - #expect(output == "swbuild> \(String.newline)") + // Verify there were no errors. + #expect(output == "swbuild> \(String.newline)") - // Assert the tool exited successfully. - await #expect(try promise.value == .exit(0)) - } + // Assert the tool exited successfully. + #expect(try result.exitStatus == .exit(0)) } @Test