Skip to content

Commit 9cf96d5

Browse files
committed
Conditionally adopt swift-subprocess
This fully resolves working directory thread safety issues with subprocess spawning across all platforms. For now, subprocess is adopted conditionally in order to continue building in certain environments where the Subprocess module may not be available, in which case we fall back to Foundation Process. Closes #441
1 parent 62bd4c6 commit 9cf96d5

16 files changed

+373
-109
lines changed

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ add_compile_definitions(USE_STATIC_PLUGIN_INITIALIZATION)
8181
find_package(ArgumentParser)
8282
find_package(LLBuild)
8383
find_package(SwiftDriver)
84+
find_package(SwiftSubprocess)
8485
find_package(SwiftSystem)
8586
find_package(TSC)
8687
# NOTE: these two are required for LLBuild dependencies

Package.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,7 @@ let package = Package(
201201
"SWBCSupport",
202202
"SWBLibc",
203203
.product(name: "ArgumentParser", package: "swift-argument-parser"),
204+
.product(name: "Subprocess", package: "swift-subprocess"),
204205
.product(name: "SystemPackage", package: "swift-system", condition: .when(platforms: [.linux, .openbsd, .android, .windows, .custom("freebsd")])),
205206
],
206207
exclude: ["CMakeLists.txt"],
@@ -447,6 +448,7 @@ for target in package.targets {
447448
if useLocalDependencies {
448449
package.dependencies += [
449450
.package(path: "../swift-driver"),
451+
.package(path: "../swift-subprocess"),
450452
.package(path: "../swift-system"),
451453
.package(path: "../swift-argument-parser"),
452454
]
@@ -456,6 +458,7 @@ if useLocalDependencies {
456458
} else {
457459
package.dependencies += [
458460
.package(url: "https://github.com/swiftlang/swift-driver.git", branch: "main"),
461+
.package(url: "https://github.com/swiftlang/swift-subprocess.git", branch: "main"),
459462
.package(url: "https://github.com/apple/swift-system.git", .upToNextMajor(from: "1.5.0")),
460463
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.3"),
461464
]

Sources/SWBCore/ProcessExecutionCache.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,11 +17,6 @@ public final class ProcessExecutionCache: Sendable {
1717
private let workingDirectory: Path?
1818

1919
public init(workingDirectory: Path? = .root) {
20-
// 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.
21-
if try! Process.hasUnsafeWorkingDirectorySupport {
22-
self.workingDirectory = nil
23-
return
24-
}
2520
self.workingDirectory = workingDirectory
2621
}
2722

Sources/SWBTestSupport/Misc.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ package func runProcessWithDeveloperDirectory(_ args: [String], workingDirectory
7575
package func runHostProcess(_ args: [String], workingDirectory: Path? = nil, interruptible: Bool = true, redirectStderr: Bool = true) async throws -> String {
7676
switch try ProcessInfo.processInfo.hostOperatingSystem() {
7777
case .macOS:
78-
return try await InstalledXcode.currentlySelected().xcrun(args, workingDirectory: workingDirectory, redirectStderr: redirectStderr)
78+
return try await InstalledXcode.currentlySelected().xcrun(args, workingDirectory: workingDirectory, interruptible: interruptible, redirectStderr: redirectStderr)
7979
default:
8080
return try await runProcess(args, workingDirectory: workingDirectory, environment: .current, interruptible: interruptible, redirectStderr: redirectStderr)
8181
}

Sources/SWBTestSupport/SkippedTestSupport.swift

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -152,11 +152,6 @@ extension Trait where Self == Testing.ConditionTrait {
152152
})
153153
}
154154

155-
/// Constructs a condition trait that causes a test to be disabled if the Foundation process spawning implementation is not thread-safe.
156-
package static var requireThreadSafeWorkingDirectory: Self {
157-
disabled(if: try Process.hasUnsafeWorkingDirectorySupport, "Foundation.Process working directory support is not thread-safe.")
158-
}
159-
160155
/// Constructs a condition trait that causes a test to be disabled if the specified llbuild API version requirement is not met.
161156
package static func requireLLBuild(apiVersion version: Int32) -> Self {
162157
let llbuildVersion = llb_get_api_version()

Sources/SWBTestSupport/Xcode.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,8 +31,8 @@ package struct InstalledXcode: Sendable {
3131
return try await Path(xcrun(["-f", tool] + toolchainArgs).trimmingCharacters(in: .whitespacesAndNewlines))
3232
}
3333

34-
package func xcrun(_ args: [String], workingDirectory: Path? = nil, redirectStderr: Bool = true) async throws -> String {
35-
return try await runProcessWithDeveloperDirectory(["/usr/bin/xcrun"] + args, workingDirectory: workingDirectory, overrideDeveloperDirectory: self.developerDirPath.str, redirectStderr: redirectStderr)
34+
package func xcrun(_ args: [String], workingDirectory: Path? = nil, interruptible: Bool = true, redirectStderr: Bool = true) async throws -> String {
35+
return try await runProcessWithDeveloperDirectory(["/usr/bin/xcrun"] + args, workingDirectory: workingDirectory, overrideDeveloperDirectory: self.developerDirPath.str, interruptible: interruptible, redirectStderr: redirectStderr)
3636
}
3737

3838
package func productBuildVersion() throws -> ProductBuildVersion {

Sources/SWBUtil/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ add_library(SWBUtil
7777
POSIX.swift
7878
Process+Async.swift
7979
Process.swift
80+
ProcessController.swift
8081
ProcessInfo.swift
8182
Promise.swift
8283
PropertyList.swift
@@ -113,6 +114,7 @@ target_link_libraries(SWBUtil PUBLIC
113114
SWBCSupport
114115
SWBLibc
115116
ArgumentParser
117+
SwiftSubprocess::Subprocess
116118
$<$<NOT:$<PLATFORM_ID:Darwin>>:SwiftSystem::SystemPackage>)
117119

118120
set_target_properties(SWBUtil PROPERTIES

Sources/SWBUtil/Process+Async.swift

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,7 @@ extension Process {
9090
/// - note: This method sets the process's termination handler, if one is set.
9191
/// - throws: ``CancellationError`` if the task was cancelled. Applies only when `interruptible` is true.
9292
/// - throws: Rethrows the error from ``Process/run`` if the task could not be launched.
93-
public func run(interruptible: Bool = true) async throws {
93+
public func run(interruptible: Bool = true, onStarted: () -> () = { }) async throws {
9494
@Sendable func cancelIfRunning() {
9595
// Only send the termination signal if the process is already running.
9696
// We might have created the termination monitoring continuation at this
@@ -115,6 +115,7 @@ extension Process {
115115
}
116116

117117
try run()
118+
onStarted()
118119
} catch {
119120
terminationHandler = nil
120121

Sources/SWBUtil/Process.swift

Lines changed: 93 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -11,18 +11,21 @@
1111
//===----------------------------------------------------------------------===//
1212

1313
public import Foundation
14-
import SWBLibc
14+
public import SWBLibc
15+
import Synchronization
1516

16-
#if os(Windows)
17-
public typealias pid_t = Int32
17+
#if canImport(Subprocess)
18+
import Subprocess
1819
#endif
1920

20-
#if !canImport(Darwin)
21-
extension ProcessInfo {
22-
public var isMacCatalystApp: Bool {
23-
false
24-
}
25-
}
21+
#if canImport(System)
22+
public import System
23+
#else
24+
public import SystemPackage
25+
#endif
26+
27+
#if os(Windows)
28+
public typealias pid_t = Int32
2629
#endif
2730

2831
#if (!canImport(Foundation.NSTask) || targetEnvironment(macCatalyst)) && canImport(Darwin)
@@ -64,7 +67,7 @@ public typealias Process = Foundation.Process
6467
#endif
6568

6669
extension Process {
67-
public static var hasUnsafeWorkingDirectorySupport: Bool {
70+
fileprivate static var hasUnsafeWorkingDirectorySupport: Bool {
6871
get throws {
6972
switch try ProcessInfo.processInfo.hostOperatingSystem() {
7073
case .linux:
@@ -81,6 +84,36 @@ extension Process {
8184

8285
extension Process {
8386
public static func getOutput(url: URL, arguments: [String], currentDirectoryURL: URL? = nil, environment: Environment? = nil, interruptible: Bool = true) async throws -> Processes.ExecutionResult {
87+
#if canImport(Subprocess)
88+
#if !canImport(Darwin) || os(macOS)
89+
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
90+
try await inputWriter.finish()
91+
let cancellationPromise = Promise<Bool, Never>()
92+
return try await withTaskCancellationHandler {
93+
async let cancellationListener: () = {
94+
if await cancellationPromise.value, interruptible {
95+
try execution.send(signal: .terminate)
96+
}
97+
}()
98+
async let stdoutBytesAsync = outputReader.collect().flatMap { $0.withUnsafeBytes(Array.init) }
99+
async let stderrBytesAsync = errorReader.collect().flatMap { $0.withUnsafeBytes(Array.init) }
100+
let stdoutBytes = try await stdoutBytesAsync
101+
let stderrBytes = try await stderrBytesAsync
102+
cancellationPromise.fulfill(with: false)
103+
try await cancellationListener
104+
if interruptible {
105+
try Task.checkCancellation()
106+
}
107+
return (stdoutBytes, stderrBytes)
108+
} onCancel: {
109+
cancellationPromise.fulfill(with: true)
110+
}
111+
})
112+
return Processes.ExecutionResult(exitStatus: .init(result.terminationStatus), stdout: Data(result.value.0), stderr: Data(result.value.1))
113+
#else
114+
throw StubError.error("Process spawning is unavailable")
115+
#endif
116+
#else
84117
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
85118
// Extend the lifetime of the pipes to avoid file descriptors being closed until the AsyncStream is finished being consumed.
86119
return try await withExtendedLifetime((Pipe(), Pipe())) { (stdoutPipe, stderrPipe) in
@@ -110,9 +143,45 @@ extension Process {
110143
return Processes.ExecutionResult(exitStatus: exitStatus, stdout: Data(output.stdoutData), stderr: Data(output.stderrData))
111144
}
112145
}
146+
#endif
113147
}
114148

115149
public static func getMergedOutput(url: URL, arguments: [String], currentDirectoryURL: URL? = nil, environment: Environment? = nil, interruptible: Bool = true) async throws -> (exitStatus: Processes.ExitStatus, output: Data) {
150+
#if canImport(Subprocess)
151+
#if !canImport(Darwin) || os(macOS)
152+
let (readEnd, writeEnd) = try FileDescriptor.pipe()
153+
return try await readEnd.closeAfter {
154+
// 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).
155+
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
156+
let cancellationPromise = Promise<Bool, Never>()
157+
return try await withTaskCancellationHandler {
158+
async let cancellationListener: () = {
159+
if await cancellationPromise.value, interruptible {
160+
try execution.send(signal: .terminate)
161+
}
162+
}()
163+
let bytes: [UInt8]
164+
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
165+
bytes = try await Array(Data(DispatchFD(fileDescriptor: readEnd).dataStream().collect()))
166+
} else {
167+
bytes = try await Array(Data(DispatchFD(fileDescriptor: readEnd)._dataStream().collect()))
168+
}
169+
cancellationPromise.fulfill(with: false)
170+
try await cancellationListener
171+
if interruptible {
172+
try Task.checkCancellation()
173+
}
174+
return bytes
175+
} onCancel: {
176+
cancellationPromise.fulfill(with: true)
177+
}
178+
})
179+
return (.init(result.terminationStatus), Data(result.value))
180+
}
181+
#else
182+
throw StubError.error("Process spawning is unavailable")
183+
#endif
184+
#else
116185
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
117186
// Extend the lifetime of the pipe to avoid file descriptors being closed until the AsyncStream is finished being consumed.
118187
return try await withExtendedLifetime(Pipe()) { pipe in
@@ -138,6 +207,7 @@ extension Process {
138207
return (exitStatus: exitStatus, output: Data(output))
139208
}
140209
}
210+
#endif
141211
}
142212

143213
private static func _getOutput<T, U>(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 {
294364
}
295365
}
296366

367+
#if canImport(Subprocess)
368+
extension Processes.ExitStatus {
369+
init(_ terminationStatus: TerminationStatus) {
370+
switch terminationStatus {
371+
case let .exited(code):
372+
self = .exit(code)
373+
case let .unhandledException(code):
374+
self = .uncaughtSignal(code)
375+
}
376+
}
377+
}
378+
#endif
379+
297380
extension Processes.ExitStatus {
298381
public init(_ process: Process) throws {
299382
assert(!process.isRunning)

0 commit comments

Comments
 (0)