Files
luma/Sources/LumaCore/ChildProcess.swift
Ole André Vadla Ravnås 1bf6dee728 Keep ChildProcess to the platforms that spawn
Process is not in the iOS SDK, and the alias stood outside any guard
-- so a phone build reached for a type that is not there. Say where it
belongs, the way everything that uses it already does.
2026-09-15 17:58:28 +02:00

215 lines
7.4 KiB
Swift

import Foundation
#if os(Windows)
import WinSDK
public final class ChildProcess: @unchecked Sendable {
public var executableURL: URL?
public var arguments: [String]?
public var environment: [String: String]?
public var currentDirectoryURL: URL?
public var standardInput: Any?
public var standardOutput: Any?
public var standardError: Any?
public var terminationHandler: ((ChildProcess) -> Void)?
public private(set) var processIdentifier: Int32 = 0
private let state = NSLock()
private var process: HANDLE?
private var exitCode: DWORD = 0
public init() {}
public var isRunning: Bool {
guard let process = liveProcess else { return false }
return WaitForSingleObject(process, 0) == DWORD(WAIT_TIMEOUT)
}
public var terminationStatus: Int32 {
state.withLock { Int32(bitPattern: exitCode) }
}
public func run() throws {
guard let executableURL else {
throw ChildProcessError.spawnFailed(reason: "no executable was named")
}
var opened: [HANDLE] = []
var startup = STARTUPINFOW()
startup.cb = DWORD(MemoryLayout<STARTUPINFOW>.size)
startup.dwFlags = DWORD(STARTF_USESTDHANDLES)
startup.hStdInput = try inherited(standardInput, writing: false, opened: &opened)
startup.hStdOutput = try inherited(standardOutput, writing: true, opened: &opened)
startup.hStdError = try inherited(standardError, writing: true, opened: &opened)
defer { opened.forEach { CloseHandle($0) } }
var spawned = PROCESS_INFORMATION()
let commandLine = Self.quoted([executableURL.path] + (arguments ?? []))
let block = Self.environmentBlock(environment ?? ProcessInfo.processInfo.environment)
let directory = currentDirectoryURL?.path ?? FileManager.default.currentDirectoryPath
let created = commandLine.withCString(encodedAs: UTF16.self) { line in
block.withCString(encodedAs: UTF16.self) { environment in
directory.withCString(encodedAs: UTF16.self) { directory in
CreateProcessW(
nil,
UnsafeMutablePointer(mutating: line),
nil,
nil,
true,
DWORD(CREATE_NO_WINDOW) | DWORD(CREATE_UNICODE_ENVIRONMENT),
UnsafeMutableRawPointer(mutating: environment),
directory,
&startup,
&spawned
)
}
}
}
guard created else {
throw ChildProcessError.spawnFailed(reason: "CreateProcessW answered \(GetLastError())")
}
CloseHandle(spawned.hThread)
processIdentifier = Int32(bitPattern: spawned.dwProcessId)
state.withLock { process = spawned.hProcess }
handOver(standardInput, writing: false)
handOver(standardOutput, writing: true)
handOver(standardError, writing: true)
watchForExit()
}
public func waitUntilExit() {
guard let process = liveProcess else { return }
WaitForSingleObject(process, INFINITE)
readExitCode(of: process)
}
public func terminate() {
guard let process = liveProcess else { return }
TerminateProcess(process, UINT(1))
}
private func watchForExit() {
let watcher = Thread { [self] in
guard let process = liveProcess else { return }
WaitForSingleObject(process, INFINITE)
readExitCode(of: process)
terminationHandler?(self)
}
watcher.name = "ChildProcess \(processIdentifier)"
watcher.start()
}
private func readExitCode(of process: HANDLE) {
var code: DWORD = 0
GetExitCodeProcess(process, &code)
state.withLock { exitCode = code }
}
private var liveProcess: HANDLE? {
state.withLock { process }
}
private func inherited(_ stream: Any?, writing: Bool, opened: inout [HANDLE]) throws -> HANDLE? {
guard let stream else {
let device = try Self.nullDevice(writing: writing)
opened.append(device)
return device
}
let end: FileHandle
switch stream {
case let pipe as Pipe:
end = writing ? pipe.fileHandleForWriting : pipe.fileHandleForReading
case let handle as FileHandle:
end = handle
default:
throw ChildProcessError.spawnFailed(reason: "a stream was neither a pipe nor a file handle")
}
SetHandleInformation(end._handle, DWORD(HANDLE_FLAG_INHERIT), DWORD(HANDLE_FLAG_INHERIT))
return end._handle
}
private func handOver(_ stream: Any?, writing: Bool) {
guard let pipe = stream as? Pipe else { return }
try? (writing ? pipe.fileHandleForWriting : pipe.fileHandleForReading).close()
}
private static func nullDevice(writing: Bool) throws -> HANDLE {
var inheritable = SECURITY_ATTRIBUTES()
inheritable.nLength = DWORD(MemoryLayout<SECURITY_ATTRIBUTES>.size)
inheritable.bInheritHandle = true
let device = "NUL".withCString(encodedAs: UTF16.self) { name in
CreateFileW(
name,
writing ? DWORD(GENERIC_WRITE) : DWORD(GENERIC_READ),
DWORD(FILE_SHARE_READ) | DWORD(FILE_SHARE_WRITE),
&inheritable,
DWORD(OPEN_EXISTING),
DWORD(FILE_ATTRIBUTE_NORMAL),
nil
)
}
guard let device, device != INVALID_HANDLE_VALUE else {
throw ChildProcessError.spawnFailed(reason: "NUL could not be opened")
}
return device
}
private static func environmentBlock(_ environment: [String: String]) -> String {
environment.sorted { $0.key.caseInsensitiveCompare($1.key) == .orderedAscending }
.map { "\($0.key)=\($0.value)\0" }.joined()
}
private static func quoted(_ commandLine: [String]) -> String {
commandLine.map(quoted).joined(separator: " ")
}
private static func quoted(_ argument: String) -> String {
guard argument.contains(where: { " \t\n\"".contains($0) }) else { return argument }
var quoted = "\""
var rest = argument.unicodeScalars
while !rest.isEmpty {
guard let firstNonBackslash = rest.firstIndex(where: { $0 != "\\" }) else {
quoted.append(String(repeating: "\\", count: rest.count * 2))
break
}
let backslashes = rest.distance(from: rest.startIndex, to: firstNonBackslash)
if rest[firstNonBackslash] == "\"" {
quoted.append(String(repeating: "\\", count: backslashes * 2 + 1))
} else {
quoted.append(String(repeating: "\\", count: backslashes))
}
quoted.append(String(rest[firstNonBackslash]))
rest.removeFirst(backslashes + 1)
}
quoted.append("\"")
return quoted
}
}
public enum ChildProcessError: Swift.Error, LocalizedError {
case spawnFailed(reason: String)
public var errorDescription: String? {
switch self {
case .spawnFailed(let reason):
return "Unable to start the process: \(reason)"
}
}
}
#elseif os(macOS) || os(Linux)
public typealias ChildProcess = Process
#endif