From e0559da10ab74c71905a5dea7889f41bb0d1734a Mon Sep 17 00:00:00 2001 From: Jake Petroules Date: Sun, 29 Jun 2025 15:08:52 -0700 Subject: [PATCH] Introduce PlatformHandles API Imbue Execution with a new PlatformHandles structure which provides access to platform-specific handles used to control the process while it is running. This is necessary for interop with certain platform-native APIs, especially on Windows. One test has been introduced which shows how this API could be used to associate a subprocess with a Job Object using the Windows API. In future, the idea is that PlatformHandles could store a process descriptor on Linux (clone3(..., CLONE_PIDFD) / pidfd_open) and FreeBSD (pdfork), or other platforms which may have or introduce similar concepts in the future. --- Sources/Subprocess/Execution.swift | 27 +++----------- .../Platforms/Subprocess+Darwin.swift | 11 +++++- .../Platforms/Subprocess+Linux.swift | 11 +++++- .../Platforms/Subprocess+Windows.swift | 36 ++++++++++++++----- .../SubprocessTests+Windows.swift | 30 ++++++++++++++++ 5 files changed, 82 insertions(+), 33 deletions(-) diff --git a/Sources/Subprocess/Execution.swift b/Sources/Subprocess/Execution.swift index a21a170..ded276f 100644 --- a/Sources/Subprocess/Execution.swift +++ b/Sources/Subprocess/Execution.swift @@ -34,36 +34,19 @@ public struct Execution: Sendable { /// The process identifier of the current execution public let processIdentifier: ProcessIdentifier - #if os(Windows) - internal nonisolated(unsafe) let processInformation: PROCESS_INFORMATION - internal let consoleBehavior: PlatformOptions.ConsoleBehavior + /// The collection of platform-specific handles of the current execution. + public let platformHandles: PlatformHandles init( processIdentifier: ProcessIdentifier, - processInformation: PROCESS_INFORMATION, - consoleBehavior: PlatformOptions.ConsoleBehavior + platformHandles: PlatformHandles ) { self.processIdentifier = processIdentifier - self.processInformation = processInformation - self.consoleBehavior = consoleBehavior + self.platformHandles = platformHandles } - #else - init( - processIdentifier: ProcessIdentifier - ) { - self.processIdentifier = processIdentifier - } - #endif // os(Windows) internal func release() { - #if os(Windows) - guard CloseHandle(processInformation.hThread) else { - fatalError("Failed to close thread HANDLE: \(SubprocessError.UnderlyingError(rawValue: GetLastError()))") - } - guard CloseHandle(processInformation.hProcess) else { - fatalError("Failed to close process HANDLE: \(SubprocessError.UnderlyingError(rawValue: GetLastError()))") - } - #endif + platformHandles.release() } } diff --git a/Sources/Subprocess/Platforms/Subprocess+Darwin.swift b/Sources/Subprocess/Platforms/Subprocess+Darwin.swift index 7cd32c7..090589b 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Darwin.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Darwin.swift @@ -33,6 +33,14 @@ import FoundationEssentials #endif // SubprocessFoundation +// MARK: - PlatformExecution + +/// The collection of platform-specific handles used to control the subprocess when running. +public struct PlatformHandles: Sendable { + public init() {} + internal func release() {} +} + // MARK: - PlatformOptions /// The collection of platform-specific settings @@ -438,7 +446,8 @@ extension Configuration { ) let execution = Execution( - processIdentifier: .init(value: pid) + processIdentifier: .init(value: pid), + platformHandles: .init() ) return SpawnResult( execution: execution, diff --git a/Sources/Subprocess/Platforms/Subprocess+Linux.swift b/Sources/Subprocess/Platforms/Subprocess+Linux.swift index bf1e5b8..b754e96 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Linux.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Linux.swift @@ -150,7 +150,8 @@ extension Configuration { ) let execution = Execution( - processIdentifier: .init(value: pid) + processIdentifier: .init(value: pid), + platformHandles: .init() ) return SpawnResult( execution: execution, @@ -189,6 +190,14 @@ extension Configuration { } } +// MARK: - PlatformExecution + +/// The collection of platform-specific handles used to control the subprocess when running. +public struct PlatformHandles: Sendable { + public init() {} + internal func release() {} +} + // MARK: - Platform Specific Options /// The collection of platform-specific settings diff --git a/Sources/Subprocess/Platforms/Subprocess+Windows.swift b/Sources/Subprocess/Platforms/Subprocess+Windows.swift index 3dfb00c..ca625e5 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -144,8 +144,7 @@ extension Configuration { ) let execution = Execution( processIdentifier: pid, - processInformation: processInfo, - consoleBehavior: self.platformOptions.consoleBehavior + platformHandles: .init(processInformation: processInfo) ) do { @@ -288,8 +287,7 @@ extension Configuration { ) let execution = Execution( processIdentifier: pid, - processInformation: processInfo, - consoleBehavior: self.platformOptions.consoleBehavior + platformHandles: .init(processInformation: processInfo) ) do { @@ -318,6 +316,26 @@ extension Configuration { } } +// MARK: - PlatformExecution + +/// The collection of platform-specific handles used to control the subprocess when running. +public struct PlatformHandles: Sendable { + public nonisolated(unsafe) let processInformation: PROCESS_INFORMATION + + internal init(processInformation: PROCESS_INFORMATION) { + self.processInformation = processInformation + } + + internal func release() { + guard CloseHandle(processInformation.hThread) else { + fatalError("Failed to close thread HANDLE: \(SubprocessError.UnderlyingError(rawValue: GetLastError()))") + } + guard CloseHandle(processInformation.hProcess) else { + fatalError("Failed to close process HANDLE: \(SubprocessError.UnderlyingError(rawValue: GetLastError()))") + } + } +} + // MARK: - Platform Specific Options /// The collection of platform-specific settings @@ -494,7 +512,7 @@ internal func monitorProcessTermination( guard RegisterWaitForSingleObject( &waitHandle, - execution.processInformation.hProcess, + execution.platformHandles.processInformation.hProcess, callback, context, INFINITE, @@ -512,7 +530,7 @@ internal func monitorProcessTermination( } var status: DWORD = 0 - guard GetExitCodeProcess(execution.processInformation.hProcess, &status) else { + guard GetExitCodeProcess(execution.platformHandles.processInformation.hProcess, &status) else { // The child process terminated but we couldn't get its status back. // Assume generic failure. return .exited(1) @@ -530,7 +548,7 @@ extension Execution { /// Terminate the current subprocess with the given exit code /// - Parameter exitCode: The exit code to use for the subprocess. public func terminate(withExitCode exitCode: DWORD) throws { - guard TerminateProcess(processInformation.hProcess, exitCode) else { + guard TerminateProcess(platformHandles.processInformation.hProcess, exitCode) else { throw SubprocessError( code: .init(.failedToTerminate), underlyingError: .init(rawValue: GetLastError()) @@ -554,7 +572,7 @@ extension Execution { underlyingError: .init(rawValue: GetLastError()) ) } - guard NTSuspendProcess(processInformation.hProcess) >= 0 else { + guard NTSuspendProcess(platformHandles.processInformation.hProcess) >= 0 else { throw SubprocessError( code: .init(.failedToSuspend), underlyingError: .init(rawValue: GetLastError()) @@ -578,7 +596,7 @@ extension Execution { underlyingError: .init(rawValue: GetLastError()) ) } - guard NTResumeProcess(processInformation.hProcess) >= 0 else { + guard NTResumeProcess(platformHandles.processInformation.hProcess) >= 0 else { throw SubprocessError( code: .init(.failedToResume), underlyingError: .init(rawValue: GetLastError()) diff --git a/Tests/SubprocessTests/SubprocessTests+Windows.swift b/Tests/SubprocessTests/SubprocessTests+Windows.swift index 3c615e9..00d9416 100644 --- a/Tests/SubprocessTests/SubprocessTests+Windows.swift +++ b/Tests/SubprocessTests/SubprocessTests+Windows.swift @@ -692,6 +692,36 @@ extension SubprocessWindowsTests { #expect(stuckProcess.terminationStatus.isSuccess) } + /// Tests a use case for Windows platform handles by assigning the newly created process to a Job Object + /// - see: https://devblogs.microsoft.com/oldnewthing/20131209-00/ + @Test func testPlatformHandles() async throws { + let hJob = CreateJobObjectW(nil, nil) + defer { #expect(CloseHandle(hJob)) } + var info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION() + info.BasicLimitInformation.LimitFlags = DWORD(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE) + SetInformationJobObject(hJob, JobObjectExtendedLimitInformation, &info, DWORD(MemoryLayout.size)) + + var platformOptions = PlatformOptions() + platformOptions.preSpawnProcessConfigurator = { (createProcessFlags, startupInfo) in + createProcessFlags |= DWORD(CREATE_SUSPENDED) + } + + let result = try await Subprocess.run( + self.cmdExe, + arguments: ["/c", "echo"], + platformOptions: platformOptions, + output: .discarded + ) { execution, _ in + guard AssignProcessToJobObject(hJob, execution.platformHandles.processInformation.hProcess) else { + throw SubprocessError.UnderlyingError(rawValue: GetLastError()) + } + guard ResumeThread(execution.platformHandles.processInformation.hThread) != DWORD(bitPattern: -1) else { + throw SubprocessError.UnderlyingError(rawValue: GetLastError()) + } + } + #expect(result.terminationStatus.isSuccess) + } + @Test func testRunDetached() async throws { let (readFd, writeFd) = try FileDescriptor.ssp_pipe() SetHandleInformation(