Skip to content

Commit b686e55

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 b686e55

File tree

14 files changed

+365
-106
lines changed

14 files changed

+365
-106
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/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/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: 88 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,32 @@ 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 {
95+
try execution.send(signal: .terminate)
96+
}
97+
}()
98+
async let stdoutBytes = outputReader.collect().flatMap { $0.withUnsafeBytes(Array.init) }
99+
async let stderrBytes = errorReader.collect().flatMap { $0.withUnsafeBytes(Array.init) }
100+
cancellationPromise.fulfill(with: true)
101+
try await cancellationListener
102+
try Task.checkCancellation()
103+
return try await (stdoutBytes, stderrBytes)
104+
} onCancel: {
105+
cancellationPromise.fulfill(with: true)
106+
}
107+
})
108+
return Processes.ExecutionResult(exitStatus: .init(result.terminationStatus), stdout: Data(result.value.0), stderr: Data(result.value.1))
109+
#else
110+
throw StubError.error("Process spawning is unavailable")
111+
#endif
112+
#else
84113
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
85114
// Extend the lifetime of the pipes to avoid file descriptors being closed until the AsyncStream is finished being consumed.
86115
return try await withExtendedLifetime((Pipe(), Pipe())) { (stdoutPipe, stderrPipe) in
@@ -110,9 +139,44 @@ extension Process {
110139
return Processes.ExecutionResult(exitStatus: exitStatus, stdout: Data(output.stdoutData), stderr: Data(output.stderrData))
111140
}
112141
}
142+
#endif
113143
}
114144

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

143208
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 +359,19 @@ public enum Processes: Sendable {
294359
}
295360
}
296361

362+
#if canImport(Subprocess)
363+
extension Processes.ExitStatus {
364+
init(_ terminationStatus: TerminationStatus) {
365+
switch terminationStatus {
366+
case let .exited(code):
367+
self = .exit(code)
368+
case let .unhandledException(code):
369+
self = .uncaughtSignal(code)
370+
}
371+
}
372+
}
373+
#endif
374+
297375
extension Processes.ExitStatus {
298376
public init(_ process: Process) throws {
299377
assert(!process.isRunning)

0 commit comments

Comments
 (0)