Skip to content

Commit bbb2de5

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 6988bf1 commit bbb2de5

16 files changed

+323
-109
lines changed

CMakeLists.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,7 @@ add_compile_definitions(USE_STATIC_PLUGIN_INITIALIZATION)
8080

8181
find_package(ArgumentParser)
8282
find_package(LLBuild)
83+
find_package(Subprocess)
8384
find_package(SwiftDriver)
8485
find_package(SwiftSystem)
8586
find_package(TSC)

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+
Subprocess::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: 67 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,23 @@ 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+
var platformOptions = PlatformOptions()
90+
if interruptible {
91+
platformOptions.teardownSequence = [.gracefulShutDown(allowedDurationToNextStep: .seconds(5))]
92+
}
93+
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, platformOptions: platformOptions, body: { execution, inputWriter, outputReader, errorReader in
94+
try await inputWriter.finish()
95+
async let stdoutBytes = outputReader.collect().flatMap { $0.withUnsafeBytes(Array.init) }
96+
async let stderrBytes = errorReader.collect().flatMap { $0.withUnsafeBytes(Array.init) }
97+
return try await (stdoutBytes, stderrBytes)
98+
})
99+
return Processes.ExecutionResult(exitStatus: .init(result.terminationStatus), stdout: Data(result.value.0), stderr: Data(result.value.1))
100+
#else
101+
throw StubError.error("Process spawning is unavailable")
102+
#endif
103+
#else
84104
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
85105
// Extend the lifetime of the pipes to avoid file descriptors being closed until the AsyncStream is finished being consumed.
86106
return try await withExtendedLifetime((Pipe(), Pipe())) { (stdoutPipe, stderrPipe) in
@@ -110,9 +130,32 @@ extension Process {
110130
return Processes.ExecutionResult(exitStatus: exitStatus, stdout: Data(output.stdoutData), stderr: Data(output.stderrData))
111131
}
112132
}
133+
#endif
113134
}
114135

115136
public static func getMergedOutput(url: URL, arguments: [String], currentDirectoryURL: URL? = nil, environment: Environment? = nil, interruptible: Bool = true) async throws -> (exitStatus: Processes.ExitStatus, output: Data) {
137+
#if canImport(Subprocess)
138+
#if !canImport(Darwin) || os(macOS)
139+
let (readEnd, writeEnd) = try FileDescriptor.pipe()
140+
return try await readEnd.closeAfter {
141+
// 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).
142+
var platformOptions = PlatformOptions()
143+
if interruptible {
144+
platformOptions.teardownSequence = [.gracefulShutDown(allowedDurationToNextStep: .seconds(5))]
145+
}
146+
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, platformOptions: platformOptions, output: .fileDescriptor(writeEnd, closeAfterSpawningProcess: true), error: .fileDescriptor(writeEnd, closeAfterSpawningProcess: false), body: { execution in
147+
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
148+
try await Array(Data(DispatchFD(fileDescriptor: readEnd).dataStream().collect()))
149+
} else {
150+
try await Array(Data(DispatchFD(fileDescriptor: readEnd)._dataStream().collect()))
151+
}
152+
})
153+
return (.init(result.terminationStatus), Data(result.value))
154+
}
155+
#else
156+
throw StubError.error("Process spawning is unavailable")
157+
#endif
158+
#else
116159
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
117160
// Extend the lifetime of the pipe to avoid file descriptors being closed until the AsyncStream is finished being consumed.
118161
return try await withExtendedLifetime(Pipe()) { pipe in
@@ -138,6 +181,7 @@ extension Process {
138181
return (exitStatus: exitStatus, output: Data(output))
139182
}
140183
}
184+
#endif
141185
}
142186

143187
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 +338,19 @@ public enum Processes: Sendable {
294338
}
295339
}
296340

341+
#if canImport(Subprocess)
342+
extension Processes.ExitStatus {
343+
init(_ terminationStatus: TerminationStatus) {
344+
switch terminationStatus {
345+
case let .exited(code):
346+
self = .exit(numericCast(code))
347+
case let .unhandledException(code):
348+
self = .uncaughtSignal(numericCast(code))
349+
}
350+
}
351+
}
352+
#endif
353+
297354
extension Processes.ExitStatus {
298355
public init(_ process: Process) throws {
299356
assert(!process.isRunning)

0 commit comments

Comments
 (0)