Skip to content

Commit f84bfcf

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 105de3b commit f84bfcf

17 files changed

+439
-142
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
@@ -206,6 +206,7 @@ let package = Package(
206206
"SWBCSupport",
207207
"SWBLibc",
208208
.product(name: "ArgumentParser", package: "swift-argument-parser"),
209+
.product(name: "Subprocess", package: "swift-subprocess", condition: .when(platforms: [.android, .custom("freebsd"), .linux, .macOS, .openbsd, .windows])),
209210
.product(name: "SystemPackage", package: "swift-system", condition: .when(platforms: [.linux, .openbsd, .android, .windows, .custom("freebsd")])),
210211
],
211212
exclude: ["CMakeLists.txt"],
@@ -458,6 +459,7 @@ if isStaticBuild {
458459
if useLocalDependencies {
459460
package.dependencies += [
460461
.package(path: "../swift-driver"),
462+
.package(path: "../swift-subprocess"),
461463
.package(path: "../swift-system"),
462464
.package(path: "../swift-argument-parser"),
463465
]
@@ -467,6 +469,7 @@ if useLocalDependencies {
467469
} else {
468470
package.dependencies += [
469471
.package(url: "https://github.com/swiftlang/swift-driver.git", branch: "main"),
472+
.package(url: "https://github.com/swiftlang/swift-subprocess.git", branch: "main"),
470473
.package(url: "https://github.com/apple/swift-system.git", .upToNextMajor(from: "1.5.0")),
471474
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.0.3"),
472475
]

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
@@ -78,6 +78,7 @@ add_library(SWBUtil
7878
POSIX.swift
7979
Process+Async.swift
8080
Process.swift
81+
ProcessController.swift
8182
ProcessInfo.swift
8283
Promise.swift
8384
PropertyList.swift
@@ -114,6 +115,7 @@ target_link_libraries(SWBUtil PUBLIC
114115
SWBCSupport
115116
SWBLibc
116117
ArgumentParser
118+
Subprocess::Subprocess
117119
$<$<NOT:$<PLATFORM_ID:Darwin>>:SwiftSystem::SystemPackage>)
118120

119121
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: 95 additions & 13 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) && (!canImport(Darwin) || os(macOS))
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,30 @@ 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 configuration = try Subprocess.Configuration(
94+
.path(FilePath(url.filePath.str)),
95+
arguments: .init(arguments),
96+
environment: environment.map { .custom(.init($0)) } ?? .inherit,
97+
workingDirectory: (currentDirectoryURL?.filePath.str).map { FilePath($0) } ?? nil,
98+
platformOptions: platformOptions
99+
)
100+
let result = try await Subprocess.run(configuration, body: { execution, inputWriter, outputReader, errorReader in
101+
async let stdoutBytes = outputReader.collect().flatMap { $0.withUnsafeBytes(Array.init) }
102+
async let stderrBytes = errorReader.collect().flatMap { $0.withUnsafeBytes(Array.init) }
103+
try await inputWriter.finish()
104+
return try await (stdoutBytes, stderrBytes)
105+
})
106+
return Processes.ExecutionResult(exitStatus: .init(result.terminationStatus), stdout: Data(result.value.0), stderr: Data(result.value.1))
107+
#else
108+
throw StubError.error("Process spawning is unavailable")
109+
#endif
110+
#else
84111
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
85112
let stdoutPipe = Pipe()
86113
let stderrPipe = Pipe()
@@ -118,9 +145,39 @@ extension Process {
118145
}
119146
return Processes.ExecutionResult(exitStatus: exitStatus, stdout: Data(output.stdoutData), stderr: Data(output.stderrData))
120147
}
148+
#endif
121149
}
122150

123151
public static func getMergedOutput(url: URL, arguments: [String], currentDirectoryURL: URL? = nil, environment: Environment? = nil, interruptible: Bool = true) async throws -> (exitStatus: Processes.ExitStatus, output: Data) {
152+
#if canImport(Subprocess)
153+
#if !canImport(Darwin) || os(macOS)
154+
let (readEnd, writeEnd) = try FileDescriptor.pipe()
155+
return try await readEnd.closeAfter {
156+
// 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).
157+
var platformOptions = PlatformOptions()
158+
if interruptible {
159+
platformOptions.teardownSequence = [.gracefulShutDown(allowedDurationToNextStep: .seconds(5))]
160+
}
161+
let configuration = try Subprocess.Configuration(
162+
.path(FilePath(url.filePath.str)),
163+
arguments: .init(arguments),
164+
environment: environment.map { .custom(.init($0)) } ?? .inherit,
165+
workingDirectory: (currentDirectoryURL?.filePath.str).map { FilePath($0) } ?? nil,
166+
platformOptions: platformOptions
167+
)
168+
let result = try await Subprocess.run(configuration, output: .fileDescriptor(writeEnd, closeAfterSpawningProcess: true), error: .fileDescriptor(writeEnd, closeAfterSpawningProcess: false), body: { execution in
169+
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
170+
try await Array(Data(DispatchFD(fileDescriptor: readEnd).dataStream().collect()))
171+
} else {
172+
try await Array(Data(DispatchFD(fileDescriptor: readEnd)._dataStream().collect()))
173+
}
174+
})
175+
return (.init(result.terminationStatus), Data(result.value))
176+
}
177+
#else
178+
throw StubError.error("Process spawning is unavailable")
179+
#endif
180+
#else
124181
if #available(macOS 15, iOS 18, tvOS 18, watchOS 11, visionOS 2, *) {
125182
let pipe = Pipe()
126183

@@ -150,6 +207,7 @@ extension Process {
150207
}
151208
return (exitStatus: exitStatus, output: Data(output))
152209
}
210+
#endif
153211
}
154212

155213
private static func _getOutput<T, U>(url: URL, arguments: [String], currentDirectoryURL: URL?, environment: Environment?, interruptible: Bool, setup: (Process) -> T, collect: @Sendable (T) async throws -> U) async throws -> (exitStatus: Processes.ExitStatus, output: U) {
@@ -215,9 +273,8 @@ public enum Processes: Sendable {
215273
case exit(_ code: Int32)
216274
case uncaughtSignal(_ signal: Int32)
217275

218-
public init?(rawValue: Int32) {
219-
#if os(Windows)
220-
let dwExitCode = DWORD(bitPattern: rawValue)
276+
#if os(Windows)
277+
public init(dwExitCode: DWORD) {
221278
// Do the same thing as swift-corelibs-foundation (the input value is the GetExitCodeProcess return value)
222279
if (dwExitCode & 0xF0000000) == 0x80000000 // HRESULT
223280
|| (dwExitCode & 0xF0000000) == 0xC0000000 // NTSTATUS
@@ -227,6 +284,12 @@ public enum Processes: Sendable {
227284
} else {
228285
self = .exit(Int32(bitPattern: UInt32(dwExitCode)))
229286
}
287+
}
288+
#endif
289+
290+
public init?(rawValue: Int32) {
291+
#if os(Windows)
292+
self = .init(dwExitCode: DWORD(bitPattern: rawValue))
230293
#else
231294
func WSTOPSIG(_ status: Int32) -> Int32 {
232295
return status >> 8
@@ -306,6 +369,25 @@ public enum Processes: Sendable {
306369
}
307370
}
308371

372+
#if canImport(Subprocess) && (!canImport(Darwin) || os(macOS))
373+
extension Processes.ExitStatus {
374+
init(_ terminationStatus: TerminationStatus) {
375+
switch terminationStatus {
376+
case let .exited(code):
377+
self = .exit(numericCast(code))
378+
case let .unhandledException(code):
379+
#if os(Windows)
380+
// Currently swift-subprocess returns the original raw GetExitCodeProcess value as uncaughtSignal for all values other than zero.
381+
// See also: https://github.com/swiftlang/swift-subprocess/issues/114
382+
self = .init(dwExitCode: code)
383+
#else
384+
self = .uncaughtSignal(code)
385+
#endif
386+
}
387+
}
388+
}
389+
#endif
390+
309391
extension Processes.ExitStatus {
310392
public init(_ process: Process) throws {
311393
assert(!process.isRunning)

0 commit comments

Comments
 (0)