From 43929396c47bd2f0aa616cfec887b32d2cf49480 Mon Sep 17 00:00:00 2001 From: Charles Hu Date: Wed, 30 Jul 2025 15:09:08 -0700 Subject: [PATCH 1/4] Unify tests on all supported platforms to ensure consistent behavior and add more tests. --- Sources/Subprocess/Configuration.swift | 62 +- Sources/Subprocess/IO/AsyncIO+Darwin.swift | 4 +- Sources/Subprocess/IO/AsyncIO+Linux.swift | 23 +- Sources/Subprocess/IO/AsyncIO+Windows.swift | 45 +- Sources/Subprocess/IO/Input.swift | 11 +- Sources/Subprocess/IO/Output.swift | 29 +- .../Platforms/Subprocess+Unix.swift | 7 - .../Platforms/Subprocess+Windows.swift | 260 +- Sources/Subprocess/Teardown.swift | 17 + .../_SubprocessCShims/include/process_shims.h | 8 + Sources/_SubprocessCShims/process_shims.c | 4 + Tests/SubprocessTests/AsyncIOTests.swift | 262 ++ ...ssTests+Darwin.swift => DarwinTests.swift} | 0 Tests/SubprocessTests/IntegrationTests.swift | 2279 +++++++++++++++++ ...sTests+Linting.swift => LinterTests.swift} | 0 ...cessTests+Linux.swift => LinuxTests.swift} | 0 .../SubprocessTests+Unix.swift | 1182 --------- Tests/SubprocessTests/TestSupport.swift | 9 + Tests/SubprocessTests/UnixTests.swift | 537 ++++ ...Tests+Windows.swift => WindowsTests.swift} | 421 --- 20 files changed, 3397 insertions(+), 1763 deletions(-) create mode 100644 Tests/SubprocessTests/AsyncIOTests.swift rename Tests/SubprocessTests/{SubprocessTests+Darwin.swift => DarwinTests.swift} (100%) create mode 100644 Tests/SubprocessTests/IntegrationTests.swift rename Tests/SubprocessTests/{SubprocessTests+Linting.swift => LinterTests.swift} (100%) rename Tests/SubprocessTests/{SubprocessTests+Linux.swift => LinuxTests.swift} (100%) delete mode 100644 Tests/SubprocessTests/SubprocessTests+Unix.swift create mode 100644 Tests/SubprocessTests/UnixTests.swift rename Tests/SubprocessTests/{SubprocessTests+Windows.swift => WindowsTests.swift} (55%) diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index af43569d..1732af55 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -79,38 +79,42 @@ public struct Configuration: Sendable { let execution = _spawnResult.execution - let result: Swift.Result - do { - result = try await .success(withAsyncTaskCleanupHandler { - let inputIO = _spawnResult.inputWriteEnd() - let outputIO = _spawnResult.outputReadEnd() - let errorIO = _spawnResult.errorReadEnd() + return try await withAsyncTaskCleanupHandler { + let inputIO = _spawnResult.inputWriteEnd() + let outputIO = _spawnResult.outputReadEnd() + let errorIO = _spawnResult.errorReadEnd() + let result: Swift.Result + do { // Body runs in the same isolation - return try await body(_spawnResult.execution, inputIO, outputIO, errorIO) - } onCleanup: { - // Attempt to terminate the child process - await execution.runTeardownSequence( - self.platformOptions.teardownSequence - ) - }) - } catch { - result = .failure(error) - } + let bodyResult = try await body(_spawnResult.execution, inputIO, outputIO, errorIO) + result = .success(bodyResult) + } catch { + result = .failure(error) + } - // Ensure that we begin monitoring process termination after `body` runs - // and regardless of whether `body` throws, so that the pid gets reaped - // even if `body` throws, and we are not leaving zombie processes in the - // process table which will cause the process termination monitoring thread - // to effectively hang due to the pid never being awaited - let terminationStatus = try await Subprocess.monitorProcessTermination( - for: execution.processIdentifier - ) + // Ensure that we begin monitoring process termination after `body` runs + // and regardless of whether `body` throws, so that the pid gets reaped + // even if `body` throws, and we are not leaving zombie processes in the + // process table which will cause the process termination monitoring thread + // to effectively hang due to the pid never being awaited + let terminationStatus = try await monitorProcessTermination( + for: execution.processIdentifier + ) - // Close process file descriptor now we finished monitoring - execution.processIdentifier.close() + // Close process file descriptor now we finished monitoring + execution.processIdentifier.close() - return try ExecutionResult(terminationStatus: terminationStatus, value: result.get()) + return ExecutionResult( + terminationStatus: terminationStatus, + value: try result.get() + ) + } onCleanup: { + // Attempt to terminate the child process + await execution.runTeardownSequence( + self.platformOptions.teardownSequence + ) + } } } @@ -334,12 +338,12 @@ public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { self.executablePathOverride = nil } } + #endif public init(_ array: [[UInt8]]) { self.storage = array.map { .rawBytes($0) } self.executablePathOverride = nil } - #endif } extension Arguments: CustomStringConvertible, CustomDebugStringConvertible { @@ -869,7 +873,7 @@ internal struct CreatedPipe: ~Copyable { DWORD(readBufferSize), DWORD(readBufferSize), 0, - &saAttributes + nil ) } guard let parentEnd, parentEnd != INVALID_HANDLE_VALUE else { diff --git a/Sources/Subprocess/IO/AsyncIO+Darwin.swift b/Sources/Subprocess/IO/AsyncIO+Darwin.swift index 4d355f3d..11cf94a6 100644 --- a/Sources/Subprocess/IO/AsyncIO+Darwin.swift +++ b/Sources/Subprocess/IO/AsyncIO+Darwin.swift @@ -25,7 +25,9 @@ internal import Dispatch final class AsyncIO: Sendable { static let shared: AsyncIO = AsyncIO() - private init() {} + internal init() {} + + internal func shutdown() { /* noop on Darwin */ } internal func read( from diskIO: borrowing IOChannel, diff --git a/Sources/Subprocess/IO/AsyncIO+Linux.swift b/Sources/Subprocess/IO/AsyncIO+Linux.swift index 0ca318e3..000e5481 100644 --- a/Sources/Subprocess/IO/AsyncIO+Linux.swift +++ b/Sources/Subprocess/IO/AsyncIO+Linux.swift @@ -68,8 +68,9 @@ final class AsyncIO: Sendable { static let shared: AsyncIO = AsyncIO() private let state: Result + private let shutdownFlag: Atomic = Atomic(0) - private init() { + internal init() { // Create main epoll fd let epollFileDescriptor = epoll_create1(CInt(EPOLL_CLOEXEC)) guard epollFileDescriptor >= 0 else { @@ -200,11 +201,15 @@ final class AsyncIO: Sendable { } } - private func shutdown() { + internal func shutdown() { guard case .success(let currentState) = self.state else { return } + guard self.shutdownFlag.add(1, ordering: .sequentiallyConsistent).newValue == 1 else { + // We already closed this AsyncIO + return + } var one: UInt64 = 1 // Wake up the thread for shutdown _ = _subprocess_write(currentState.shutdownFileDescriptor, &one, MemoryLayout.stride) @@ -222,7 +227,6 @@ final class AsyncIO: Sendable { } } - private func registerFileDescriptor( _ fileDescriptor: FileDescriptor, for event: Event @@ -273,11 +277,12 @@ final class AsyncIO: Sendable { &event ) if rc != 0 { + let capturedError = errno let error = SubprocessError( code: .init(.asyncIOFailed( "failed to add \(fileDescriptor.rawValue) to epoll list") ), - underlyingError: .init(rawValue: errno) + underlyingError: .init(rawValue: capturedError) ) continuation.finish(throwing: error) return @@ -340,6 +345,9 @@ extension AsyncIO { from fileDescriptor: FileDescriptor, upTo maxLength: Int ) async throws -> [UInt8]? { + guard maxLength > 0 else { + return nil + } // If we are reading until EOF, start with readBufferSize // and gradually increase buffer size let bufferLength = maxLength == .max ? readBufferSize : maxLength @@ -403,6 +411,7 @@ extension AsyncIO { } } } + resultBuffer.removeLast(resultBuffer.count - readLength) return resultBuffer } @@ -417,6 +426,9 @@ extension AsyncIO { _ bytes: Bytes, to diskIO: borrowing IOChannel ) async throws -> Int { + guard bytes.count > 0 else { + return 0 + } let fileDescriptor = diskIO.channel let signalStream = self.registerFileDescriptor(fileDescriptor, for: .write) var writtenLength: Int = 0 @@ -460,6 +472,9 @@ extension AsyncIO { _ span: borrowing RawSpan, to diskIO: borrowing IOChannel ) async throws -> Int { + guard span.byteCount > 0 else { + return 0 + } let fileDescriptor = diskIO.channel let signalStream = self.registerFileDescriptor(fileDescriptor, for: .write) var writtenLength: Int = 0 diff --git a/Sources/Subprocess/IO/AsyncIO+Windows.swift b/Sources/Subprocess/IO/AsyncIO+Windows.swift index 72fd6a29..031126b3 100644 --- a/Sources/Subprocess/IO/AsyncIO+Windows.swift +++ b/Sources/Subprocess/IO/AsyncIO+Windows.swift @@ -51,10 +51,10 @@ final class AsyncIO: @unchecked Sendable { static let shared = AsyncIO() private let ioCompletionPort: Result - private let monitorThread: Result + private let shutdownFlag: Atomic = Atomic(0) - private init() { + internal init() { var maybeSetupError: SubprocessError? = nil // Create the the completion port guard let port = CreateIoCompletionPort( @@ -78,10 +78,11 @@ final class AsyncIO: @unchecked Sendable { /// > thread management rather than CreateThread and ExitThread let threadHandleValue = _beginthreadex(nil, 0, { args in func reportError(_ error: SubprocessError) { - _registration.withLock { store in - for continuation in store.values { - continuation.finish(throwing: error) - } + let continuations = _registration.withLock { store in + return store.values + } + for continuation in continuations { + continuation.finish(throwing: error) } } @@ -110,11 +111,13 @@ final class AsyncIO: @unchecked Sendable { // in the store. Windows does not offer an API to remove a // HANDLE from an IOCP port, therefore we leave the registration // to signify the HANDLE has already been resisted. - _registration.withLock { store in + let continuation = _registration.withLock { store -> SignalStream.Continuation? in if let continuation = store[targetFileDescriptor] { - continuation.finish() + return continuation } + return nil } + continuation?.finish() continue } else { let error = SubprocessError( @@ -159,12 +162,17 @@ final class AsyncIO: @unchecked Sendable { } } - private func shutdown() { - // Post status to shutdown HANDLE + internal func shutdown() { guard case .success(let ioPort) = ioCompletionPort, case .success(let monitorThreadHandle) = monitorThread else { return } + // Make sure we don't shutdown the same instance twice + guard self.shutdownFlag.add(1, ordering: .relaxed).newValue == 1 else { + // We already closed this AsyncIO + return + } + // Post status to shutdown HANDLE PostQueuedCompletionStatus( ioPort, // CompletionPort 0, // Number of bytes transferred. @@ -245,6 +253,9 @@ final class AsyncIO: @unchecked Sendable { from handle: HANDLE, upTo maxLength: Int ) async throws -> [UInt8]? { + guard maxLength > 0 else { + return nil + } // If we are reading until EOF, start with readBufferSize // and gradually increase buffer size let bufferLength = maxLength == .max ? readBufferSize : maxLength @@ -284,8 +295,12 @@ final class AsyncIO: @unchecked Sendable { // Make sure we only get `ERROR_IO_PENDING` or `ERROR_BROKEN_PIPE` let lastError = GetLastError() if lastError == ERROR_BROKEN_PIPE { - // We reached EOF - return nil + // We reached EOF. Return whatever's left + guard readLength > 0 else { + return nil + } + resultBuffer.removeLast(resultBuffer.count - readLength) + return resultBuffer } guard lastError == ERROR_IO_PENDING else { let error = SubprocessError( @@ -337,6 +352,9 @@ final class AsyncIO: @unchecked Sendable { _ span: borrowing RawSpan, to diskIO: borrowing IOChannel ) async throws -> Int { + guard span.byteCount > 0 else { + return 0 + } let handle = diskIO.channel var signalStream = self.registerHandle(diskIO.channel).makeAsyncIterator() var writtenLength: Int = 0 @@ -389,6 +407,9 @@ final class AsyncIO: @unchecked Sendable { _ bytes: Bytes, to diskIO: borrowing IOChannel ) async throws -> Int { + guard bytes.count > 0 else { + return 0 + } let handle = diskIO.channel var signalStream = self.registerHandle(diskIO.channel).makeAsyncIterator() var writtenLength: Int = 0 diff --git a/Sources/Subprocess/IO/Input.swift b/Sources/Subprocess/IO/Input.swift index 715428ed..55db67d9 100644 --- a/Sources/Subprocess/IO/Input.swift +++ b/Sources/Subprocess/IO/Input.swift @@ -49,20 +49,15 @@ public protocol InputProtocol: Sendable, ~Copyable { public struct NoInput: InputProtocol { internal func createPipe() throws -> CreatedPipe { #if os(Windows) - // On Windows, instead of binding to dev null, - // we don't set the input handle in the `STARTUPINFOW` - // to signal no input - return CreatedPipe( - readFileDescriptor: nil, - writeFileDescriptor: nil - ) + let devnullFd: FileDescriptor = try .openDevNull(withAccessMode: .writeOnly) + let devnull = HANDLE(bitPattern: _get_osfhandle(devnullFd.rawValue))! #else let devnull: FileDescriptor = try .openDevNull(withAccessMode: .readOnly) + #endif return CreatedPipe( readFileDescriptor: .init(devnull, closeWhenDone: true), writeFileDescriptor: nil ) - #endif } public func write(with writer: StandardInputWriter) async throws { diff --git a/Sources/Subprocess/IO/Output.swift b/Sources/Subprocess/IO/Output.swift index 17590912..1096a307 100644 --- a/Sources/Subprocess/IO/Output.swift +++ b/Sources/Subprocess/IO/Output.swift @@ -58,21 +58,17 @@ public struct DiscardedOutput: OutputProtocol { public typealias OutputType = Void internal func createPipe() throws -> CreatedPipe { + #if os(Windows) - // On Windows, instead of binding to dev null, - // we don't set the input handle in the `STARTUPINFOW` - // to signal no output - return CreatedPipe( - readFileDescriptor: nil, - writeFileDescriptor: nil - ) + let devnullFd: FileDescriptor = try .openDevNull(withAccessMode: .writeOnly) + let devnull = HANDLE(bitPattern: _get_osfhandle(devnullFd.rawValue))! #else - let devnull: FileDescriptor = try .openDevNull(withAccessMode: .readOnly) + let devnull: FileDescriptor = try .openDevNull(withAccessMode: .writeOnly) + #endif return CreatedPipe( readFileDescriptor: nil, writeFileDescriptor: .init(devnull, closeWhenDone: true) ) - #endif } internal init() {} @@ -289,6 +285,7 @@ extension OutputProtocol { from diskIO: consuming IOChannel? ) async throws -> OutputType { if OutputType.self == Void.self { + try diskIO?.safelyClose() return () as! OutputType } // `diskIO` is only `nil` for any types that conform to `OutputProtocol` @@ -330,6 +327,7 @@ extension OutputProtocol { underlyingError: nil ) } + #if canImport(Darwin) return try self.output(from: result ?? .empty) #else @@ -400,3 +398,16 @@ extension DispatchData { return result ?? [] } } + +extension FileDescriptor { + internal static func openDevNull( + withAccessMode mode: FileDescriptor.AccessMode + ) throws -> FileDescriptor { + #if os(Windows) + let devnull: FileDescriptor = try .open("NUL", mode) + #else + let devnull: FileDescriptor = try .open("/dev/null", mode) + #endif + return devnull + } +} diff --git a/Sources/Subprocess/Platforms/Subprocess+Unix.swift b/Sources/Subprocess/Platforms/Subprocess+Unix.swift index 555ca909..f9bf2a54 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -366,13 +366,6 @@ extension FileDescriptor { try pipe() } - internal static func openDevNull( - withAccessMode mode: FileDescriptor.AccessMode - ) throws -> FileDescriptor { - let devnull: FileDescriptor = try .open("/dev/null", mode) - return devnull - } - internal var platformDescriptor: PlatformFileDescriptor { return self.rawValue } diff --git a/Sources/Subprocess/Platforms/Subprocess+Windows.swift b/Sources/Subprocess/Platforms/Subprocess+Windows.swift index 541cc7ea..a22d24ae 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -19,6 +19,8 @@ internal import Dispatch @preconcurrency import SystemPackage #endif +import _SubprocessCShims + // Windows specific implementation extension Configuration { internal func spawn( @@ -78,41 +80,44 @@ extension Configuration { throw error } - var startupInfo = try self.generateStartupInfo( - withInputRead: inputReadFileDescriptor, + var processInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() + var createProcessFlags = self.generateCreateProcessFlag() + + let created = try self.withStartupInfoEx( + inputRead: inputReadFileDescriptor, inputWrite: inputWriteFileDescriptor, outputRead: outputReadFileDescriptor, outputWrite: outputWriteFileDescriptor, errorRead: errorReadFileDescriptor, errorWrite: errorWriteFileDescriptor - ) - var processInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() - var createProcessFlags = self.generateCreateProcessFlag() - // Give calling process a chance to modify flag and startup info - if let configurator = self.platformOptions.preSpawnProcessConfigurator { - try configurator(&createProcessFlags, &startupInfo) - } - // Spawn! - let created = try applicationName.withOptionalNTPathRepresentation { applicationNameW in - try commandAndArgs.withCString( - encodedAs: UTF16.self - ) { commandAndArgsW in - try environment.withCString( + ) { startupInfo in + // Give calling process a chance to modify flag and startup info + if let configurator = self.platformOptions.preSpawnProcessConfigurator { + try configurator(&createProcessFlags, &startupInfo.pointer(to: \.StartupInfo)!.pointee) + } + + // Spawn! + return try applicationName.withOptionalNTPathRepresentation { applicationNameW in + try commandAndArgs.withCString( encodedAs: UTF16.self - ) { environmentW in - try intendedWorkingDir.withOptionalNTPathRepresentation { intendedWorkingDirW in - CreateProcessW( - applicationNameW, - UnsafeMutablePointer(mutating: commandAndArgsW), - nil, // lpProcessAttributes - nil, // lpThreadAttributes - true, // bInheritHandles - createProcessFlags, - UnsafeMutableRawPointer(mutating: environmentW), - intendedWorkingDirW, - &startupInfo, - &processInfo - ) + ) { commandAndArgsW in + try environment.withCString( + encodedAs: UTF16.self + ) { environmentW in + try intendedWorkingDir.withOptionalNTPathRepresentation { intendedWorkingDirW in + CreateProcessW( + applicationNameW, + UnsafeMutablePointer(mutating: commandAndArgsW), + nil, // lpProcessAttributes + nil, // lpThreadAttributes + true, // bInheritHandles + createProcessFlags, + UnsafeMutableRawPointer(mutating: environmentW), + intendedWorkingDirW, + startupInfo.pointer(to: \.StartupInfo)!, + &processInfo + ) + } } } } @@ -128,6 +133,22 @@ extension Configuration { errorRead: errorReadFileDescriptor, errorWrite: errorWriteFileDescriptor ) + // Match Darwin and Linux behavior and throw + // .executableNotFound or .failedToChangeWorkingDirectory accordingly + if windowsError == ERROR_FILE_NOT_FOUND { + throw SubprocessError( + code: .init(.executableNotFound(self.executable.description)), + underlyingError: .init(rawValue: windowsError) + ) + } + + if windowsError == ERROR_DIRECTORY { + throw SubprocessError( + code: .init(.failedToChangeWorkingDirectory(self.workingDirectory?.string ?? "")), + underlyingError: .init(rawValue: windowsError) + ) + } + throw SubprocessError( code: .init(.spawnFailed), underlyingError: .init(rawValue: windowsError) @@ -209,51 +230,54 @@ extension Configuration { throw error } - var startupInfo = try self.generateStartupInfo( - withInputRead: inputReadFileDescriptor, + var processInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() + var createProcessFlags = self.generateCreateProcessFlag() + + let created = try self.withStartupInfoEx( + inputRead: inputReadFileDescriptor, inputWrite: inputWriteFileDescriptor, outputRead: outputReadFileDescriptor, outputWrite: outputWriteFileDescriptor, errorRead: errorReadFileDescriptor, errorWrite: errorWriteFileDescriptor - ) - var processInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() - var createProcessFlags = self.generateCreateProcessFlag() - // Give calling process a chance to modify flag and startup info - if let configurator = self.platformOptions.preSpawnProcessConfigurator { - try configurator(&createProcessFlags, &startupInfo) - } - // Spawn (featuring pyramid!) - let created = try userCredentials.username.withCString( - encodedAs: UTF16.self - ) { usernameW in - try userCredentials.password.withCString( + ) { startupInfo in + // Give calling process a chance to modify flag and startup info + if let configurator = self.platformOptions.preSpawnProcessConfigurator { + try configurator(&createProcessFlags, &startupInfo.pointer(to: \.StartupInfo)!.pointee) + } + + // Spawn (featuring pyramid!) + return try userCredentials.username.withCString( encodedAs: UTF16.self - ) { passwordW in - try userCredentials.domain.withOptionalCString( + ) { usernameW in + try userCredentials.password.withCString( encodedAs: UTF16.self - ) { domainW in - try applicationName.withOptionalNTPathRepresentation { applicationNameW in - try commandAndArgs.withCString( - encodedAs: UTF16.self - ) { commandAndArgsW in - try environment.withCString( + ) { passwordW in + try userCredentials.domain.withOptionalCString( + encodedAs: UTF16.self + ) { domainW in + try applicationName.withOptionalNTPathRepresentation { applicationNameW in + try commandAndArgs.withCString( encodedAs: UTF16.self - ) { environmentW in - try intendedWorkingDir.withOptionalNTPathRepresentation { intendedWorkingDirW in - CreateProcessWithLogonW( - usernameW, - domainW, - passwordW, - DWORD(LOGON_WITH_PROFILE), - applicationNameW, - UnsafeMutablePointer(mutating: commandAndArgsW), - createProcessFlags, - UnsafeMutableRawPointer(mutating: environmentW), - intendedWorkingDirW, - &startupInfo, - &processInfo - ) + ) { commandAndArgsW in + try environment.withCString( + encodedAs: UTF16.self + ) { environmentW in + try intendedWorkingDir.withOptionalNTPathRepresentation { intendedWorkingDirW in + CreateProcessWithLogonW( + usernameW, + domainW, + passwordW, + DWORD(LOGON_WITH_PROFILE), + applicationNameW, + UnsafeMutablePointer(mutating: commandAndArgsW), + createProcessFlags, + UnsafeMutableRawPointer(mutating: environmentW), + intendedWorkingDirW, + startupInfo.pointer(to: \.StartupInfo)!, + &processInfo + ) + } } } } @@ -262,6 +286,7 @@ extension Configuration { } } + guard created else { let windowsError = GetLastError() try self.safelyCloseMultiple( @@ -272,6 +297,22 @@ extension Configuration { errorRead: errorReadFileDescriptor, errorWrite: errorWriteFileDescriptor ) + // Match Darwin and Linux behavior and throw + // .executableNotFound or .failedToChangeWorkingDirectory accordingly + if windowsError == ERROR_FILE_NOT_FOUND { + throw SubprocessError( + code: .init(.executableNotFound(self.executable.description)), + underlyingError: .init(rawValue: windowsError) + ) + } + + if windowsError == ERROR_DIRECTORY { + throw SubprocessError( + code: .init(.failedToChangeWorkingDirectory(self.workingDirectory?.string ?? "")), + underlyingError: .init(rawValue: windowsError) + ) + } + throw SubprocessError( code: .init(.spawnFailed), underlyingError: .init(rawValue: windowsError) @@ -748,17 +789,7 @@ extension Configuration { applicationName, commandAndArgs ) = try self.generateWindowsCommandAndAgruments() - // Validate workingDir - if let workingDirectory = self.workingDirectory?.string { - guard Self.pathAccessible(workingDirectory) else { - throw SubprocessError( - code: .init( - .failedToChangeWorkingDirectory(workingDirectory) - ), - underlyingError: nil - ) - } - } + return ( applicationName: applicationName, commandAndArgs: commandAndArgs, @@ -768,7 +799,7 @@ extension Configuration { } private func generateCreateProcessFlag() -> DWORD { - var flags = CREATE_UNICODE_ENVIRONMENT + var flags = CREATE_UNICODE_ENVIRONMENT | EXTENDED_STARTUPINFO_PRESENT switch self.platformOptions.consoleBehavior.storage { case .createNew: flags |= CREATE_NEW_CONSOLE @@ -783,26 +814,33 @@ extension Configuration { return DWORD(flags) } - private func generateStartupInfo( - withInputRead inputReadFileDescriptor: borrowing IODescriptor?, + private func withStartupInfoEx( + inputRead inputReadFileDescriptor: borrowing IODescriptor?, inputWrite inputWriteFileDescriptor: borrowing IODescriptor?, outputRead outputReadFileDescriptor: borrowing IODescriptor?, outputWrite outputWriteFileDescriptor: borrowing IODescriptor?, errorRead errorReadFileDescriptor: borrowing IODescriptor?, errorWrite errorWriteFileDescriptor: borrowing IODescriptor?, - ) throws -> STARTUPINFOW { - var info: STARTUPINFOW = STARTUPINFOW() - info.cb = DWORD(MemoryLayout.size) - info.dwFlags |= DWORD(STARTF_USESTDHANDLES) + _ body: (UnsafeMutablePointer) throws -> Result + ) rethrows -> Result { + var info: STARTUPINFOEXW = STARTUPINFOEXW() + info.StartupInfo.cb = DWORD(MemoryLayout.size) + info.StartupInfo.dwFlags |= DWORD(STARTF_USESTDHANDLES) if self.platformOptions.windowStyle.storage != .normal { - info.wShowWindow = self.platformOptions.windowStyle.platformStyle - info.dwFlags |= DWORD(STARTF_USESHOWWINDOW) + info.StartupInfo.wShowWindow = self.platformOptions.windowStyle.platformStyle + info.StartupInfo.dwFlags |= DWORD(STARTF_USESHOWWINDOW) } // Bind IOs + // Keep track of the explicitly list HANDLE to be inherited by the child process + // This emulate `posix_spawn`'s `POSIX_SPAWN_CLOEXEC_DEFAULT` + var inheritedHandles: Set = Set() // Input if inputReadFileDescriptor != nil { - info.hStdInput = inputReadFileDescriptor!.platformDescriptor() + let inputHandle = inputReadFileDescriptor!.platformDescriptor() + SetHandleInformation(inputHandle, DWORD(HANDLE_FLAG_INHERIT), DWORD(HANDLE_FLAG_INHERIT)) + info.StartupInfo.hStdInput = inputHandle + inheritedHandles.insert(inputHandle) } if inputWriteFileDescriptor != nil { // Set parent side to be uninheritable @@ -814,7 +852,10 @@ extension Configuration { } // Output if outputWriteFileDescriptor != nil { - info.hStdOutput = outputWriteFileDescriptor!.platformDescriptor() + let outputHandle = outputWriteFileDescriptor!.platformDescriptor() + SetHandleInformation(outputHandle, DWORD(HANDLE_FLAG_INHERIT), DWORD(HANDLE_FLAG_INHERIT)) + info.StartupInfo.hStdOutput = outputHandle + inheritedHandles.insert(outputHandle) } if outputReadFileDescriptor != nil { // Set parent side to be uninheritable @@ -826,7 +867,10 @@ extension Configuration { } // Error if errorWriteFileDescriptor != nil { - info.hStdError = errorWriteFileDescriptor!.platformDescriptor() + let errorHandle = errorWriteFileDescriptor!.platformDescriptor() + SetHandleInformation(errorHandle, DWORD(HANDLE_FLAG_INHERIT), DWORD(HANDLE_FLAG_INHERIT)) + info.StartupInfo.hStdError = errorHandle + inheritedHandles.insert(errorHandle) } if errorReadFileDescriptor != nil { // Set parent side to be uninheritable @@ -836,7 +880,43 @@ extension Configuration { 0 ) } - return info + // Initialize an attribute list of sufficient size for the specified number of + // attributes. Alignment is a problem because LPPROC_THREAD_ATTRIBUTE_LIST is + // an opaque pointer and we don't know the alignment of the underlying data. + // We *should* use the alignment of C's max_align_t, but it is defined using a + // C++ using statement on Windows and isn't imported into Swift. So, 16 it is. + let alignment = 16 + var attributeListByteCount = SIZE_T(0) + _ = InitializeProcThreadAttributeList(nil, 1, 0, &attributeListByteCount) + return try withUnsafeTemporaryAllocation(byteCount: Int(attributeListByteCount), alignment: alignment) { attributeListPtr in + let attributeList = LPPROC_THREAD_ATTRIBUTE_LIST(attributeListPtr.baseAddress!) + guard InitializeProcThreadAttributeList(attributeList, 1, 0, &attributeListByteCount) else { + throw SubprocessError( + code: .init(.spawnFailed), + underlyingError: .init(rawValue: GetLastError()) + ) + } + defer { + DeleteProcThreadAttributeList(attributeList) + } + + var handles = Array(inheritedHandles) + return try handles.withUnsafeMutableBufferPointer { inheritedHandlesPtr in + _ = UpdateProcThreadAttribute( + attributeList, + 0, + _subprocess_PROC_THREAD_ATTRIBUTE_HANDLE_LIST(), + inheritedHandlesPtr.baseAddress!, + SIZE_T(MemoryLayout.stride * inheritedHandlesPtr.count), + nil, + nil + ) + + info.lpAttributeList = attributeList + + return try body(&info) + } + } } private func generateWindowsCommandAndAgruments() throws -> ( diff --git a/Sources/Subprocess/Teardown.swift b/Sources/Subprocess/Teardown.swift index d5dd5511..055e9f85 100644 --- a/Sources/Subprocess/Teardown.swift +++ b/Sources/Subprocess/Teardown.swift @@ -138,6 +138,10 @@ extension Execution { let finalSequence = sequence + [TeardownStep(storage: .kill)] for step in finalSequence { let stepCompletion: TeardownStepCompletion + guard self.isStillAlive() else { + // Early return since the process has already exited + return + } switch step.storage { case .gracefulShutDown(let allowedDuration): @@ -194,6 +198,19 @@ extension Execution { } } } + + private func isStillAlive() -> Bool { + // Non-blockingly check whether the current execution has already exited + // Note here we do NOT want to reap the exit status because we are still + // running monitorProcessTermination() + #if os(Windows) + var exitCode: DWORD = 0 + GetExitCodeProcess(self.processIdentifier.processDescriptor, &exitCode) + return exitCode == STILL_ACTIVE + #else + return kill(self.processIdentifier.value, 0) == 0 + #endif + } } func withUncancelledTask( diff --git a/Sources/_SubprocessCShims/include/process_shims.h b/Sources/_SubprocessCShims/include/process_shims.h index 085cb779..2fa4d54a 100644 --- a/Sources/_SubprocessCShims/include/process_shims.h +++ b/Sources/_SubprocessCShims/include/process_shims.h @@ -98,6 +98,8 @@ int _pidfd_send_signal(int pidfd, int signal); #if TARGET_OS_WINDOWS +#include + #ifndef _WINDEF_ typedef unsigned long DWORD; typedef int BOOL; @@ -106,6 +108,12 @@ typedef int BOOL; BOOL _subprocess_windows_send_vm_close(DWORD pid); unsigned int _subprocess_windows_get_errno(void); +/// Get the value of `PROC_THREAD_ATTRIBUTE_HANDLE_LIST`. +/// +/// This function is provided because `PROC_THREAD_ATTRIBUTE_HANDLE_LIST` is a +/// complex macro and cannot be imported directly into Swift. +DWORD_PTR _subprocess_PROC_THREAD_ATTRIBUTE_HANDLE_LIST(void); + #endif #endif /* process_shims_h */ diff --git a/Sources/_SubprocessCShims/process_shims.c b/Sources/_SubprocessCShims/process_shims.c index d456f4ca..2b17daa1 100644 --- a/Sources/_SubprocessCShims/process_shims.c +++ b/Sources/_SubprocessCShims/process_shims.c @@ -722,5 +722,9 @@ unsigned int _subprocess_windows_get_errno(void) { return errno; } +DWORD_PTR _subprocess_PROC_THREAD_ATTRIBUTE_HANDLE_LIST(void) { + return PROC_THREAD_ATTRIBUTE_HANDLE_LIST; +} + #endif diff --git a/Tests/SubprocessTests/AsyncIOTests.swift b/Tests/SubprocessTests/AsyncIOTests.swift new file mode 100644 index 00000000..03e0c90d --- /dev/null +++ b/Tests/SubprocessTests/AsyncIOTests.swift @@ -0,0 +1,262 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if canImport(System) +@preconcurrency import System +#else +@preconcurrency import SystemPackage +#endif + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Bionic) +import Bionic +#elseif canImport(Musl) +import Musl +#elseif canImport(WinSDK) +import WinSDK +#endif + +import Testing +import Dispatch +import Foundation +import TestResources +import _SubprocessCShims +@testable import Subprocess + +@Suite("Subprocess.AsyncIO Unit Tests", .serialized) +struct SubprocessAsyncIOTests { } + +// MARK: - Basic Functionality Tests +extension SubprocessAsyncIOTests { + @Test func testBasicReadWrite() async throws { + let testData = randomData(count: 1024) + try await runReadWriteTest { readIO, readTestBed in + let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + #expect(Array(readData!) == testData) + } writer: { writeIO, writeTestBed in + _ = try await writeIO.write(testData, to: writeTestBed.ioChannel) + try await writeTestBed.finish() + } + } + + @Test func testMultipleSequentialReadWrite() async throws { + var _chunks: [[UInt8]] = [] + for _ in 0 ..< 10 { + // Generate some that's short + _chunks.append(randomData(count: Int.random(in: 1 ..< 512))) + } + for _ in 0 ..< 10 { + // Generate some that are longer than buffer size + _chunks.append(randomData(count: Int.random(in: Subprocess.readBufferSize ..< Subprocess.readBufferSize * 3))) + } + _chunks.shuffle() + let chunks = _chunks + try await runReadWriteTest { readIO, readTestBed in + for expectedChunk in chunks { + let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: expectedChunk.count) + #expect(readData != nil) + #expect(Array(readData!) == expectedChunk) + } + + // Final read should return nil + let finalRead = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + #expect(finalRead == nil) + } writer: { writeIO, writeTestBed in + for chunk in chunks { + _ = try await writeIO.write(chunk, to: writeTestBed.ioChannel) + try await writeTestBed.delay(.milliseconds(10)) + } + try await writeTestBed.finish() + } + } +} + +// MARK: - Edge Case Tests +extension SubprocessAsyncIOTests { + @Test func testReadFromEmptyPipe() async throws { + try await runReadWriteTest { readIO, readTestBed in + let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + #expect(readData == nil) + } writer: { writeIO, writeTestBed in + // Close write end immediately without writing any data + try await writeTestBed.finish() + } + } + + @Test func testZeroLengthRead() async throws { + let testData = randomData(count: 64) + try await runReadWriteTest { readIO, readTestBed in + let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: 0) + #expect(readData == nil) + } writer: { writeIO, writeTestBed in + _ = try await writeIO.write(testData, to: writeTestBed.ioChannel) + try await writeTestBed.finish() + } + } + + @Test func testZeroLengthWrite() async throws { + try await runReadWriteTest { readIO, readTestBed in + let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + #expect(readData == nil) + } writer: { writeIO, writeTestBed in + let written = try await writeIO.write([], to: writeTestBed.ioChannel) + #expect(written == 0) + try await writeTestBed.finish() + } + } + + @Test func testLargeReadWrite() async throws { + let testData = randomData(count: 1024 * 1024) + try await runReadWriteTest { readIO, readTestBed in + let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + #expect(Array(readData!) == testData) + } writer: { writeIO, writeTestBed in + _ = try await writeIO.write(testData, to: writeTestBed.ioChannel) + try await writeTestBed.finish() + } + } +} + + +// MARK: - Error Handling Tests +extension SubprocessAsyncIOTests { + @Test func testWriteToClosedPipe() async throws { + var pipe = try CreatedPipe(closeWhenDone: true, purpose: .input) + var writeChannel = pipe.writeFileDescriptor()!.createIOChannel() + var readChannel = pipe.readFileDescriptor()!.createIOChannel() + defer { + try? readChannel.safelyClose() + } + + try writeChannel.safelyClose() + + do { + _ = try await AsyncIO.shared.write([100], to: writeChannel) + Issue.record("Expected write to closed pipe to throw an error") + } catch { + guard let subprocessError = error as? SubprocessError else { + Issue.record("Expecting SubprocessError, but got \(error)") + return + } + #if canImport(Darwin) + #expect(subprocessError.underlyingError == .init(rawValue: ECANCELED)) + #elseif os(Linux) + #expect(subprocessError.underlyingError == .init(rawValue: EBADF)) + #endif + } + } + + @Test func testReadFromClosedPipe() async throws { + var pipe = try CreatedPipe(closeWhenDone: true, purpose: .input) + var writeChannel = pipe.writeFileDescriptor()!.createIOChannel() + var readChannel = pipe.readFileDescriptor()!.createIOChannel() + defer { + try? writeChannel.safelyClose() + } + + try readChannel.safelyClose() + + do { + _ = try await AsyncIO.shared.read(from: readChannel, upTo: .max) + Issue.record("Expected write to closed pipe to throw an error") + } catch { + guard let subprocessError = error as? SubprocessError else { + Issue.record("Expecting SubprocessError, but got \(error)") + return + } + #if canImport(Darwin) + #expect(subprocessError.underlyingError == .init(rawValue: ECANCELED)) + #elseif os(Linux) + #expect(subprocessError.underlyingError == .init(rawValue: EBADF)) + #endif + } + } + + @Test func testBinaryDataWithNullBytes() async throws { + let binaryData: [UInt8] = [0x00, 0x01, 0x02, 0x00, 0xFF, 0x00, 0xFE, 0xFD] + try await runReadWriteTest { readIO, readTestBed in + let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + #expect(readData != nil) + #expect(Array(readData!) == binaryData) + } writer: { writeIO, writeTestBed in + let written = try await writeIO.write(binaryData, to: writeTestBed.ioChannel) + #expect(written == binaryData.count) + try await writeTestBed.finish() + } + } +} + +// MARK: - Utils +extension SubprocessAsyncIOTests { + final class TestBed { + let ioChannel: Subprocess.IOChannel + + init(ioChannel: consuming Subprocess.IOChannel) { + self.ioChannel = ioChannel + } + } +} + +extension SubprocessAsyncIOTests { + func runReadWriteTest( + reader: @escaping @Sendable (AsyncIO, consuming SubprocessAsyncIOTests.TestBed) async throws -> Void, + writer: @escaping @Sendable (AsyncIO, consuming SubprocessAsyncIOTests.TestBed) async throws -> Void + ) async throws { + try await withThrowingTaskGroup { group in + // First create the pipe + var pipe = try CreatedPipe(closeWhenDone: true, purpose: .input) + + let readChannel: IOChannel? = pipe.readFileDescriptor()?.createIOChannel() + let writeChannel: IOChannel? = pipe.writeFileDescriptor()?.createIOChannel() + + var readBox: IOChannel? = consume readChannel + var writeBox: IOChannel? = consume writeChannel + + let readIO = AsyncIO.shared + let writeIO = AsyncIO() + + group.addTask { + var readIOContainer: IOChannel? = readBox.take() + let readTestBed = TestBed(ioChannel: readIOContainer.take()!) + try await reader(readIO, readTestBed) + } + group.addTask { + var writeIOContainer: IOChannel? = writeBox.take() + let writeTestBed = TestBed(ioChannel: writeIOContainer.take()!) + try await writer(writeIO, writeTestBed) + } + + try await group.waitForAll() + // Teardown + // readIO shutdown is done via `atexit`. + writeIO.shutdown() + } + } +} + +extension SubprocessAsyncIOTests.TestBed { + consuming func finish() async throws { +#if canImport(WinSDK) + try _safelyClose(.handle(self.ioChannel.channel)) +#elseif canImport(Darwin) + try _safelyClose(.dispatchIO(self.ioChannel.channel)) +#else + try _safelyClose(.fileDescriptor(self.ioChannel.channel)) +#endif + } + + func delay(_ duration: Duration) async throws { + try await Task.sleep(for: duration) + } +} diff --git a/Tests/SubprocessTests/SubprocessTests+Darwin.swift b/Tests/SubprocessTests/DarwinTests.swift similarity index 100% rename from Tests/SubprocessTests/SubprocessTests+Darwin.swift rename to Tests/SubprocessTests/DarwinTests.swift diff --git a/Tests/SubprocessTests/IntegrationTests.swift b/Tests/SubprocessTests/IntegrationTests.swift new file mode 100644 index 00000000..fe403803 --- /dev/null +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -0,0 +1,2279 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if canImport(System) +@preconcurrency import System +#else +@preconcurrency import SystemPackage +#endif + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Bionic) +import Bionic +#elseif canImport(Musl) +import Musl +#elseif canImport(WinSDK) +import WinSDK +#endif + +import Testing +import Dispatch +import Foundation +import TestResources +import _SubprocessCShims +@testable import Subprocess + +@Suite("Subprocess Integration (End to End) Tests", .serialized) +struct SubprocessIntegrationTests {} + +// MARK: - Executable Tests +extension SubprocessIntegrationTests { + @Test func testExecutableNamed() async throws { + let message = "Hello, world!" + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: .init(["/c", "echo", message]) + ) + #else + let setup = TestSetup( + executable: .name("echo"), + arguments: .init([message]) + ) + #endif + + // Simple test to make sure we can find a common utility + let result = try await _run( + setup, + input: .none, + output: .string(limit: 32), + error: .discarded + ) + #expect(result.terminationStatus.isSuccess) + // rdar://138670128 + let output = result.standardOutput? + .trimmingNewLineAndQuotes() + // Windows echo includes quotes + #expect(output == message) + } + + @Test func testExecutableNamedCannotResolve() async { + do { + _ = try await Subprocess.run(.name("do-not-exist"), output: .discarded) + Issue.record("Expected to throw") + } catch { + guard let subprocessError: SubprocessError = error as? SubprocessError else { + Issue.record("Expected SubprocessError, got \(error)") + return + } + #expect(subprocessError.code == .init(.executableNotFound("do-not-exist"))) + } + } + + @Test func testExecutableAtPath() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: .init(["/c", "cd"]) + ) + #else + let setup = TestSetup( + executable: .path("/bin/pwd"), + arguments: .init() + ) + #endif + let expected = FileManager.default.currentDirectoryPath + let result = try await _run( + setup, + input: .none, + output: .string(limit: .max), + error: .discarded + ) + #expect(result.terminationStatus.isSuccess) + // rdar://138670128 + let maybePath = result.standardOutput? + .trimmingNewLineAndQuotes() + let path = try #require(maybePath) + #expect(directory(path, isSameAs: expected)) + } + + @Test func testExecutableAtPathCannotResolve() async { + #if os(Windows) + let fakePath = FilePath("D:\\does\\not\\exist") + #else + let fakePath = FilePath("/usr/bin/do-not-exist") + #endif + do { + _ = try await Subprocess.run(.path(fakePath), output: .discarded) + Issue.record("Expected to throw SubprocessError") + } catch { + guard let subprocessError: SubprocessError = error as? SubprocessError else { + Issue.record("Expected SubprocessError, got \(error)") + return + } + #expect(subprocessError.code == .init(.executableNotFound(fakePath.string))) + } + } +} + +// MARK: - Argument Tests +extension SubprocessIntegrationTests { + @Test func testArgumentsArrayLiteral() async throws { + let message = "Hello World!" + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo", message] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo \(message)"] + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .string(limit: 32), + error: .discarded + ) + #expect(result.terminationStatus.isSuccess) + // rdar://138670128 + let output = result.standardOutput? + .trimmingNewLineAndQuotes() + #expect( + output == message + ) + } + + #if !os(Windows) + // Windows does not support argument 0 override + // This test will not compile on Windows + @Test func testArgumentsOverride() async throws { + let result = try await Subprocess.run( + .path("/bin/sh"), + arguments: .init( + executablePathOverride: "apple", + remainingValues: ["-c", "echo $0"] + ), + output: .string(limit: 16) + ) + #expect(result.terminationStatus.isSuccess) + // rdar://138670128 + let output = result.standardOutput? + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect( + output == "apple" + ) + } + #endif + + #if !os(Windows) + // Windows does not support byte array arguments + // This test will not compile on Windows + @Test func testArgumentsFromBytes() async throws { + let arguments: [UInt8] = Array("Data Content\0".utf8) + let result = try await Subprocess.run( + .path("/bin/echo"), + arguments: .init( + executablePathOverride: nil, + remainingValues: [arguments] + ), + output: .string(limit: 32) + ) + #expect(result.terminationStatus.isSuccess) + // rdar://138670128 + let output = result.standardOutput? + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect( + output == "Data Content" + ) + } + #endif +} + +// MARK: - Environment Tests +extension SubprocessIntegrationTests { + @Test func testEnvironmentInherit() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo %Path%"], + environment: .inherit + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "printenv PATH"], + environment: .inherit + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .string(limit: .max), + error: .discarded + ) + #expect(result.terminationStatus.isSuccess) + let pathValue = try #require(result.standardOutput) + #if os(Windows) + // As a sanity check, make sure there's + // `C:\Windows\system32` in PATH + // since we inherited the environment variables + #expect(pathValue.contains("C:\\Windows\\system32")) + #else + // As a sanity check, make sure there's `/bin` in PATH + // since we inherited the environment variables + // rdar://138670128 + #expect(pathValue.contains("/bin")) + #endif + } + + @Test func testEnvironmentInheritOverride() async throws { + #if os(Windows) + let path = "C:\\My\\New\\Home" + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo %HOMEPATH%"], + environment: .inherit.updating([ + "HOMEPATH": path + ]) + ) + #else + let path = "/my/new/home" + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "printenv HOMEPATH"], + environment: .inherit.updating([ + "HOMEPATH": path + ]) + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .string(limit: 32), + error: .discarded + ) + #expect(result.terminationStatus.isSuccess) + // rdar://138670128 + let output = result.standardOutput? + .trimmingNewLineAndQuotes() + #expect( + output == path + ) + } + + @Test( + // Make sure we don't accidentally have this dummy value + .enabled(if: ProcessInfo.processInfo.environment["SystemRoot"] != nil) + ) + func testEnvironmentCustom() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "set"], + environment: .custom([ + "Path": "C:\\Windows\\system32;C:\\Windows", + "ComSpec": "C:\\Windows\\System32\\cmd.exe", + ]) + ) + #else + let setup = TestSetup( + executable: .path("/usr/bin/printenv"), + arguments: [], + environment: .custom([ + "PATH": "/bin:/usr/bin" + ]) + ) + #endif + + let result = try await _run( + setup, + input: .none, + output: .string(limit: .max), + error: .discarded + ) + #expect(result.terminationStatus.isSuccess) + let output = try #require( + result.standardOutput + ).trimmingNewLineAndQuotes() + #if os(Windows) + // Make sure the newly launched process does + // NOT have `SystemRoot` in environment + #expect(!output.contains("SystemRoot")) + #else + // There shouldn't be any other environment variables besides + // `PATH` that we set + // rdar://138670128 + #expect( + output == "PATH=/bin:/usr/bin" + ) + #endif + } +} + +// MARK: - Working Directory Tests +extension SubprocessIntegrationTests { + @Test func testWorkingDirectoryDefaultValue() async throws { + // By default we should use the working directory of the parent process + let workingDirectory = FileManager.default.currentDirectoryPath + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "cd"], + workingDirectory: nil + ) + #else + let setup = TestSetup( + executable: .path("/bin/pwd"), + arguments: [], + workingDirectory: nil + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .string(limit: .max), + error: .discarded + ) + #expect(result.terminationStatus.isSuccess) + // There shouldn't be any other environment variables besides + // `PATH` that we set + // rdar://138670128 + let output = result.standardOutput? + .trimmingNewLineAndQuotes() + let path = try #require(output) + #expect(directory(path, isSameAs: workingDirectory)) + } + + @Test func testWorkingDirectoryCustomValue() async throws { + let workingDirectory = FilePath( + FileManager.default.temporaryDirectory._fileSystemPath + ) + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "cd"], + workingDirectory: workingDirectory + ) + #else + let setup = TestSetup( + executable: .path("/bin/pwd"), + arguments: [], + workingDirectory: workingDirectory + ) + #endif + + let result = try await _run( + setup, + input: .none, + output: .string(limit: .max), + error: .discarded + ) + #expect(result.terminationStatus.isSuccess) + // There shouldn't be any other environment variables besides + // `PATH` that we set + let resultPath = result.standardOutput! + .trimmingNewLineAndQuotes() + #if canImport(Darwin) + // On Darwin, /var is linked to /private/var; /tmp is linked to /private/tmp + var expected = workingDirectory + if expected.starts(with: "/var") || expected.starts(with: "/tmp") { + expected = FilePath("/private").appending(expected.components) + } + #expect( + FilePath(resultPath) == expected + ) + #else + #expect( + FilePath(resultPath) == workingDirectory + ) + #endif + } + + @Test func testWorkingDirectoryInvalidValue() async throws { + #if os(Windows) + let invalidPath: FilePath = FilePath(#"X:\Does\Not\Exist"#) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "cd"], + workingDirectory: invalidPath + ) + #else + let invalidPath: FilePath = FilePath("/does/not/exist") + let setup = TestSetup( + executable: .path("/bin/pwd"), + arguments: [], + workingDirectory: invalidPath + ) + #endif + + do { + _ = try await _run(setup, input: .none, output: .string(limit: .max), error: .discarded) + Issue.record("Expected to throw an error when working directory is invalid") + } catch { + guard let subprocessError = error as? SubprocessError else { + Issue.record("Expecting SubprocessError, got \(error)") + return + } + #expect(subprocessError.code == .init(.failedToChangeWorkingDirectory(invalidPath.string))) + } + } +} + +// MARK: - Input Tests +extension SubprocessIntegrationTests { + @Test func testNoInput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "more"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [] + ) + #endif + let catResult = try await _run( + setup, + input: .none, + output: .string(limit: 16), + error: .discarded + ) + #expect(catResult.terminationStatus.isSuccess) + // We should have read exactly 0 bytes + #expect(catResult.standardOutput == "") + } + + @Test func testStringInput() async throws { + let content = randomString(length: 100_000) + #if os(Windows) + let setup = TestSetup( + executable: .name("powershell.exe"), + arguments: [ + "-Command", + "while (($c = [Console]::In.Read()) -ne -1) { [Console]::Out.Write([char]$c); [Console]::Out.Flush() }" + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [] + ) + #endif + let catResult = try await _run( + setup, + input: .string(content, using: UTF8.self), + output: .string(limit: .max), + error: .discarded + ) + #expect(catResult.terminationStatus.isSuccess) + // Output should match the input content + #expect( + catResult.standardOutput?.trimmingNewLineAndQuotes() == content + ) + } + + @Test func testArrayInput() async throws { + let content = randomString(length: 64) + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "more"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [] + ) + #endif + let catResult = try await _run( + setup, + input: .array(Array(content.utf8)), + output: .string(limit: .max), + error: .discarded + ) + #expect(catResult.terminationStatus.isSuccess) + // Output should match the input content + #expect( + catResult.standardOutput?.trimmingNewLineAndQuotes() == + content.trimmingNewLineAndQuotes() + ) + } + + @Test func testFileDescriptorInput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x*", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [] + ) + #endif + // Make sure we can read long text from standard input + let expected: Data = try Data( + contentsOf: URL(filePath: theMysteriousIsland.string) + ) + let text: FileDescriptor = try .open( + theMysteriousIsland, + .readOnly + ) + let cat = try await _run( + setup, + input: .fileDescriptor(text, closeAfterSpawningProcess: true), + output: .data(limit: 2048 * 1024), + error: .discarded + ) + #expect(cat.terminationStatus.isSuccess) + // Make sure we read all bytes + #expect(cat.standardOutput == expected) + } + + #if SubprocessFoundation + @Test func testDataInput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x*", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [] + ) + #endif + // Make sure we can read long text as Sequence + let expected: Data = try Data( + contentsOf: URL(filePath: theMysteriousIsland.string) + ) + let catResult = try await _run( + setup, + input: .data(expected), + output: .data(limit: 2048 * 1024), + error: .discarded + ) + #expect(catResult.terminationStatus.isSuccess) + #expect(catResult.standardOutput.count == expected.count) + #expect(Array(catResult.standardOutput) == Array(expected)) + } + #endif + + #if SubprocessSpan + @Test func testSpanInput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x*", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [] + ) + #endif + let expected: Data = try Data( + contentsOf: URL(filePath: theMysteriousIsland.string) + ) + let ptr = expected.withUnsafeBytes { return $0 } + let span: Span = Span(_unsafeBytes: ptr) + let catResult = try await _run( + setup, + input: span, + output: .data(limit: 2048 * 1024), + error: .discarded + ) + #expect(catResult.terminationStatus.isSuccess) + #expect(catResult.standardOutput.count == expected.count) + #expect(Array(catResult.standardOutput) == Array(expected)) + } + #endif + + @Test func testAsyncSequenceInput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x*", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [] + ) + #endif + let chunkSize = 4096 + // Make sure we can read long text as AsyncSequence + let expected: Data = try Data( + contentsOf: URL(filePath: theMysteriousIsland.string) + ) + let stream: AsyncStream = AsyncStream { continuation in + DispatchQueue.global().async { + var currentStart = 0 + while currentStart + chunkSize < expected.count { + continuation.yield(expected[currentStart.. 0 { + continuation.yield(expected[currentStart..&2", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "cat 1>&2"] + ) + #endif + // Make sure we can read long text as Sequence + let expected: Data = try Data( + contentsOf: URL(filePath: theMysteriousIsland.string) + ) + let catResult = try await _run( + setup, + input: .data(expected), + output: .discarded, + error: .string(limit: 2048 * 1024, encoding: UTF8.self) + ) + let output = try #require( + catResult.standardError?.trimmingNewLineAndQuotes() + ) + #expect( + output == String( + decoding: expected, + as: Unicode.UTF8.self + ).trimmingNewLineAndQuotes() + ) + } + + @Test func testStringErrorOutputExceedsLimit() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x* \(theMysteriousIsland.string) 1>&2", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "cat \(theMysteriousIsland.string) 1>&2"] + ) + #endif + + do { + _ = try await _run( + setup, + input: .none, + output: .discarded, + error: .string(limit: 16) + ) + Issue.record("Expected to throw") + } catch { + guard let subprocessError = error as? SubprocessError else { + Issue.record("Expected SubprocessError, got \(error)") + return + } + #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) + } + } + + @Test func testBytesErrorOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x* 1>&2", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "cat 1>&2"] + ) + #endif + // Make sure we can read long text as Sequence + let expected: Data = try Data( + contentsOf: URL(filePath: theMysteriousIsland.string) + ) + let catResult = try await _run( + setup, + input: .data(expected), + output: .discarded, + error: .bytes(limit: 2048 * 1024) + ) + #expect( + catResult.standardError == Array(expected) + ) + } + + @Test func testBytesErrorOutputExceedsLimit() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x* \(theMysteriousIsland.string) 1>&2", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "cat \(theMysteriousIsland.string) 1>&2"] + ) + #endif + + do { + _ = try await _run( + setup, + input: .none, + output: .discarded, + error: .bytes(limit: 16) + ) + Issue.record("Expected to throw") + } catch { + guard let subprocessError = error as? SubprocessError else { + Issue.record("Expected SubprocessError, got \(error)") + return + } + #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) + } + } + + @Test func testFileDescriptorErrorOutput() async throws { + let expected = randomString(length: 32) + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo \(expected) 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo \(expected) 1>&2"] + ) + #endif + + let outputFilePath = FilePath(FileManager.default.temporaryDirectory._fileSystemPath) + .appending("TestError.out") + if FileManager.default.fileExists(atPath: outputFilePath.string) { + try FileManager.default.removeItem(atPath: outputFilePath.string) + } + let outputFile: FileDescriptor = try .open( + outputFilePath, + .readWrite, + options: .create, + permissions: [.ownerReadWrite, .groupReadWrite] + ) + let echoResult = try await _run( + setup, + input: .none, + output: .discarded, + error: .fileDescriptor( + outputFile, + closeAfterSpawningProcess: false + ) + ) + #expect(echoResult.terminationStatus.isSuccess) + try outputFile.close() + let outputData: Data = try Data( + contentsOf: URL(filePath: outputFilePath.string) + ) + let output = try #require( + String(data: outputData, encoding: .utf8) + ).trimmingNewLineAndQuotes() + #expect(echoResult.terminationStatus.isSuccess) + #expect(output == expected) + } + + @Test func testFileDescriptorErrorOutputAutoClose() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo Hello World", "1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo Hello World", "1>&2"] + ) + #endif + let outputFilePath = FilePath(FileManager.default.temporaryDirectory._fileSystemPath) + .appending("TestError.out") + if FileManager.default.fileExists(atPath: outputFilePath.string) { + try FileManager.default.removeItem(atPath: outputFilePath.string) + } + let outputFile: FileDescriptor = try .open( + outputFilePath, + .readWrite, + options: .create, + permissions: [.ownerReadWrite, .groupReadWrite] + ) + let echoResult = try await _run( + setup, + input: .none, + output: .discarded, + error: .fileDescriptor( + outputFile, + closeAfterSpawningProcess: true + ) + ) + #expect(echoResult.terminationStatus.isSuccess) + // Make sure the file descriptor is already closed + do { + try outputFile.close() + Issue.record("Output file descriptor should be closed automatically") + } catch { + guard let typedError = error as? Errno else { + Issue.record("Wrong type of error thrown") + return + } + #expect(typedError == .badFileDescriptor) + } + } + + @Test func testFileDescriptorOutputErrorToSameFile() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo Hello Stdout & echo Hello Stderr 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo Hello Stdout; echo Hello Stderr 1>&2"] + ) + #endif + + let outputFilePath = FilePath(FileManager.default.temporaryDirectory._fileSystemPath) + .appending("TestOutputErrorCombined.out") + if FileManager.default.fileExists(atPath: outputFilePath.string) { + try FileManager.default.removeItem(atPath: outputFilePath.string) + } + let outputFile: FileDescriptor = try .open( + outputFilePath, + .readWrite, + options: .create, + permissions: [.ownerReadWrite, .groupReadWrite] + ) + let echoResult = try await _run( + setup, + input: .none, + output: .fileDescriptor( + outputFile, + closeAfterSpawningProcess: false + ), + error: .fileDescriptor( + outputFile, + closeAfterSpawningProcess: false + ) + ) + #expect(echoResult.terminationStatus.isSuccess) + try outputFile.close() + let outputData: Data = try Data( + contentsOf: URL(filePath: outputFilePath.string) + ) + let output = try #require( + String(data: outputData, encoding: .utf8) + ).trimmingNewLineAndQuotes() + #expect(echoResult.terminationStatus.isSuccess) + } + + #if SubprocessFoundation + @Test func testDataErrorOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x* 1>&2", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "cat 1>&2"] + ) + #endif + // Make sure we can read long text as Sequence + let expected: Data = try Data( + contentsOf: URL(filePath: theMysteriousIsland.string) + ) + let catResult = try await _run( + setup, + input: .data(expected), + output: .discarded, + error: .data(limit: 2048 * 1024) + ) + #expect( + catResult.standardError == expected + ) + } + + @Test func testDataErrorOutputExceedsLimit() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x* \(theMysteriousIsland.string) 1>&2", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "cat \(theMysteriousIsland.string) 1>&2"] + ) + #endif + + do { + _ = try await _run( + setup, + input: .none, + output: .discarded, + error: .data(limit: 16) + ) + Issue.record("Expected to throw") + } catch { + guard let subprocessError = error as? SubprocessError else { + Issue.record("Expected SubprocessError, got \(error)") + return + } + #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) + } + } + #endif + + @Test func testStreamingErrorOutput() async throws { +#if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x* 1>&2", + ] + ) +#else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "cat 1>&2"] + ) +#endif + let expected: Data = try Data( + contentsOf: URL(filePath: theMysteriousIsland.string) + ) + let result = try await _run( + setup, + output: .discarded + ) { execution, standardInputWriter, standardError in + return try await withThrowingTaskGroup(of: Data?.self) { group in + group.addTask { + var buffer = Data() + for try await chunk in standardError { + let currentChunk = chunk.withUnsafeBytes { Data($0) } + buffer += currentChunk + } + return buffer + } + + group.addTask { + _ = try await standardInputWriter.write(Array(expected)) + try await standardInputWriter.finish() + return nil + } + + var buffer: Data! + while let result = try await group.next() { + if let result: Data = result { + buffer = result + } + } + return buffer + } + + } + #expect(result.terminationStatus.isSuccess) + #expect(result.value == expected) + } + + @Test func stressTestWithLittleOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo x & echo y 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo x; echo y 1>&2;"] + ) + #endif + + for _ in 0 ..< 128 { + let result = try await _run( + setup, + input: .none, + output: .string(limit: 4), + error: .string(limit: 4) + ) + #expect(result.terminationStatus.isSuccess) + #expect(result.standardOutput?.trimmingNewLineAndQuotes() == "x") + #expect(result.standardError?.trimmingNewLineAndQuotes() == "y") + } + } + + @Test func stressTestWithLongOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo x & echo y 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo x; echo y 1>&2;"] + ) + #endif + + for _ in 0 ..< 128 { + let result = try await _run( + setup, + input: .none, + output: .string(limit: 4), + error: .string(limit: 4) + ) + #expect(result.terminationStatus.isSuccess) + #expect(result.standardOutput?.trimmingNewLineAndQuotes() == "x") + #expect(result.standardError?.trimmingNewLineAndQuotes() == "y") + } + } + + @Test func testInheritingOutputAndError() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo Standard Output Testing & echo Standard Error Testing 1>&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo Standard Output Testing; echo Standard Error Testing 1>&2;"] + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .fileDescriptor(.standardOutput, closeAfterSpawningProcess: false), + error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false) + ) + #expect(result.terminationStatus.isSuccess) + } + + @Test func stressTestDiscardedOutput() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("powershell.exe"), + arguments: [ + "-Command", + """ + $size = 1MB + $data = New-Object byte[] $size + [Console]::OpenStandardOutput().Write($data, 0, $data.Length) + [Console]::OpenStandardError().Write($data, 0, $data.Length) + """ + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: [ + "-c", + "/bin/dd if=/dev/zero bs=\(1024*1024) count=1; /bin/dd >&2 if=/dev/zero bs=\(1024*1024) count=1;" + ] + ) + #endif + let result = try await _run(setup, input: .none, output: .discarded, error: .discarded) + #expect(result.terminationStatus.isSuccess) + } + + @Test func testCaptureEmptyOutputError() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", ""] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", ""] + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .string(limit: .max), + error: .string(limit: .max) + ) + #expect(result.terminationStatus.isSuccess) + #expect(result.standardOutput?.trimmingNewLineAndQuotes() == "") + #expect(result.standardError?.trimmingNewLineAndQuotes() == "") + } +} + +// MARK: - Other Tests +extension SubprocessIntegrationTests { + @Test func testTerminateProcess() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "timeout /t 99999 >nul"]) + #else + let setup = TestSetup( + executable: .path("/bin/sleep"), + arguments: ["infinite"] + ) + #endif + let stuckResult = try await _run( + // This will intentionally hang + setup, + input: .none, + error: .discarded + ) { subprocess, standardOutput in + // Make sure we can send signals to terminate the process + #if os(Windows) + try subprocess.terminate(withExitCode: 99) + #else + try subprocess.send(signal: .terminate) + #endif + for try await _ in standardOutput {} + } + + #if os(Windows) + guard case .exited(let exitCode) = stuckResult.terminationStatus else { + Issue.record("Wrong termination status reported: \(stuckResult.terminationStatus)") + return + } + #expect(exitCode == 99) + #else + guard case .unhandledException(let exception) = stuckResult.terminationStatus else { + Issue.record("Wrong termination status reported: \(stuckResult.terminationStatus)") + return + } + #expect(exception == Signal.terminate.rawValue) + #endif + } + + @Test func testLineSequence() async throws { + typealias TestCase = (value: String, count: Int, newLine: String) + enum TestCaseSize: CaseIterable { + case large // (1.0 ~ 2.0) * buffer size + case medium // (0.2 ~ 1.0) * buffer size + case small // Less than 16 characters + } + + let newLineCharacters: [[UInt8]] = [ + [0x0A], // Line feed + [0x0B], // Vertical tab + [0x0C], // Form feed + [0x0D], // Carriage return + [0x0D, 0x0A], // Carriage return + Line feed + [0xC2, 0x85], // New line + [0xE2, 0x80, 0xA8], // Line Separator + [0xE2, 0x80, 0xA9] // Paragraph separator + ] + + // Generate test cases + func generateString(size: TestCaseSize) -> [UInt8] { + // Basic Latin has the range U+0020 ... U+007E + let range: ClosedRange = 0x20 ... 0x7E + + let length: Int + switch size { + case .large: + length = Int(Double.random(in: 1.0 ..< 2.0) * Double(readBufferSize)) + 1 + case .medium: + length = Int(Double.random(in: 0.2 ..< 1.0) * Double(readBufferSize)) + 1 + case .small: + length = Int.random(in: 1 ..< 16) + } + + var buffer: [UInt8] = Array(repeating: 0, count: length) + for index in 0 ..< length { + buffer[index] = UInt8.random(in: range) + } + // Buffer cannot be empty or a line with a \r ending followed by an empty one with a \n ending would be indistinguishable. + // This matters for any line ending sequences where one line ending sequence is the prefix of another. \r and \r\n are the + // only two which meet this criteria. + precondition(!buffer.isEmpty) + return buffer + } + + // Generate at least 2 long lines that is longer than buffer size + func generateTestCases(count: Int) -> [TestCase] { + var targetSizes: [TestCaseSize] = TestCaseSize.allCases.flatMap { + Array(repeating: $0, count: count / 3) + } + // Fill the remainder + let remaining = count - targetSizes.count + let rest = TestCaseSize.allCases.shuffled().prefix(remaining) + targetSizes.append(contentsOf: rest) + // Do a final shuffle to achieve random order + targetSizes.shuffle() + // Now generate test cases based on sizes + var testCases: [TestCase] = [] + for size in targetSizes { + let components = generateString(size: size) + // Choose a random new line + let newLine = newLineCharacters.randomElement()! + let string = String(decoding: components + newLine, as: UTF8.self) + testCases.append(( + value: string, + count: components.count + newLine.count, + newLine: String(decoding: newLine, as: UTF8.self) + )) + } + return testCases + } + + func writeTestCasesToFile(_ testCases: [TestCase], at url: URL) throws { + #if canImport(Darwin) + FileManager.default.createFile(atPath: url.path(), contents: nil, attributes: nil) + let fileHadle = try FileHandle(forWritingTo: url) + for testCase in testCases { + fileHadle.write(testCase.value.data(using: .utf8)!) + } + try fileHadle.close() + #else + var result = "" + for testCase in testCases { + result += testCase.value + } + try result.write(to: url, atomically: true, encoding: .utf8) + #endif + } + + let testCaseCount = 60 + let testFilePath = URL.temporaryDirectory.appending(path: "NewLines-\(UUID().uuidString).txt") + if FileManager.default.fileExists(atPath: testFilePath.path()) { + try FileManager.default.removeItem(at: testFilePath) + } + let testCases = generateTestCases(count: testCaseCount) + try writeTestCasesToFile(testCases, at: testFilePath) + + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: [ + "/c", + "findstr x* \(testFilePath._fileSystemPath)", + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [testFilePath._fileSystemPath] + ) + #endif + + _ = try await _run( + setup, + input: .none, + error: .discarded + ) { execution, standardOutput in + var index = 0 + for try await line in standardOutput.lines(encoding: UTF8.self) { + defer { index += 1 } + try #require(index < testCases.count, "Received more lines than expected") + #expect( + line == testCases[index].value, + """ + Found mismatching line at index \(index) + Expected: [\(testCases[index].value)] + Actual: [\(line)] + Line Ending \(Array(testCases[index].newLine.utf8)) + """ + ) + } + } + try FileManager.default.removeItem(at: testFilePath) + } + + @Test func testCaptureLongStandardOutputAndError() async throws { + let string = String(repeating: "X", count: 100_000) + #if os(Windows) + let setup = TestSetup( + executable: .name("powershell.exe"), + arguments: [ + "-Command", + "while (($c = [Console]::In.Read()) -ne -1) { [Console]::Out.Write([char]$c); [Console]::Error.Write([char]$c); [Console]::Out.Flush(); [Console]::Error.Flush() }" + ] + ) + #else + let setup = TestSetup( + executable: .path("/usr/bin/tee"), + arguments: ["/dev/stderr"] + ) + #endif + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0 ..< 8 { + group.addTask { + // This invocation specifically requires bash semantics; sh (on FreeBSD at least) does not consistently support -s in this way + let r = try await _run( + setup, + input: .string(string), + output: .data(limit: .max), + error: .data(limit: .max) + ) + #expect(r.terminationStatus == .exited(0)) + #expect(r.standardOutput.count == 100_000, "Standard output actual \(r.standardOutput.count)") + #expect(r.standardError.count == 100_000, "Standard error actual \(r.standardError.count)") + } + try await group.next() + } + try await group.waitForAll() + } + } + + @Test func stressTestCancelProcessVeryEarlyOn() async throws { + + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "timeout /t 100000 /nobreak"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sleep"), + arguments: ["100000"] + ) + #endif + for i in 0 ..< 100 { + let terminationStatus = try await withThrowingTaskGroup( + of: TerminationStatus?.self, + returning: TerminationStatus.self + ) { group in + group.addTask { + var platformOptions = PlatformOptions() + platformOptions.teardownSequence = [] + + return try await _run( + setup, + platformOptions: platformOptions, + input: .none, + output: .string(limit: .max), + error: .discarded + ).terminationStatus + } + group.addTask { + let waitNS = UInt64.random(in: 0..<10_000_000) + try? await Task.sleep(nanoseconds: waitNS) + return nil + } + + while let result = try await group.next() { + if let result = result { + return result + } else { + group.cancelAll() + } + } + preconditionFailure("this should be impossible, task should've returned a result") + } + #if !os(Windows) + #expect(terminationStatus == .unhandledException(SIGKILL), "iteration \(i)") + #endif + } + } + + @Test func testExitCode() async throws { + for exitCode in UInt8.min ..< UInt8.max { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "exit \(exitCode)"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "exit \(exitCode)"] + ) + #endif + + let result = try await _run(setup, input: .none, output: .discarded, error: .discarded) + #expect(result.terminationStatus == .exited(TerminationStatus.Code(exitCode))) + } + } + + @Test func testInteractiveShell() async throws { + enum OutputCaptureState { + case standardOutputCaptured(String) + case standardErrorCaptured(String) + } + + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/Q", "/D"], + environment: .inherit.updating(["PROMPT": "\"\""]) + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: [] + ) + #endif + + let result = try await _run(setup) { execution, standardInputWriter, standardOutput, standardError in + return try await withThrowingTaskGroup(of: OutputCaptureState?.self) { group in + group.addTask { + #if os(Windows) + _ = try await standardInputWriter.write("echo off\n") + #endif + _ = try await standardInputWriter.write("echo hello stdout\n") + _ = try await standardInputWriter.write("echo >&2 hello stderr\n") + _ = try await standardInputWriter.write("exit 0\n") + try await standardInputWriter.finish() + return nil + } + group.addTask { + var result = "" + for try await line in standardOutput.lines() { + result += line + } + return .standardOutputCaptured(result.trimmingNewLineAndQuotes()) + } + group.addTask { + var result = "" + for try await line in standardError.lines() { + result += line + } + return .standardErrorCaptured(result.trimmingNewLineAndQuotes()) + } + + var output: String = "" + var error: String = "" + while let state = try await group.next() { + switch state { + case .some(.standardOutputCaptured(let string)): + output = string + case .some(.standardErrorCaptured(let string)): + error = string + case .none: + continue + } + } + + return (output: output, error: error) + } + } + + #expect(result.terminationStatus.isSuccess) + #if os(Windows) + // cmd.exe interactive mode prints more info + #expect(result.value.output.contains("hello stdout")) + #else + #expect(result.value.output == "hello stdout") + #endif + #expect(result.value.error == "hello stderr") + } + + @Test( + .disabled("Linux requires #46 to be fixed", { + #if os(Linux) + return true + #else + return false + #endif + }), + .bug("https://github.com/swiftlang/swift-subprocess/issues/46") + ) + func testSubprocessPipeChain() async throws { + struct Pipe: @unchecked Sendable { + #if os(Windows) + let readEnd: HANDLE + let writeEnd: HANDLE + #else + let readEnd: FileDescriptor + let writeEnd: FileDescriptor + #endif + } + + // This is NOT the final piping API that we want + // This test only makes sure it's possible to create + // a chain of subprocess + #if os(Windows) + // On Windows we need to set inheritability of each end + // differently for each process + var readHandle: HANDLE? = nil + var writeHandle: HANDLE? = nil + guard CreatePipe(&readHandle, &writeHandle, nil, 0), + readHandle != INVALID_HANDLE_VALUE, + writeHandle != INVALID_HANDLE_VALUE, + let readHandle: HANDLE = readHandle, + let writeHandle: HANDLE = writeHandle + else { + throw SubprocessError( + code: .init(.failedToCreatePipe), + underlyingError: .init(rawValue: GetLastError()) + ) + } + SetHandleInformation(readHandle, HANDLE_FLAG_INHERIT, 0) + SetHandleInformation(writeHandle, HANDLE_FLAG_INHERIT, 0) + let pipe = Pipe(readEnd: readHandle, writeEnd: writeHandle) + #else + let _pipe = try FileDescriptor.pipe() + let pipe: Pipe = Pipe(readEnd: _pipe.readEnd, writeEnd: _pipe.writeEnd) + #endif + try await withThrowingTaskGroup { group in + group.addTask { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "echo apple"] + ) + // Set write handle to be inheritable only + var writeEndHandle: HANDLE? = nil + guard DuplicateHandle( + GetCurrentProcess(), + pipe.writeEnd, + GetCurrentProcess(), + &writeEndHandle, + 0, true, DWORD(DUPLICATE_SAME_ACCESS) + ) else { + throw SubprocessError( + code: .init(.failedToCreatePipe), + underlyingError: .init(rawValue: GetLastError()) + ) + } + guard let writeEndHandle else { + throw SubprocessError( + code: .init(.failedToCreatePipe), + underlyingError: .init(rawValue: GetLastError()) + ) + } + CloseHandle(pipe.writeEnd) // No longer need the original + // Allow Subprocess to inherit writeEnd + SetHandleInformation(writeEndHandle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) + let writeEndFd = _open_osfhandle( + intptr_t(bitPattern: writeEndHandle), + FileDescriptor.AccessMode.writeOnly.rawValue + ) + let writeEnd = FileDescriptor(rawValue: writeEndFd) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "echo apple"] + ) + let writeEnd = pipe.writeEnd + #endif + _ = try await _run( + setup, + input: .none, + output: .fileDescriptor( + writeEnd, + closeAfterSpawningProcess: true + ), + error: .discarded + ) + } + group.addTask { + #if os(Windows) + let setup = TestSetup( + executable: .name("powershell.exe"), + arguments: ["-Command", "[Console]::In.ReadToEnd().ToUpper()"] + ) + // Set read handle to be inheritable only + var readEndHandle: HANDLE? = nil + guard DuplicateHandle( + GetCurrentProcess(), + pipe.readEnd, + GetCurrentProcess(), + &readEndHandle, + 0, true, DWORD(DUPLICATE_SAME_ACCESS) + ) else { + throw SubprocessError( + code: .init(.failedToCreatePipe), + underlyingError: .init(rawValue: GetLastError()) + ) + } + guard let readEndHandle else { + throw SubprocessError( + code: .init(.failedToCreatePipe), + underlyingError: .init(rawValue: GetLastError()) + ) + } + CloseHandle(pipe.readEnd) // No longer need the original + // Allow Subprocess to inherit writeEnd + SetHandleInformation(readEndHandle, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) + let readEndFd = _open_osfhandle( + intptr_t(bitPattern: readEndHandle), + FileDescriptor.AccessMode.writeOnly.rawValue + ) + let readEnd = FileDescriptor(rawValue: readEndFd) + #else + let setup = TestSetup( + executable: .path("/usr/bin/tr"), + arguments: ["[:lower:]", "[:upper:]"] + ) + let readEnd = pipe.readEnd + #endif + let result = try await _run( + setup, + input: .fileDescriptor(readEnd, closeAfterSpawningProcess: true), + output: .string(limit: 32), + error: .discarded + ) + + #expect(result.terminationStatus.isSuccess) + #expect(result.standardOutput?.trimmingNewLineAndQuotes() == "APPLE") + } + + try await group.waitForAll() + } + } + + @Test func testLineSequenceNoNewLines() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .path(#"C:\Windows\System32\cmd.exe"#), + arguments: ["/c", "&2"] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "/bin/echo -n x; /bin/echo >&2 -n y"] + ) + #endif + _ = try await _run(setup) { execution, inputWriter, standardOutput, standardError in + try await withThrowingTaskGroup { group in + group.addTask { + try await inputWriter.finish() + } + + group.addTask { + var result = "" + for try await line in standardOutput.lines() { + result += line + } + #expect(result.trimmingNewLineAndQuotes() == "x") + } + + group.addTask { + var result = "" + for try await line in standardError.lines() { + result += line + } + #expect(result.trimmingNewLineAndQuotes() == "y") + } + try await group.waitForAll() + } + } + } +} + +// MARK: - Utilities +extension String { + func trimmingNewLineAndQuotes() -> String { + var characterSet = CharacterSet.whitespacesAndNewlines + characterSet.insert(charactersIn: "\"") + return self.trimmingCharacters(in: characterSet) + } +} + +// Easier to support platform differences +struct TestSetup { + let executable: Subprocess.Executable + let arguments: Subprocess.Arguments + let environment: Subprocess.Environment + let workingDirectory: FilePath? + + init( + executable: Subprocess.Executable, + arguments: Subprocess.Arguments, + environment: Subprocess.Environment = .inherit, + workingDirectory: FilePath? = nil + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.workingDirectory = workingDirectory + } +} + +func _run< + Input: InputProtocol, + Output: OutputProtocol, + Error: OutputProtocol +>( + _ testSetup: TestSetup, + platformOptions: PlatformOptions = PlatformOptions(), + input: Input, + output: Output, + error: Error +) async throws -> CollectedResult { + return try await Subprocess.run( + testSetup.executable, + arguments: testSetup.arguments, + environment: testSetup.environment, + workingDirectory: testSetup.workingDirectory, + platformOptions: platformOptions, + input: input, + output: output, + error: error + ) +} + +#if SubprocessSpan +func _run< + InputElement: BitwiseCopyable, + Output: OutputProtocol, + Error: OutputProtocol +>( + _ testSetup: TestSetup, + input: borrowing Span, + output: Output, + error: Error +) async throws -> CollectedResult { + return try await Subprocess.run( + testSetup.executable, + arguments: testSetup.arguments, + environment: testSetup.environment, + workingDirectory: testSetup.workingDirectory, + input: input, + output: output, + error: error + ) +} +#endif + +func _run< + Result, + Input: InputProtocol, + Error: OutputProtocol +>( + _ setup: TestSetup, + input: Input, + error: Error, + body: ((Execution, AsyncBufferSequence) async throws -> Result) +) async throws -> ExecutionResult where Error.OutputType == Void { + return try await Subprocess.run( + setup.executable, + arguments: setup.arguments, + environment: setup.environment, + workingDirectory: setup.workingDirectory, + input: input, + error: error, + body: body + ) +} + +func _run< + Result, + Error: OutputProtocol +>( + _ setup: TestSetup, + error: Error, + body: ((Execution, StandardInputWriter, AsyncBufferSequence) async throws -> Result) +) async throws -> ExecutionResult where Error.OutputType == Void { + return try await Subprocess.run( + setup.executable, + arguments: setup.arguments, + environment: setup.environment, + workingDirectory: setup.workingDirectory, + error: error, + body: body + ) +} + +func _run< + Result, + Output: OutputProtocol +>( + _ setup: TestSetup, + output: Output, + body: ((Execution, StandardInputWriter, AsyncBufferSequence) async throws -> Result) +) async throws -> ExecutionResult where Output.OutputType == Void { + return try await Subprocess.run( + setup.executable, + arguments: setup.arguments, + environment: setup.environment, + workingDirectory: setup.workingDirectory, + output: output, + body: body + ) +} + +func _run( + _ setup: TestSetup, + body: ((Execution, StandardInputWriter, AsyncBufferSequence, AsyncBufferSequence) async throws -> Result) +) async throws -> ExecutionResult { + return try await Subprocess.run( + setup.executable, + arguments: setup.arguments, + environment: setup.environment, + workingDirectory: setup.workingDirectory, + body: body + ) +} + diff --git a/Tests/SubprocessTests/SubprocessTests+Linting.swift b/Tests/SubprocessTests/LinterTests.swift similarity index 100% rename from Tests/SubprocessTests/SubprocessTests+Linting.swift rename to Tests/SubprocessTests/LinterTests.swift diff --git a/Tests/SubprocessTests/SubprocessTests+Linux.swift b/Tests/SubprocessTests/LinuxTests.swift similarity index 100% rename from Tests/SubprocessTests/SubprocessTests+Linux.swift rename to Tests/SubprocessTests/LinuxTests.swift diff --git a/Tests/SubprocessTests/SubprocessTests+Unix.swift b/Tests/SubprocessTests/SubprocessTests+Unix.swift deleted file mode 100644 index 289bfee0..00000000 --- a/Tests/SubprocessTests/SubprocessTests+Unix.swift +++ /dev/null @@ -1,1182 +0,0 @@ -//===----------------------------------------------------------------------===// -// -// This source file is part of the Swift.org open source project -// -// Copyright (c) 2025 Apple Inc. and the Swift project authors -// Licensed under Apache License v2.0 with Runtime Library Exception -// -// See https://swift.org/LICENSE.txt for license information -// -//===----------------------------------------------------------------------===// - -#if canImport(Darwin) || canImport(Glibc) - -#if canImport(Darwin) -// On Darwin always prefer system Foundation -import Foundation -#else -// On other platforms prefer FoundationEssentials -import FoundationEssentials -#endif - -#if canImport(Glibc) -import Glibc -#elseif canImport(Bionic) -import Bionic -#elseif canImport(Musl) -import Musl -#endif - -import _SubprocessCShims -import Testing -@testable import Subprocess - -import TestResources - -import Dispatch -#if canImport(System) -@preconcurrency import System -#else -@preconcurrency import SystemPackage -#endif - -@Suite(.serialized) -struct SubprocessUnixTests {} - -// MARK: - Executable test -extension SubprocessUnixTests { - - @Test func testExecutableNamed() async throws { - // Simple test to make sure we can find a common utility - let message = "Hello, world!" - let result = try await Subprocess.run( - .name("echo"), - arguments: [message], - output: .string(limit: 32) - ) - #expect(result.terminationStatus.isSuccess) - // rdar://138670128 - let output = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect(output == message) - } - - @Test func testExecutableNamedCannotResolve() async { - do { - _ = try await Subprocess.run(.name("do-not-exist"), output: .discarded) - Issue.record("Expected to throw") - } catch { - guard let subprocessError: SubprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.executableNotFound("do-not-exist"))) - } - } - - @Test func testExecutableAtPath() async throws { - let expected = FileManager.default.currentDirectoryPath - let result = try await Subprocess.run(.path("/bin/pwd"), output: .string(limit: .max)) - #expect(result.terminationStatus.isSuccess) - // rdar://138670128 - let maybePath = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) - let path = try #require(maybePath) - #expect(directory(path, isSameAs: expected)) - } - - @Test func testExecutableAtPathCannotResolve() async { - do { - _ = try await Subprocess.run(.path("/usr/bin/do-not-exist"), output: .discarded) - Issue.record("Expected to throw SubprocessError") - } catch { - guard let subprocessError: SubprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.executableNotFound("/usr/bin/do-not-exist"))) - } - } -} - -// MARK: - Arguments Tests -extension SubprocessUnixTests { - @Test func testArgumentsArrayLiteral() async throws { - let result = try await Subprocess.run( - .path("/bin/sh"), - arguments: ["-c", "echo Hello World!"], - output: .string(limit: 32) - ) - #expect(result.terminationStatus.isSuccess) - // rdar://138670128 - let output = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect( - output == "Hello World!" - ) - } - - @Test func testArgumentsOverride() async throws { - let result = try await Subprocess.run( - .path("/bin/sh"), - arguments: .init( - executablePathOverride: "apple", - remainingValues: ["-c", "echo $0"] - ), - output: .string(limit: 32) - ) - #expect(result.terminationStatus.isSuccess) - // rdar://138670128 - let output = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect( - output == "apple" - ) - } - - @Test func testArgumentsFromArray() async throws { - let arguments: [UInt8] = Array("Data Content\0".utf8) - let result = try await Subprocess.run( - .path("/bin/echo"), - arguments: .init( - executablePathOverride: nil, - remainingValues: [arguments] - ), - output: .string(limit: 32) - ) - #expect(result.terminationStatus.isSuccess) - // rdar://138670128 - let output = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect( - output == "Data Content" - ) - } -} - -// MARK: - Environment Tests -extension SubprocessUnixTests { - @Test func testEnvironmentInherit() async throws { - let result = try await Subprocess.run( - .path("/bin/sh"), - arguments: ["-c", "printenv PATH"], - environment: .inherit, - output: .string(limit: .max) - ) - #expect(result.terminationStatus.isSuccess) - // As a sanity check, make sure there's `/bin` in PATH - // since we inherited the environment variables - // rdar://138670128 - let maybeOutput = result.standardOutput - let pathValue = try #require(maybeOutput) - #expect(pathValue.contains("/bin")) - } - - @Test func testEnvironmentInheritOverride() async throws { - let result = try await Subprocess.run( - .path("/bin/sh"), - arguments: ["-c", "printenv HOME"], - environment: .inherit.updating([ - "HOME": "/my/new/home" - ]), - output: .string(limit: 32) - ) - #expect(result.terminationStatus.isSuccess) - // rdar://138670128 - let output = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect( - output == "/my/new/home" - ) - } - - @Test func testEnvironmentCustom() async throws { - let result = try await Subprocess.run( - .path("/usr/bin/printenv"), - environment: .custom([ - "PATH": "/bin:/usr/bin" - ]), - output: .string(limit: 32) - ) - #expect(result.terminationStatus.isSuccess) - // There shouldn't be any other environment variables besides - // `PATH` that we set - // rdar://138670128 - let output = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect( - output == "PATH=/bin:/usr/bin" - ) - } -} - -// MARK: - Working Directory Tests -extension SubprocessUnixTests { - @Test func testWorkingDirectoryDefaultValue() async throws { - // By default we should use the working directory of the parent process - let workingDirectory = FileManager.default.currentDirectoryPath - let result = try await Subprocess.run( - .path("/bin/pwd"), - workingDirectory: nil, - output: .string(limit: .max) - ) - #expect(result.terminationStatus.isSuccess) - // There shouldn't be any other environment variables besides - // `PATH` that we set - // rdar://138670128 - let output = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) - let path = try #require(output) - #expect(directory(path, isSameAs: workingDirectory)) - } - - @Test func testWorkingDirectoryCustomValue() async throws { - let workingDirectory = FilePath( - FileManager.default.temporaryDirectory.path() - ) - let result = try await Subprocess.run( - .path("/bin/pwd"), - workingDirectory: workingDirectory, - output: .string(limit: .max) - ) - #expect(result.terminationStatus.isSuccess) - // There shouldn't be any other environment variables besides - // `PATH` that we set - let resultPath = result.standardOutput! - .trimmingCharacters(in: .whitespacesAndNewlines) - #if canImport(Darwin) - // On Darwin, /var is linked to /private/var; /tmp is linked to /private/tmp - var expected = workingDirectory - if expected.starts(with: "/var") || expected.starts(with: "/tmp") { - expected = FilePath("/private").appending(expected.components) - } - #expect( - FilePath(resultPath) == expected - ) - #else - #expect( - FilePath(resultPath) == workingDirectory - ) - #endif - } -} - -// MARK: - Input Tests -extension SubprocessUnixTests { - @Test func testInputNoInput() async throws { - let catResult = try await Subprocess.run( - .path("/bin/cat"), - input: .none, - output: .string(limit: 16) - ) - #expect(catResult.terminationStatus.isSuccess) - // We should have read exactly 0 bytes - #expect(catResult.standardOutput == "") - } - - @Test func testStringInput() async throws { - let content = randomString(length: 64) - let catResult = try await Subprocess.run( - .path("/bin/cat"), - input: .string(content, using: UTF8.self), - output: .string(limit: 64) - ) - #expect(catResult.terminationStatus.isSuccess) - // Output should match the input content - #expect(catResult.standardOutput == content) - } - - @Test func testInputFileDescriptor() async throws { - // Make sure we can read long text from standard input - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let text: FileDescriptor = try .open( - theMysteriousIsland, - .readOnly - ) - let cat = try await Subprocess.run( - .path("/bin/cat"), - input: .fileDescriptor(text, closeAfterSpawningProcess: true), - output: .data(limit: 2048 * 1024) - ) - #expect(cat.terminationStatus.isSuccess) - // Make sure we read all bytes - #expect(cat.standardOutput == expected) - } - - @Test func testInputSequence() async throws { - // Make sure we can read long text as Sequence - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let catResult = try await Subprocess.run( - .path("/bin/cat"), - input: .data(expected), - output: .data(limit: 2048 * 1024) - ) - #expect(catResult.terminationStatus.isSuccess) - #expect(catResult.standardOutput.count == expected.count) - #expect(Array(catResult.standardOutput) == Array(expected)) - } - - #if SubprocessSpan - @Test func testInputSpan() async throws { - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let ptr = expected.withUnsafeBytes { return $0 } - let span: Span = Span(_unsafeBytes: ptr) - let catResult = try await Subprocess.run( - .path("/bin/cat"), - input: span, - output: .data(limit: 2048 * 1024) - ) - #expect(catResult.terminationStatus.isSuccess) - #expect(catResult.standardOutput.count == expected.count) - #expect(Array(catResult.standardOutput) == Array(expected)) - } - #endif - - @Test func testInputAsyncSequence() async throws { - // Make sure we can read long text as AsyncSequence - let fd: FileDescriptor = try .open(theMysteriousIsland, .readOnly) - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let channel = DispatchIO(type: .stream, fileDescriptor: fd.rawValue, queue: .main) { error in - try? fd.close() - } - let stream: AsyncStream = AsyncStream { continuation in - channel.read(offset: 0, length: .max, queue: .main) { done, data, error in - if done { - continuation.finish() - } - guard let data = data else { - return - } - continuation.yield(Data(data)) - } - } - let catResult = try await Subprocess.run( - .path("/bin/cat"), - input: .sequence(stream), - output: .data(limit: 2048 * 1024) - ) - #expect(catResult.terminationStatus.isSuccess) - #expect(catResult.standardOutput == expected) - } - - @Test func testInputSequenceCustomExecutionBody() async throws { - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let result = try await Subprocess.run( - .path("/bin/cat"), - input: .data(expected), - error: .discarded - ) { execution, standardOutput in - var buffer = Data() - for try await chunk in standardOutput { - let currentChunk = chunk.withUnsafeBytes { Data($0) } - buffer += currentChunk - } - return buffer - } - #expect(result.terminationStatus.isSuccess) - #expect(result.value == expected) - } - - @Test func testInputAsyncSequenceCustomExecutionBody() async throws { - // Make sure we can read long text as AsyncSequence - let fd: FileDescriptor = try .open(theMysteriousIsland, .readOnly) - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let channel = DispatchIO(type: .stream, fileDescriptor: fd.rawValue, queue: .main) { error in - try? fd.close() - } - let stream: AsyncStream = AsyncStream { continuation in - channel.read(offset: 0, length: .max, queue: .main) { done, data, error in - if done { - continuation.finish() - } - guard let data = data else { - return - } - continuation.yield(Data(data)) - } - } - let result = try await Subprocess.run( - .path("/bin/cat"), - input: .sequence(stream), - error: .discarded - ) { execution, standardOutput in - var buffer = Data() - for try await chunk in standardOutput { - let currentChunk = chunk.withUnsafeBytes { Data($0) } - buffer += currentChunk - } - return buffer - } - #expect(result.terminationStatus.isSuccess) - #expect(result.value == expected) - } -} - -// MARK: - Output Tests -extension SubprocessUnixTests { - #if false // This test needs "death test" support - @Test func testOutputDiscarded() async throws { - let echoResult = try await Subprocess.run( - .path("/bin/echo"), - arguments: ["Some garbage text"], - output: .discard - ) - #expect(echoResult.terminationStatus.isSuccess) - _ = echoResult.standardOutput // this line should fatalError - } - #endif - - @Test func testCollectedOutput() async throws { - let expected = try Data(contentsOf: URL(filePath: theMysteriousIsland.string)) - let echoResult = try await Subprocess.run( - .path("/bin/cat"), - arguments: [theMysteriousIsland.string], - output: .data(limit: .max) - ) - #expect(echoResult.terminationStatus.isSuccess) - #expect(echoResult.standardOutput == expected) - } - - @Test func testCollectedOutputExceedsLimit() async throws { - do { - _ = try await Subprocess.run( - .path("/bin/cat"), - arguments: [theMysteriousIsland.string], - output: .string(limit: 16), - ) - Issue.record("Expected to throw") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) - } - } - - @Test func testCollectedOutputFileDescriptor() async throws { - let outputFilePath = FilePath(FileManager.default.temporaryDirectory.path()) - .appending("Test.out") - if FileManager.default.fileExists(atPath: outputFilePath.string) { - try FileManager.default.removeItem(atPath: outputFilePath.string) - } - let outputFile: FileDescriptor = try .open( - outputFilePath, - .readWrite, - options: .create, - permissions: [.ownerReadWrite, .groupReadWrite] - ) - let expected = randomString(length: 32) - let echoResult = try await Subprocess.run( - .path("/bin/echo"), - arguments: [expected], - output: .fileDescriptor( - outputFile, - closeAfterSpawningProcess: false - ) - ) - #expect(echoResult.terminationStatus.isSuccess) - try outputFile.close() - let outputData: Data = try Data( - contentsOf: URL(filePath: outputFilePath.string) - ) - let output = try #require( - String(data: outputData, encoding: .utf8) - ).trimmingCharacters(in: .whitespacesAndNewlines) - #expect(echoResult.terminationStatus.isSuccess) - #expect(output == expected) - } - - @Test func testCollectedOutputFileDescriptorAutoClose() async throws { - let outputFilePath = FilePath(FileManager.default.temporaryDirectory.path()) - .appending("Test.out") - if FileManager.default.fileExists(atPath: outputFilePath.string) { - try FileManager.default.removeItem(atPath: outputFilePath.string) - } - let outputFile: FileDescriptor = try .open( - outputFilePath, - .readWrite, - options: .create, - permissions: [.ownerReadWrite, .groupReadWrite] - ) - let echoResult = try await Subprocess.run( - .path("/bin/echo"), - arguments: ["Hello world"], - output: .fileDescriptor( - outputFile, - closeAfterSpawningProcess: true - ) - ) - #expect(echoResult.terminationStatus.isSuccess) - // Make sure the file descriptor is already closed - do { - try outputFile.close() - Issue.record("Output file descriptor should be closed automatically") - } catch { - guard let typedError = error as? Errno else { - Issue.record("Wrong type of error thrown") - return - } - #expect(typedError == .badFileDescriptor) - } - } - - @Test func testRedirectedOutputWithUnsafeBytes() async throws { - // Make sure we can read long text redirected to AsyncSequence - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let catResult = try await Subprocess.run( - .path("/bin/cat"), - arguments: [theMysteriousIsland.string], - error: .discarded - ) { execution, standardOutput in - var buffer = Data() - for try await chunk in standardOutput { - let currentChunk = chunk.withUnsafeBytes { Data($0) } - buffer += currentChunk - } - return buffer - } - #expect(catResult.terminationStatus.isSuccess) - #expect(catResult.value == expected) - } - - #if SubprocessSpan - @Test func testRedirectedOutputBytes() async throws { - // Make sure we can read long text redirected to AsyncSequence - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let catResult = try await Subprocess.run( - .path("/bin/cat"), - arguments: [theMysteriousIsland.string] - ) { (execution: Execution, standardOutput: AsyncBufferSequence) -> Data in - var buffer: Data = Data() - for try await chunk in standardOutput { - buffer += chunk.withUnsafeBytes { Data(bytes: $0.baseAddress!, count: chunk.count) } - } - return buffer - } - #expect(catResult.terminationStatus.isSuccess) - #expect(catResult.value == expected) - } - #endif - - @Test func testBufferOutput() async throws { - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let inputFd: FileDescriptor = try .open(theMysteriousIsland, .readOnly) - let catResult = try await Subprocess.run( - .path("/bin/cat"), - input: .fileDescriptor(inputFd, closeAfterSpawningProcess: true), - output: .bytes(limit: 2048 * 1024) - ) - #expect(catResult.terminationStatus.isSuccess) - #expect(expected.elementsEqual(catResult.standardOutput)) - } - - @Test func testCollectedError() async throws { - // Make sure we can capture long text on standard error - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let catResult = try await Subprocess.run( - .path("/bin/sh"), - arguments: ["-c", "cat \(theMysteriousIsland.string) 1>&2"], - output: .discarded, - error: .data(limit: 2048 * 1024) - ) - #expect(catResult.terminationStatus.isSuccess) - #expect(catResult.standardError == expected) - } -} - -#if SubprocessSpan -extension Data { - init(bytes: borrowing RawSpan) { - let data = bytes.withUnsafeBytes { - return Data(bytes: $0.baseAddress!, count: $0.count) - } - self = data - } -} -#endif - -// MARK: - PlatformOption Tests -extension SubprocessUnixTests { - // Run this test with sudo - @Test( - .enabled( - if: getgid() == 0, - "This test requires root privileges" - ) - ) - func testSubprocessPlatformOptionsUserID() async throws { - let expectedUserID = uid_t(Int.random(in: 1000...2000)) - var platformOptions = PlatformOptions() - platformOptions.userID = expectedUserID - try await self.assertID( - withArgument: "-u", - platformOptions: platformOptions, - isEqualTo: expectedUserID - ) - } - - // Run this test with sudo - @Test( - .enabled( - if: getgid() == 0, - "This test requires root privileges" - ) - ) - func testSubprocessPlatformOptionsGroupID() async throws { - let expectedGroupID = gid_t(Int.random(in: 1000...2000)) - var platformOptions = PlatformOptions() - platformOptions.groupID = expectedGroupID - try await self.assertID( - withArgument: "-g", - platformOptions: platformOptions, - isEqualTo: expectedGroupID - ) - } - - // Run this test with sudo - @Test( - .enabled( - if: getgid() == 0, - "This test requires root privileges" - ) - ) - func testSubprocessPlatformOptionsSupplementaryGroups() async throws { - var expectedGroups: Set = Set() - for _ in 0..[\-]?[0-9]+)\s*(?[\-]?[0-9]+)\s*/#.wholeMatch(in: resultValue), "ps output was in an unexpected format:\n\n\(resultValue)") - // PGID should == PID - #expect(match.output.pid == match.output.pgid) - } - - @Test( - .enabled( - if: (try? Executable.name("ps").resolveExecutablePath(in: .inherit)) != nil, - "This test requires ps (install procps package on Debian or RedHat Linux distros)" - ) - ) - func testSubprocessPlatformOptionsCreateSession() async throws { - // platformOptions.createSession implies calls to setsid - var platformOptions = PlatformOptions() - platformOptions.createSession = true - // Check the process ID (pid), process group ID (pgid), and - // controlling terminal's process group ID (tpgid) - let psResult = try await Subprocess.run( - .path("/bin/sh"), - arguments: ["-c", "ps -o pid,pgid,tpgid -p $$"], - platformOptions: platformOptions, - output: .string(limit: .max) - ) - try assertNewSessionCreated(with: psResult) - } - - @Test(.requiresBash) func testTeardownSequence() async throws { - let result = try await Subprocess.run( - .name("bash"), - arguments: [ - "-c", - """ - set -e - trap 'echo saw SIGQUIT;' QUIT - trap 'echo saw SIGTERM;' TERM - trap 'echo saw SIGINT; exit 42;' INT - while true; do sleep 1; done - exit 2 - """, - ], - input: .none, - error: .discarded - ) { subprocess, standardOutput in - return try await withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - try await Task.sleep(for: .milliseconds(200)) - // Send shut down signal - await subprocess.teardown(using: [ - .send(signal: .quit, allowedDurationToNextStep: .milliseconds(500)), - .send(signal: .terminate, allowedDurationToNextStep: .milliseconds(500)), - .send(signal: .interrupt, allowedDurationToNextStep: .milliseconds(1000)), - ]) - } - group.addTask { - var outputs: [String] = [] - for try await line in standardOutput.lines() { - outputs.append(line.trimmingCharacters(in: .newlines)) - } - #expect(outputs == ["saw SIGQUIT", "saw SIGTERM", "saw SIGINT"]) - } - try await group.waitForAll() - } - } - #expect(result.terminationStatus == .exited(42)) - } -} - -// MARK: - Misc -extension SubprocessUnixTests { - @Test func testTerminateProcess() async throws { - let stuckResult = try await Subprocess.run( - // This will intentionally hang - .path("/bin/sleep"), - arguments: ["infinity"], - output: .discarded, - error: .discarded - ) { subprocess in - // Make sure we can send signals to terminate the process - try subprocess.send(signal: .terminate) - } - guard case .unhandledException(let exception) = stuckResult.terminationStatus else { - Issue.record("Wrong termination status reported: \(stuckResult.terminationStatus)") - return - } - #expect(exception == Signal.terminate.rawValue) - } - - @Test func testExitSignal() async throws { - let signalsToTest: [CInt] = [SIGKILL, SIGTERM, SIGINT] - for signal in signalsToTest { - let result = try await Subprocess.run( - .path("/bin/sh"), - arguments: ["-c", "kill -\(signal) $$"], - output: .discarded - ) - #expect(result.terminationStatus == .unhandledException(signal)) - } - } - - @Test func testCanReliablyKillProcessesEvenWithSigmask() async throws { - let result = try await withThrowingTaskGroup( - of: TerminationStatus?.self, - returning: TerminationStatus.self - ) { group in - group.addTask { - return try await Subprocess.run( - .path("/bin/sh"), - arguments: ["-c", "trap 'echo no' TERM; while true; do sleep 1; done"], - output: .string(limit: .max) - ).terminationStatus - } - group.addTask { - try? await Task.sleep(nanoseconds: 100_000_000) - return nil - } - while let result = try await group.next() { - group.cancelAll() - if let result = result { - return result - } - } - preconditionFailure("Task should have returned a result") - } - #expect(result == .unhandledException(SIGKILL)) - } - - @Test func testLineSequence() async throws { - typealias TestCase = (value: String, count: Int, newLine: String) - enum TestCaseSize: CaseIterable { - case large // (1.0 ~ 2.0) * buffer size - case medium // (0.2 ~ 1.0) * buffer size - case small // Less than 16 characters - } - - let newLineCharacters: [[UInt8]] = [ - [0x0A], // Line feed - [0x0B], // Vertical tab - [0x0C], // Form feed - [0x0D], // Carriage return - [0x0D, 0x0A], // Carriage return + Line feed - [0xC2, 0x85], // New line - [0xE2, 0x80, 0xA8], // Line Separator - [0xE2, 0x80, 0xA9] // Paragraph separator - ] - - // Generate test cases - func generateString(size: TestCaseSize) -> [UInt8] { - // Basic Latin has the range U+0020 ... U+007E - let range: ClosedRange = 0x20 ... 0x7E - - let length: Int - switch size { - case .large: - length = Int(Double.random(in: 1.0 ..< 2.0) * Double(readBufferSize)) + 1 - case .medium: - length = Int(Double.random(in: 0.2 ..< 1.0) * Double(readBufferSize)) + 1 - case .small: - length = Int.random(in: 1 ..< 16) - } - - var buffer: [UInt8] = Array(repeating: 0, count: length) - for index in 0 ..< length { - buffer[index] = UInt8.random(in: range) - } - // Buffer cannot be empty or a line with a \r ending followed by an empty one with a \n ending would be indistinguishable. - // This matters for any line ending sequences where one line ending sequence is the prefix of another. \r and \r\n are the - // only two which meet this criteria. - precondition(!buffer.isEmpty) - return buffer - } - - // Generate at least 2 long lines that is longer than buffer size - func generateTestCases(count: Int) -> [TestCase] { - var targetSizes: [TestCaseSize] = TestCaseSize.allCases.flatMap { - Array(repeating: $0, count: count / 3) - } - // Fill the remainder - let remaining = count - targetSizes.count - let rest = TestCaseSize.allCases.shuffled().prefix(remaining) - targetSizes.append(contentsOf: rest) - // Do a final shuffle to achieve random order - targetSizes.shuffle() - // Now generate test cases based on sizes - var testCases: [TestCase] = [] - for size in targetSizes { - let components = generateString(size: size) - // Choose a random new line - let newLine = newLineCharacters.randomElement()! - let string = String(decoding: components + newLine, as: UTF8.self) - testCases.append(( - value: string, - count: components.count + newLine.count, - newLine: String(decoding: newLine, as: UTF8.self) - )) - } - return testCases - } - - func writeTestCasesToFile(_ testCases: [TestCase], at url: URL) throws { - #if canImport(Darwin) - FileManager.default.createFile(atPath: url.path(), contents: nil, attributes: nil) - let fileHadle = try FileHandle(forWritingTo: url) - for testCase in testCases { - fileHadle.write(testCase.value.data(using: .utf8)!) - } - try fileHadle.close() - #else - var result = "" - for testCase in testCases { - result += testCase.value - } - try result.write(to: url, atomically: true, encoding: .utf8) - #endif - } - - let testCaseCount = 60 - let testFilePath = URL.temporaryDirectory.appending(path: "NewLines-\(UUID().uuidString).txt") - if FileManager.default.fileExists(atPath: testFilePath.path()) { - try FileManager.default.removeItem(at: testFilePath) - } - let testCases = generateTestCases(count: testCaseCount) - try writeTestCasesToFile(testCases, at: testFilePath) - - _ = try await Subprocess.run( - .path("/bin/cat"), - arguments: [testFilePath.path()], - error: .discarded - ) { execution, standardOutput in - var index = 0 - for try await line in standardOutput.lines(encoding: UTF8.self) { - defer { index += 1 } - try #require(index < testCases.count, "Received more lines than expected") - #expect( - line == testCases[index].value, - """ - Found mismatching line at index \(index) - Expected: [\(testCases[index].value)] - Actual: [\(line)] - Line Ending \(Array(testCases[index].newLine.utf8)) - """ - ) - } - } - try FileManager.default.removeItem(at: testFilePath) - } -} - -// MARK: - Utils -extension FileDescriptor { - /// Runs a closure and then closes the FileDescriptor, even if an error occurs. - /// - /// - Parameter body: The closure to run. - /// If the closure throws an error, - /// this method closes the file descriptor before it rethrows that error. - /// - /// - Returns: The value returned by the closure. - /// - /// If `body` throws an error - /// or an error occurs while closing the file descriptor, - /// this method rethrows that error. - public func closeAfter(_ body: () async throws -> R) async throws -> R { - // No underscore helper, since the closure's throw isn't necessarily typed. - let result: R - do { - result = try await body() - } catch { - _ = try? self.close() // Squash close error and throw closure's - throw error - } - try self.close() - return result - } -} - -extension SubprocessUnixTests { - private func assertID( - withArgument argument: String, - platformOptions: PlatformOptions, - isEqualTo expected: gid_t - ) async throws { - let idResult = try await Subprocess.run( - .path("/usr/bin/id"), - arguments: [argument], - platformOptions: platformOptions, - output: .string(limit: 32) - ) - #expect(idResult.terminationStatus.isSuccess) - let id = try #require(idResult.standardOutput) - #expect( - id.trimmingCharacters(in: .whitespacesAndNewlines) == "\(expected)" - ) - } -} - -internal func assertNewSessionCreated( - with result: CollectedResult< - StringOutput, - Output - > -) throws { - try assertNewSessionCreated( - terminationStatus: result.terminationStatus, - output: #require(result.standardOutput) - ) -} - -internal func assertNewSessionCreated( - terminationStatus: TerminationStatus, - output psValue: String -) throws { - #expect(terminationStatus.isSuccess) - - let match = try #require(try #/\s*PID\s*PGID\s*TPGID\s*(?[\-]?[0-9]+)\s*(?[\-]?[0-9]+)\s*(?[\-]?[0-9]+)\s*/#.wholeMatch(in: psValue), "ps output was in an unexpected format:\n\n\(psValue)") - // If setsid() has been called successfully, we should observe: - // - pid == pgid - // - tpgid <= 0 - let pid = try #require(Int(match.output.pid)) - let pgid = try #require(Int(match.output.pgid)) - let tpgid = try #require(Int(match.output.tpgid)) - #expect(pid == pgid) - #expect(tpgid <= 0) -} - -extension FileDescriptor { - internal func readUntilEOF(upToLength maxLength: Int) async throws -> Data { - return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let dispatchIO = DispatchIO( - type: .stream, - fileDescriptor: self.rawValue, - queue: .global() - ) { error in - if error != 0 { - continuation.resume(throwing: POSIXError(.init(rawValue: error) ?? .ENODEV)) - } - } - var buffer: Data = Data() - dispatchIO.read( - offset: 0, - length: maxLength, - queue: .global() - ) { done, data, error in - guard error == 0 else { - continuation.resume(throwing: POSIXError(.init(rawValue: error) ?? .ENODEV)) - return - } - if let data = data { - buffer += Data(data) - } - if done { - dispatchIO.close() - continuation.resume(returning: buffer) - } - } - } - } -} - -// MARK: - Performance Tests -extension SubprocessUnixTests { - @Test(.requiresBash) func testConcurrentRun() async throws { - // Launch as many processes as we can - // Figure out the max open file limit - let limitResult = try await Subprocess.run( - .path("/bin/sh"), - arguments: ["-c", "ulimit -n"], - output: .string(limit: 32) - ) - guard - let limitString = limitResult - .standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines), - let ulimit = Int(limitString) - else { - Issue.record("Failed to run ulimit -n") - return - } - // Constrain to an ultimate upper limit of 4096, since Docker containers can have limits like 2^20 which is a bit too high for this test. - // Common defaults are 2560 for macOS and 1024 for Linux. - let limit = min(ulimit, 4096) - // Since we open two pipes per `run`, launch - // limit / 4 subprocesses should reveal any - // file descriptor leaks - let maxConcurrent = limit / 4 - try await withThrowingTaskGroup(of: Void.self) { group in - var running = 0 - let byteCount = 1000 - for _ in 0..&2"#, "--", String(repeating: "X", count: byteCount), - ], - output: .data(limit: .max), - error: .data(limit: .max) - ) - guard r.terminationStatus.isSuccess else { - Issue.record("Unexpected exit \(r.terminationStatus) from \(r.processIdentifier)") - return - } - #expect(r.standardOutput.count == byteCount + 1, "\(r.standardOutput)") - #expect(r.standardError.count == byteCount + 1, "\(r.standardError)") - } - running += 1 - if running >= maxConcurrent / 4 { - try await group.next() - } - } - try await group.waitForAll() - } - } - - @Test(.requiresBash) func testCaptureLongStandardOutputAndError() async throws { - try await withThrowingTaskGroup(of: Void.self) { group in - var running = 0 - for _ in 0..<10 { - group.addTask { - // This invocation specifically requires bash semantics; sh (on FreeBSD at least) does not consistently support -s in this way - let r = try await Subprocess.run( - .name("bash"), - arguments: [ - "-sc", #"echo "$1" && echo "$1" >&2"#, "--", String(repeating: "X", count: 100_000), - ], - output: .data(limit: .max), - error: .data(limit: .max) - ) - #expect(r.terminationStatus == .exited(0)) - #expect(r.standardOutput.count == 100_001, "Standard output actual \(r.standardOutput)") - #expect(r.standardError.count == 100_001, "Standard error actual \(r.standardError)") - } - running += 1 - if running >= 1000 { - try await group.next() - } - } - try await group.waitForAll() - } - } - - @Test func testCancelProcessVeryEarlyOnStressTest() async throws { - for i in 0..<100 { - let terminationStatus = try await withThrowingTaskGroup( - of: TerminationStatus?.self, - returning: TerminationStatus.self - ) { group in - group.addTask { - return try await Subprocess.run( - .path("/bin/sleep"), - arguments: ["100000"], - output: .string(limit: .max) - ).terminationStatus - } - group.addTask { - let waitNS = UInt64.random(in: 0..<10_000_000) - try? await Task.sleep(nanoseconds: waitNS) - return nil - } - - while let result = try await group.next() { - group.cancelAll() - if let result = result { - return result - } - } - preconditionFailure("this should be impossible, task should've returned a result") - } - #expect(terminationStatus == .unhandledException(SIGKILL), "iteration \(i)") - } - } -} - -#endif // canImport(Darwin) || canImport(Glibc) diff --git a/Tests/SubprocessTests/TestSupport.swift b/Tests/SubprocessTests/TestSupport.swift index b8e75a1a..c13dc1d7 100644 --- a/Tests/SubprocessTests/TestSupport.swift +++ b/Tests/SubprocessTests/TestSupport.swift @@ -30,6 +30,15 @@ internal func randomString(length: Int, lettersOnly: Bool = false) -> String { return String((0.. [UInt8] { + return Array(unsafeUninitializedCapacity: count) { buffer, initializedCount in + for i in 0 ..< count { + buffer[i] = UInt8.random(in: 0...255) + } + initializedCount = count + } +} + internal func directory(_ lhs: String, isSameAs rhs: String) -> Bool { guard lhs != rhs else { return true diff --git a/Tests/SubprocessTests/UnixTests.swift b/Tests/SubprocessTests/UnixTests.swift new file mode 100644 index 00000000..88b013ea --- /dev/null +++ b/Tests/SubprocessTests/UnixTests.swift @@ -0,0 +1,537 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) || canImport(Glibc) + +#if canImport(Darwin) +// On Darwin always prefer system Foundation +import Foundation +#else +// On other platforms prefer FoundationEssentials +import FoundationEssentials +#endif + +#if canImport(Glibc) +import Glibc +#elseif canImport(Bionic) +import Bionic +#elseif canImport(Musl) +import Musl +#endif + +import _SubprocessCShims +import Testing +@testable import Subprocess + +import TestResources + +import Dispatch +#if canImport(System) +@preconcurrency import System +#else +@preconcurrency import SystemPackage +#endif + +@Suite(.serialized) +struct SubprocessUnixTests {} + +// MARK: - PlatformOption Tests +extension SubprocessUnixTests { + // Run this test with sudo + @Test( + .enabled( + if: getgid() == 0, + "This test requires root privileges" + ) + ) + func testSubprocessPlatformOptionsUserID() async throws { + let expectedUserID = uid_t(Int.random(in: 1000...2000)) + var platformOptions = PlatformOptions() + platformOptions.userID = expectedUserID + try await self.assertID( + withArgument: "-u", + platformOptions: platformOptions, + isEqualTo: expectedUserID + ) + } + + // Run this test with sudo + @Test( + .enabled( + if: getgid() == 0, + "This test requires root privileges" + ) + ) + func testSubprocessPlatformOptionsGroupID() async throws { + let expectedGroupID = gid_t(Int.random(in: 1000...2000)) + var platformOptions = PlatformOptions() + platformOptions.groupID = expectedGroupID + try await self.assertID( + withArgument: "-g", + platformOptions: platformOptions, + isEqualTo: expectedGroupID + ) + } + + // Run this test with sudo + @Test( + .enabled( + if: getgid() == 0, + "This test requires root privileges" + ) + ) + func testSubprocessPlatformOptionsSupplementaryGroups() async throws { + var expectedGroups: Set = Set() + for _ in 0..[\-]?[0-9]+)\s*(?[\-]?[0-9]+)\s*/#.wholeMatch(in: resultValue), "ps output was in an unexpected format:\n\n\(resultValue)") + // PGID should == PID + #expect(match.output.pid == match.output.pgid) + } + + @Test( + .enabled( + if: (try? Executable.name("ps").resolveExecutablePath(in: .inherit)) != nil, + "This test requires ps (install procps package on Debian or RedHat Linux distros)" + ) + ) + func testSubprocessPlatformOptionsCreateSession() async throws { + // platformOptions.createSession implies calls to setsid + var platformOptions = PlatformOptions() + platformOptions.createSession = true + // Check the process ID (pid), process group ID (pgid), and + // controlling terminal's process group ID (tpgid) + let psResult = try await Subprocess.run( + .path("/bin/sh"), + arguments: ["-c", "ps -o pid,pgid,tpgid -p $$"], + platformOptions: platformOptions, + output: .string(limit: .max) + ) + try assertNewSessionCreated(with: psResult) + } + + @Test(.requiresBash) func testTeardownSequence() async throws { + let result = try await Subprocess.run( + .name("bash"), + arguments: [ + "-c", + """ + set -e + trap 'echo saw SIGQUIT;' QUIT + trap 'echo saw SIGTERM;' TERM + trap 'echo saw SIGINT; exit 42;' INT + while true; do sleep 1; done + exit 2 + """, + ], + input: .none, + error: .discarded + ) { subprocess, standardOutput in + return try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await Task.sleep(for: .milliseconds(200)) + // Send shut down signal + await subprocess.teardown(using: [ + .send(signal: .quit, allowedDurationToNextStep: .milliseconds(500)), + .send(signal: .terminate, allowedDurationToNextStep: .milliseconds(500)), + .send(signal: .interrupt, allowedDurationToNextStep: .milliseconds(1000)), + ]) + } + group.addTask { + var outputs: [String] = [] + for try await line in standardOutput.lines() { + outputs.append(line.trimmingCharacters(in: .newlines)) + } + #expect(outputs == ["saw SIGQUIT", "saw SIGTERM", "saw SIGINT"]) + } + try await group.waitForAll() + } + } + #expect(result.terminationStatus == .exited(42)) + } +} + +// MARK: - Misc +extension SubprocessUnixTests { + @Test func testExitSignal() async throws { + let signalsToTest: [CInt] = [SIGKILL, SIGTERM, SIGINT] + for signal in signalsToTest { + let result = try await Subprocess.run( + .path("/bin/sh"), + arguments: ["-c", "kill -\(signal) $$"], + output: .discarded + ) + #expect(result.terminationStatus == .unhandledException(signal)) + } + } + + @Test func testCanReliablyKillProcessesEvenWithSigmask() async throws { + let result = try await withThrowingTaskGroup( + of: TerminationStatus?.self, + returning: TerminationStatus.self + ) { group in + group.addTask { + return try await Subprocess.run( + .path("/bin/sh"), + arguments: ["-c", "trap 'echo no' TERM; while true; do sleep 1; done"], + output: .string(limit: .max) + ).terminationStatus + } + group.addTask { + try? await Task.sleep(nanoseconds: 100_000_000) + return nil + } + while let result = try await group.next() { + group.cancelAll() + if let result = result { + return result + } + } + preconditionFailure("Task should have returned a result") + } + #expect(result == .unhandledException(SIGKILL)) + } + + @Test(.requiresBash) + func testRunawayProcess() async throws { + do { + try await withThrowingTaskGroup { group in + group.addTask { + var platformOptions = PlatformOptions() + platformOptions.teardownSequence = [ + // Send SIGINT for child to catch + .send(signal: .interrupt, allowedDurationToNextStep: .milliseconds(100)) + ] + let result = try await Subprocess.run( + .path("/bin/bash"), + arguments: [ + "-c", + """ + set -e + # The following /usr/bin/yes is the runaway grand child. + # It runs in the background forever until this script kills it + /usr/bin/yes "Runaway process from \(#function), please file a SwiftSubprocess bug." > /dev/null & + child_pid=$! # Retrieve the grand child yes pid + # When SIGINT is sent to the script, kill grand child now + trap "echo >&2 'child: received signal, killing grand child ($child_pid)'; kill -s KILL $child_pid; exit 0" INT + echo "$child_pid" # communicate the child pid to our parent + echo "child: waiting for grand child, pid: $child_pid" >&2 + wait $child_pid # wait for runaway child to exit + """ + ], + platformOptions: platformOptions, + output: .string(limit: .max), + error: .fileDescriptor(.standardError, closeAfterSpawningProcess: false) + ) + #expect(result.terminationStatus.isSuccess) + let output = try #require(result.standardOutput).trimmingNewLineAndQuotes() + let grandChildPid = try #require(pid_t(output)) + // Make sure the grand child `/usr/bin/yes` actually exited + // This is unfortunately racy because the pid isn't immediately invalided + // once the exit exits. Allow a few failures and delay to counter this + for _ in 0 ..< 10 { + let rc = kill(grandChildPid, 0) + if rc == 0 { + // Wait for a small delay + try await Task.sleep(for: .milliseconds(100)) + } else { + break + } + } + let finalRC = kill(grandChildPid, 0) + let capturedError = errno + #expect(finalRC != 0) + #expect(capturedError == ESRCH) + } + group.addTask { + // Give the script some times to run + try await Task.sleep(for: .milliseconds(100)) + } + // Wait for the sleep task to finish + _ = try await group.next() + // Cancel child process to trigger teardown + group.cancelAll() + try await group.waitForAll() + } + } catch { + if error is CancellationError { + // We intentionally cancelled the task + return + } + throw error + } + } + + @Test( + .disabled("Linux requires #46 to be fixed", { + #if os(Linux) + return true + #else + return false + #endif + }), + .bug("https://github.com/swiftlang/swift-subprocess/issues/46") + ) + func testSubprocessDoesNotInheritVeryHighFileDescriptors() async throws { + var openedFileDescriptors: [CInt] = [] + // Open /dev/null to use as source for duplication + let devnull: FileDescriptor = try .openDevNull(withAccessMode: .readOnly) + defer { + let closeResult = close(devnull.rawValue) + #expect(closeResult == 0) + } + // Duplicate devnull to higher file descriptors + for candidate in sequence( + first: CInt(1), + next: { $0 <= CInt.max / 2 ? $0 * 2 : nil } + ) { + // Use fcntl with F_DUPFD to find next available FD >= candidate + let fd = fcntl(devnull.rawValue, F_DUPFD, candidate) + if fd < 0 { + // Failed to allocate this candidate, try the next one + continue + } + openedFileDescriptors.append(fd) + } + defer { + for fd in openedFileDescriptors { + let closeResult = close(fd) + #expect(closeResult == 0) + } + } + let shellScript = + """ + for fd in "$@"; do + if [ -e "/proc/self/fd/$fd" ] || [ -e "/dev/fd/$fd" ]; then + echo "$fd:OPEN" + else + echo "$fd:CLOSED" + fi + done + """ + var arguments = ["-c", shellScript, "--"] + arguments.append(contentsOf: openedFileDescriptors.map { "\($0)" }) + + let result = try await Subprocess.run( + .path("/bin/sh"), + arguments: .init(arguments), + output: .string(limit: .max), + error: .string(limit: .max) + ) + #expect(result.terminationStatus.isSuccess) + #expect(result.standardError?.trimmingNewLineAndQuotes().isEmpty == true) + var checklist = Set(openedFileDescriptors) + let closeResult = try #require(result.standardOutput) + .trimmingNewLineAndQuotes() + .split(separator: "\n") + #expect(checklist.count == closeResult.count) + + for resultString in closeResult { + let components = resultString.split(separator: ":") + #expect(components.count == 2) + guard let fd = CInt(components[0]) else { + continue + } + #expect(checklist.remove(fd) != nil) + #expect(components[1] == "CLOSED") + } + // Make sure all fds are closed + #expect(checklist.isEmpty) + } + + @Test( + .disabled("Linux requires #46 to be fixed", { + #if os(Linux) + return true + #else + return false + #endif + }), + .bug("https://github.com/swiftlang/swift-subprocess/issues/46") + ) + func testSubprocessDoesNotInheritRandomFileDescriptors() async throws { + let pipe = try FileDescriptor.ssp_pipe() + defer { + try? pipe.readEnd.close() + try? pipe.writeEnd.close() + } + // Spawn bash and then attempt to write to the write end + let result = try await Subprocess.run( + .path("/bin/sh"), + arguments: [ + "-c", + """ + echo this string should be discarded >&\(pipe.writeEnd.rawValue); + echo wrote into \(pipe.writeEnd.rawValue), echo exit code $?; + """ + ], + input: .none, + output: .string(limit: 64), + error: .discarded + ) + try pipe.writeEnd.close() + #expect(result.terminationStatus.isSuccess) + // Make sure nothing is written to the pipe + var readBytes: [UInt8] = Array(repeating: 0, count: 1024) + let readCount = try readBytes.withUnsafeMutableBytes { ptr in + return try FileDescriptor(rawValue: pipe.readEnd.rawValue) + .read(into: ptr, retryOnInterrupt: true) + } + #expect(readCount == 0) + #expect( + result.standardOutput?.trimmingNewLineAndQuotes() == + "wrote into \(pipe.writeEnd.rawValue), echo exit code 1" + ) + } +} + +// MARK: - Utils +extension SubprocessUnixTests { + private func assertID( + withArgument argument: String, + platformOptions: PlatformOptions, + isEqualTo expected: gid_t + ) async throws { + let idResult = try await Subprocess.run( + .path("/usr/bin/id"), + arguments: [argument], + platformOptions: platformOptions, + output: .string(limit: 32) + ) + #expect(idResult.terminationStatus.isSuccess) + let id = try #require(idResult.standardOutput) + #expect( + id.trimmingCharacters(in: .whitespacesAndNewlines) == "\(expected)" + ) + } +} + +internal func assertNewSessionCreated( + with result: CollectedResult< + StringOutput, + Output + > +) throws { + #expect(result.terminationStatus.isSuccess) + let psValue = try #require( + result.standardOutput + ) + let match = try #require(try #/\s*PID\s*PGID\s*TPGID\s*(?[\-]?[0-9]+)\s*(?[\-]?[0-9]+)\s*(?[\-]?[0-9]+)\s*/#.wholeMatch(in: psValue), "ps output was in an unexpected format:\n\n\(psValue)") + // If setsid() has been called successfully, we should observe: + // - pid == pgid + // - tpgid <= 0 + let pid = try #require(Int(match.output.pid)) + let pgid = try #require(Int(match.output.pgid)) + let tpgid = try #require(Int(match.output.tpgid)) + #expect(pid == pgid) + #expect(tpgid <= 0) +} + +// MARK: - Performance Tests +extension SubprocessUnixTests { + @Test(.requiresBash) func testConcurrentRun() async throws { + // Launch as many processes as we can + // Figure out the max open file limit + let limitResult = try await Subprocess.run( + .path("/bin/sh"), + arguments: ["-c", "ulimit -n"], + output: .string(limit: 32) + ) + guard + let limitString = limitResult + .standardOutput? + .trimmingCharacters(in: .whitespacesAndNewlines), + let ulimit = Int(limitString) + else { + Issue.record("Failed to run ulimit -n") + return + } + // Constrain to an ultimate upper limit of 4096, since Docker containers can have limits like 2^20 which is a bit too high for this test. + // Common defaults are 2560 for macOS and 1024 for Linux. + let limit = min(ulimit, 4096) + // Since we open two pipes per `run`, launch + // limit / 4 subprocesses should reveal any + // file descriptor leaks + let maxConcurrent = limit / 4 + try await withThrowingTaskGroup(of: Void.self) { group in + var running = 0 + let byteCount = 1000 + for _ in 0..&2"#, "--", String(repeating: "X", count: byteCount), + ], + output: .data(limit: .max), + error: .data(limit: .max) + ) + guard r.terminationStatus.isSuccess else { + Issue.record("Unexpected exit \(r.terminationStatus) from \(r.processIdentifier)") + return + } + #expect(r.standardOutput.count == byteCount + 1, "\(r.standardOutput)") + #expect(r.standardError.count == byteCount + 1, "\(r.standardError)") + } + running += 1 + if running >= maxConcurrent / 4 { + try await group.next() + } + } + try await group.waitForAll() + } + } +} + +#endif // canImport(Darwin) || canImport(Glibc) diff --git a/Tests/SubprocessTests/SubprocessTests+Windows.swift b/Tests/SubprocessTests/WindowsTests.swift similarity index 55% rename from Tests/SubprocessTests/SubprocessTests+Windows.swift rename to Tests/SubprocessTests/WindowsTests.swift index 363fb700..36578502 100644 --- a/Tests/SubprocessTests/SubprocessTests+Windows.swift +++ b/Tests/SubprocessTests/WindowsTests.swift @@ -30,427 +30,6 @@ struct SubprocessWindowsTests { private let cmdExe: Subprocess.Executable = .name("cmd.exe") } -// MARK: - Executable Tests -extension SubprocessWindowsTests { - @Test func testExecutableNamed() async throws { - // Simple test to make sure we can run a common utility - let message = "Hello, world from Swift!" - - let result = try await Subprocess.run( - .name("cmd.exe"), - arguments: ["/c", "echo", message], - output: .string(limit: 64), - error: .discarded - ) - - #expect(result.terminationStatus.isSuccess) - #expect( - result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) == "\"\(message)\"" - ) - } - - @Test func testExecutableNamedCannotResolve() async throws { - do { - _ = try await Subprocess.run(.name("do-not-exist"), output: .discarded) - Issue.record("Expected to throw") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expected CocoaError, got \(error)") - return - } - // executable not found - #expect(subprocessError.code.value == 1) - } - } - - @Test func testExecutableAtPath() async throws { - let expected = FileManager.default.currentDirectoryPath - let result = try await Subprocess.run( - self.cmdExe, - arguments: ["/c", "cd"], - output: .string(limit: .max) - ) - #expect(result.terminationStatus.isSuccess) - #expect( - result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) == expected - ) - } - - @Test func testExecutableAtPathCannotResolve() async { - do { - // Since we are using the path directly, - // we expect the error to be thrown by the underlying - // CreateProcessW - _ = try await Subprocess.run(.path("X:\\do-not-exist"), output: .discarded) - Issue.record("Expected to throw POSIXError") - } catch { - guard let subprocessError = error as? SubprocessError, - let underlying = subprocessError.underlyingError - else { - Issue.record("Expected CocoaError, got \(error)") - return - } - #expect(underlying.rawValue == DWORD(ERROR_FILE_NOT_FOUND)) - } - } -} - -// MARK: - Argument Tests -extension SubprocessWindowsTests { - @Test func testArgumentsFromArray() async throws { - let message = "Hello, World!" - let args: [String] = [ - "/c", - "echo", - message, - ] - let result = try await Subprocess.run( - self.cmdExe, - arguments: .init(args), - output: .string(limit: 32) - ) - #expect(result.terminationStatus.isSuccess) - #expect( - result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) == "\"\(message)\"" - ) - } -} - -// MARK: - Environment Tests -extension SubprocessWindowsTests { - @Test func testEnvironmentInherit() async throws { - let result = try await Subprocess.run( - self.cmdExe, - arguments: ["/c", "echo %Path%"], - environment: .inherit, - output: .string(limit: .max) - ) - // As a sanity check, make sure there's - // `C:\Windows\system32` in PATH - // since we inherited the environment variables - let pathValue = try #require(result.standardOutput) - #expect(pathValue.contains("C:\\Windows\\system32")) - } - - @Test func testEnvironmentInheritOverride() async throws { - let result = try await Subprocess.run( - self.cmdExe, - arguments: ["/c", "echo %HOMEPATH%"], - environment: .inherit.updating([ - "HOMEPATH": "/my/new/home" - ]), - output: .string(limit: 32) - ) - #expect(result.terminationStatus.isSuccess) - #expect( - result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) == "/my/new/home" - ) - } - - @Test(.enabled(if: ProcessInfo.processInfo.environment["SystemRoot"] != nil)) - func testEnvironmentCustom() async throws { - let result = try await Subprocess.run( - self.cmdExe, - arguments: [ - "/c", "set", - ], - environment: .custom([ - "Path": "C:\\Windows\\system32;C:\\Windows", - "ComSpec": "C:\\Windows\\System32\\cmd.exe", - ]), - output: .string(limit: .max) - ) - #expect(result.terminationStatus.isSuccess) - // Make sure the newly launched process does - // NOT have `SystemRoot` in environment - let output = result.standardOutput! - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect(!output.contains("SystemRoot")) - } -} - -// MARK: - Working Directory Tests -extension SubprocessWindowsTests { - @Test func testWorkingDirectoryDefaultValue() async throws { - // By default we should use the working directory of the parent process - let workingDirectory = FileManager.default.currentDirectoryPath - let result = try await Subprocess.run( - self.cmdExe, - arguments: ["/c", "cd"], - workingDirectory: nil, - output: .string(limit: .max) - ) - #expect(result.terminationStatus.isSuccess) - // There shouldn't be any other environment variables besides - // `PATH` that we set - #expect( - result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) == workingDirectory - ) - } - - @Test func testWorkingDirectoryCustomValue() async throws { - let workingDirectory = FilePath( - FileManager.default.temporaryDirectory._fileSystemPath - ) - let result = try await Subprocess.run( - self.cmdExe, - arguments: ["/c", "cd"], - workingDirectory: workingDirectory, - output: .string(limit: .max) - ) - #expect(result.terminationStatus.isSuccess) - // There shouldn't be any other environment variables besides - // `PATH` that we set - let resultPath = result.standardOutput! - .trimmingCharacters(in: .whitespacesAndNewlines) - #expect( - FilePath(resultPath) == workingDirectory - ) - } -} - -// MARK: - Input Tests -extension SubprocessWindowsTests { - @Test func testInputNoInput() async throws { - let catResult = try await Subprocess.run( - self.cmdExe, - arguments: ["/c", "more"], - input: .none, - output: .data(limit: 16) - ) - #expect(catResult.terminationStatus.isSuccess) - // We should have read exactly 0 bytes - #expect(catResult.standardOutput.isEmpty) - } - - @Test func testInputFileDescriptor() async throws { - // Make sure we can read long text from standard input - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let text: FileDescriptor = try .open( - theMysteriousIsland, - .readOnly - ) - - let catResult = try await Subprocess.run( - self.cmdExe, - arguments: [ - "/c", - "findstr x*", - ], - input: .fileDescriptor(text, closeAfterSpawningProcess: true), - output: .data(limit: 2048 * 1024) - ) - - // Make sure we read all bytes - #expect( - catResult.standardOutput == expected - ) - } - - @Test func testInputSequence() async throws { - // Make sure we can read long text as Sequence - let expected: Data = try Data( - contentsOf: URL(filePath: getgroupsSwift.string) - ) - let catResult = try await Subprocess.run( - self.cmdExe, - arguments: [ - "/c", - "findstr x*", - ], - input: .data(expected), - output: .data(limit: 2048 * 1024), - error: .discarded - ) - // Make sure we read all bytes - #expect( - catResult.standardOutput == expected - ) - } - - @Test func testInputAsyncSequence() async throws { - let chunkSize = 4096 - // Make sure we can read long text as AsyncSequence - let fd: FileDescriptor = try .open(theMysteriousIsland, .readOnly) - let expected: Data = try Data( - contentsOf: URL(filePath: theMysteriousIsland.string) - ) - let stream: AsyncStream = AsyncStream { continuation in - DispatchQueue.global().async { - var currentStart = 0 - while currentStart + chunkSize < expected.count { - continuation.yield(expected[currentStart.. 0 { - continuation.yield(expected[currentStart.. = AsyncStream { continuation in - DispatchQueue.global().async { - var currentStart = 0 - while currentStart + chunkSize < expected.count { - continuation.yield(expected[currentStart.. 0 { - continuation.yield(expected[currentStart.. Date: Thu, 14 Aug 2025 10:58:32 -0700 Subject: [PATCH 2/4] Implement arg0 override feature for Windows --- Sources/Subprocess/Configuration.swift | 39 +- .../Platforms/Subprocess+Unix.swift | 27 + .../Platforms/Subprocess+Windows.swift | 747 ++++++++++++------ Sources/Subprocess/Teardown.swift | 8 +- Tests/SubprocessTests/AsyncIOTests.swift | 34 +- Tests/SubprocessTests/IntegrationTests.swift | 380 +++++---- Tests/SubprocessTests/UnixTests.swift | 2 +- 7 files changed, 738 insertions(+), 499 deletions(-) diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index 1732af55..28f13d78 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -250,40 +250,6 @@ extension Executable: CustomStringConvertible, CustomDebugStringConvertible { } } -extension Executable { - internal func possibleExecutablePaths( - withPathValue pathValue: String? - ) -> Set { - switch self.storage { - case .executable(let executableName): - #if os(Windows) - // Windows CreateProcessW accepts executable name directly - return Set([executableName]) - #else - var results: Set = [] - // executableName could be a full path - results.insert(executableName) - // Get $PATH from environment - let searchPaths: Set - if let pathValue = pathValue { - let localSearchPaths = pathValue.split(separator: ":").map { String($0) } - searchPaths = Set(localSearchPaths).union(Self.defaultSearchPaths) - } else { - searchPaths = Self.defaultSearchPaths - } - for path in searchPaths { - results.insert( - FilePath(path).appending(executableName).string - ) - } - return results - #endif - case .path(let executablePath): - return Set([executablePath.string]) - } - } -} - // MARK: - Arguments /// A collection of arguments to pass to the subprocess. @@ -304,7 +270,6 @@ public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { self.executablePathOverride = nil } - #if !os(Windows) // Windows does NOT support arg0 override /// Create an `Argument` object using the given values, but /// override the first Argument value to `executablePathOverride`. /// If `executablePathOverride` is nil, @@ -321,7 +286,7 @@ public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { self.executablePathOverride = nil } } - + #if !os(Windows) // Windows does not support non-unicode arguments /// Create an `Argument` object using the given values, but /// override the first Argument value to `executablePathOverride`. /// If `executablePathOverride` is nil, @@ -338,12 +303,12 @@ public struct Arguments: Sendable, ExpressibleByArrayLiteral, Hashable { self.executablePathOverride = nil } } - #endif public init(_ array: [[UInt8]]) { self.storage = array.map { .rawBytes($0) } self.executablePathOverride = nil } + #endif } extension Arguments: CustomStringConvertible, CustomDebugStringConvertible { diff --git a/Sources/Subprocess/Platforms/Subprocess+Unix.swift b/Sources/Subprocess/Platforms/Subprocess+Unix.swift index f9bf2a54..5acd49d6 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -300,6 +300,33 @@ extension Executable { return executablePath.string } } + + internal func possibleExecutablePaths( + withPathValue pathValue: String? + ) -> Set { + switch self.storage { + case .executable(let executableName): + var results: Set = [] + // executableName could be a full path + results.insert(executableName) + // Get $PATH from environment + let searchPaths: Set + if let pathValue = pathValue { + let localSearchPaths = pathValue.split(separator: ":").map { String($0) } + searchPaths = Set(localSearchPaths).union(Self.defaultSearchPaths) + } else { + searchPaths = Self.defaultSearchPaths + } + for path in searchPaths { + results.insert( + FilePath(path).appending(executableName).string + ) + } + return results + case .path(let executablePath): + return Set([executablePath.string]) + } + } } // MARK: - PreSpawn diff --git a/Sources/Subprocess/Platforms/Subprocess+Windows.swift b/Sources/Subprocess/Platforms/Subprocess+Windows.swift index a22d24ae..e5d121bd 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -57,135 +57,174 @@ extension Configuration { var errorReadFileDescriptor: IODescriptor? = errorPipe.readFileDescriptor() var errorWriteFileDescriptor: IODescriptor? = errorPipe.writeFileDescriptor() - let applicationName: String? - let commandAndArgs: String - let environment: String - let intendedWorkingDir: String? - do { - ( - applicationName, - commandAndArgs, - environment, - intendedWorkingDir - ) = try self.preSpawn() - } catch { - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor.take(), - inputWrite: inputWriteFileDescriptor.take(), - outputRead: outputReadFileDescriptor.take(), - outputWrite: outputWriteFileDescriptor.take(), - errorRead: errorReadFileDescriptor.take(), - errorWrite: errorWriteFileDescriptor.take() + // CreateProcessW supports using `lpApplicationName` as well as `lpCommandLine` to + // specify executable path. However, only `lpCommandLine` supports PATH looking up, + // whereas `lpApplicationName` does not. In general we should rely on `lpCommandLine`'s + // automatic PATH lookup so we only need to call `CreateProcessW` once. However, if + // user wants to override executable path in arguments, we have to use `lpApplicationName` + // to specify the executable path. In this case, manually loop over all possible paths. + let possibleExecutablePaths: Set + if _fastPath(self.arguments.executablePathOverride == nil) { + // Fast path: we can rely on `CreateProcessW`'s built in Path searching + switch self.executable.storage { + case .executable(let executable): + possibleExecutablePaths = Set([executable]) + case .path(let path): + possibleExecutablePaths = Set([path.string]) + } + } else { + // Slow path: user requested arg0 override, therefore we must manually + // traverse through all possible executable paths + possibleExecutablePaths = self.executable.possibleExecutablePaths( + withPathValue: self.environment.pathValue() ) - throw error } - var processInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() - var createProcessFlags = self.generateCreateProcessFlag() - - let created = try self.withStartupInfoEx( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) { startupInfo in - // Give calling process a chance to modify flag and startup info - if let configurator = self.platformOptions.preSpawnProcessConfigurator { - try configurator(&createProcessFlags, &startupInfo.pointer(to: \.StartupInfo)!.pointee) + for executablePath in possibleExecutablePaths { + let applicationName: String? + let commandAndArgs: String + let environment: String + let intendedWorkingDir: String? + do { + ( + applicationName, + commandAndArgs, + environment, + intendedWorkingDir + ) = try self.preSpawn(withPossibleExecutablePath: executablePath) + } catch { + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: inputWriteFileDescriptor, + outputRead: outputReadFileDescriptor, + outputWrite: outputWriteFileDescriptor, + errorRead: errorReadFileDescriptor, + errorWrite: errorWriteFileDescriptor + ) + throw error } - // Spawn! - return try applicationName.withOptionalNTPathRepresentation { applicationNameW in - try commandAndArgs.withCString( - encodedAs: UTF16.self - ) { commandAndArgsW in - try environment.withCString( - encodedAs: UTF16.self - ) { environmentW in - try intendedWorkingDir.withOptionalNTPathRepresentation { intendedWorkingDirW in - CreateProcessW( - applicationNameW, - UnsafeMutablePointer(mutating: commandAndArgsW), - nil, // lpProcessAttributes - nil, // lpThreadAttributes - true, // bInheritHandles - createProcessFlags, - UnsafeMutableRawPointer(mutating: environmentW), - intendedWorkingDirW, - startupInfo.pointer(to: \.StartupInfo)!, - &processInfo - ) - } - } - } - } - } + var processInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() + var createProcessFlags = self.generateCreateProcessFlag() - guard created else { - let windowsError = GetLastError() - try self.safelyCloseMultiple( + let created = try self.withStartupInfoEx( inputRead: inputReadFileDescriptor, inputWrite: inputWriteFileDescriptor, outputRead: outputReadFileDescriptor, outputWrite: outputWriteFileDescriptor, errorRead: errorReadFileDescriptor, errorWrite: errorWriteFileDescriptor - ) - // Match Darwin and Linux behavior and throw - // .executableNotFound or .failedToChangeWorkingDirectory accordingly - if windowsError == ERROR_FILE_NOT_FOUND { - throw SubprocessError( - code: .init(.executableNotFound(self.executable.description)), - underlyingError: .init(rawValue: windowsError) - ) + ) { startupInfo in + // Give calling process a chance to modify flag and startup info + if let configurator = self.platformOptions.preSpawnProcessConfigurator { + try configurator(&createProcessFlags, &startupInfo.pointer(to: \.StartupInfo)!.pointee) + } + + // Spawn! + return try applicationName.withOptionalNTPathRepresentation { applicationNameW in + try commandAndArgs.withCString( + encodedAs: UTF16.self + ) { commandAndArgsW in + try environment.withCString( + encodedAs: UTF16.self + ) { environmentW in + try intendedWorkingDir.withOptionalNTPathRepresentation { intendedWorkingDirW in + CreateProcessW( + applicationNameW, + UnsafeMutablePointer(mutating: commandAndArgsW), + nil, // lpProcessAttributes + nil, // lpThreadAttributes + true, // bInheritHandles + createProcessFlags, + UnsafeMutableRawPointer(mutating: environmentW), + intendedWorkingDirW, + startupInfo.pointer(to: \.StartupInfo)!, + &processInfo + ) + } + } + } + } } - if windowsError == ERROR_DIRECTORY { + guard created else { + let windowsError = GetLastError() + if windowsError == ERROR_FILE_NOT_FOUND || windowsError == ERROR_PATH_NOT_FOUND { + // This execution path is not it. Try the next one + continue + } + + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: inputWriteFileDescriptor, + outputRead: outputReadFileDescriptor, + outputWrite: outputWriteFileDescriptor, + errorRead: errorReadFileDescriptor, + errorWrite: errorWriteFileDescriptor + ) + + // Match Darwin and Linux behavior and throw + // .failedToChangeWorkingDirectory instead of .spawnFailed + if windowsError == ERROR_DIRECTORY { + throw SubprocessError( + code: .init(.failedToChangeWorkingDirectory(self.workingDirectory?.string ?? "")), + underlyingError: .init(rawValue: windowsError) + ) + } + throw SubprocessError( - code: .init(.failedToChangeWorkingDirectory(self.workingDirectory?.string ?? "")), + code: .init(.spawnFailed), underlyingError: .init(rawValue: windowsError) ) } - throw SubprocessError( - code: .init(.spawnFailed), - underlyingError: .init(rawValue: windowsError) + let pid = ProcessIdentifier( + value: processInfo.dwProcessId, + processDescriptor: processInfo.hProcess, + threadHandle: processInfo.hThread + ) + let execution = Execution( + processIdentifier: pid ) - } - let pid = ProcessIdentifier( - value: processInfo.dwProcessId, - processDescriptor: processInfo.hProcess, - threadHandle: processInfo.hThread - ) - let execution = Execution( - processIdentifier: pid - ) + do { + // After spawn finishes, close all child side fds + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: nil, + outputRead: nil, + outputWrite: outputWriteFileDescriptor, + errorRead: nil, + errorWrite: errorWriteFileDescriptor + ) + } catch { + // If spawn() throws, monitorProcessTermination + // won't have an opportunity to call release, so do it here to avoid leaking the handles. + pid.close() + throw error + } - do { - // After spawn finishes, close all child side fds - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: nil, - outputRead: nil, - outputWrite: outputWriteFileDescriptor, - errorRead: nil, - errorWrite: errorWriteFileDescriptor + return SpawnResult( + execution: execution, + inputWriteEnd: inputWriteFileDescriptor?.createIOChannel(), + outputReadEnd: outputReadFileDescriptor?.createIOChannel(), + errorReadEnd: errorReadFileDescriptor?.createIOChannel() ) - } catch { - // If spawn() throws, monitorProcessTermination - // won't have an opportunity to call release, so do it here to avoid leaking the handles. - pid.close() - throw error } - return SpawnResult( - execution: execution, - inputWriteEnd: inputWriteFileDescriptor?.createIOChannel(), - outputReadEnd: outputReadFileDescriptor?.createIOChannel(), - errorReadEnd: errorReadFileDescriptor?.createIOChannel() + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: inputWriteFileDescriptor, + outputRead: outputReadFileDescriptor, + outputWrite: outputWriteFileDescriptor, + errorRead: errorReadFileDescriptor, + errorWrite: errorWriteFileDescriptor + ) + + // If we reached this point, all possible executable paths have failed + throw SubprocessError( + code: .init(.executableNotFound(self.executable.description)), + underlyingError: .init(rawValue: DWORD(ERROR_FILE_NOT_FOUND)) ) } @@ -210,73 +249,100 @@ extension Configuration { let errorReadFileDescriptor: IODescriptor? = _errorPipe.readFileDescriptor() let errorWriteFileDescriptor: IODescriptor? = _errorPipe.writeFileDescriptor() - let ( - applicationName, - commandAndArgs, - environment, - intendedWorkingDir - ): (String?, String, String, String?) - do { - (applicationName, commandAndArgs, environment, intendedWorkingDir) = try self.preSpawn() - } catch { - try self.safelyCloseMultiple( + // CreateProcessW supports using `lpApplicationName` as well as `lpCommandLine` to + // specify executable path. However, only `lpCommandLine` supports PATH looking up, + // whereas `lpApplicationName` does not. In general we should rely on `lpCommandLine`'s + // automatic PATH lookup so we only need to call `CreateProcessW` once. However, if + // user wants to override executable path in arguments, we have to use `lpApplicationName` + // to specify the executable path. In this case, manually loop over all possible paths. + let possibleExecutablePaths: Set + if _fastPath(self.arguments.executablePathOverride == nil) { + // Fast path: we can rely on `CreateProcessW`'s built in Path searching + switch self.executable.storage { + case .executable(let executable): + possibleExecutablePaths = Set([executable]) + case .path(let path): + possibleExecutablePaths = Set([path.string]) + } + } else { + // Slow path: user requested arg0 override, therefore we must manually + // traverse through all possible executable paths + possibleExecutablePaths = self.executable.possibleExecutablePaths( + withPathValue: self.environment.pathValue() + ) + } + for executablePath in possibleExecutablePaths { + let ( + applicationName, + commandAndArgs, + environment, + intendedWorkingDir + ): (String?, String, String, String?) + do { + (applicationName, + commandAndArgs, + environment, + intendedWorkingDir) = try self.preSpawn(withPossibleExecutablePath: executablePath) + } catch { + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: inputWriteFileDescriptor, + outputRead: outputReadFileDescriptor, + outputWrite: outputWriteFileDescriptor, + errorRead: errorReadFileDescriptor, + errorWrite: errorWriteFileDescriptor + ) + throw error + } + + var processInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() + var createProcessFlags = self.generateCreateProcessFlag() + + let created = try self.withStartupInfoEx( inputRead: inputReadFileDescriptor, inputWrite: inputWriteFileDescriptor, outputRead: outputReadFileDescriptor, outputWrite: outputWriteFileDescriptor, errorRead: errorReadFileDescriptor, errorWrite: errorWriteFileDescriptor - ) - throw error - } - - var processInfo: PROCESS_INFORMATION = PROCESS_INFORMATION() - var createProcessFlags = self.generateCreateProcessFlag() - - let created = try self.withStartupInfoEx( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) { startupInfo in - // Give calling process a chance to modify flag and startup info - if let configurator = self.platformOptions.preSpawnProcessConfigurator { - try configurator(&createProcessFlags, &startupInfo.pointer(to: \.StartupInfo)!.pointee) - } + ) { startupInfo in + // Give calling process a chance to modify flag and startup info + if let configurator = self.platformOptions.preSpawnProcessConfigurator { + try configurator(&createProcessFlags, &startupInfo.pointer(to: \.StartupInfo)!.pointee) + } - // Spawn (featuring pyramid!) - return try userCredentials.username.withCString( - encodedAs: UTF16.self - ) { usernameW in - try userCredentials.password.withCString( + // Spawn (featuring pyramid!) + return try userCredentials.username.withCString( encodedAs: UTF16.self - ) { passwordW in - try userCredentials.domain.withOptionalCString( + ) { usernameW in + try userCredentials.password.withCString( encodedAs: UTF16.self - ) { domainW in - try applicationName.withOptionalNTPathRepresentation { applicationNameW in - try commandAndArgs.withCString( - encodedAs: UTF16.self - ) { commandAndArgsW in - try environment.withCString( + ) { passwordW in + try userCredentials.domain.withOptionalCString( + encodedAs: UTF16.self + ) { domainW in + try applicationName.withOptionalNTPathRepresentation { applicationNameW in + try commandAndArgs.withCString( encodedAs: UTF16.self - ) { environmentW in - try intendedWorkingDir.withOptionalNTPathRepresentation { intendedWorkingDirW in - CreateProcessWithLogonW( - usernameW, - domainW, - passwordW, - DWORD(LOGON_WITH_PROFILE), - applicationNameW, - UnsafeMutablePointer(mutating: commandAndArgsW), - createProcessFlags, - UnsafeMutableRawPointer(mutating: environmentW), - intendedWorkingDirW, - startupInfo.pointer(to: \.StartupInfo)!, - &processInfo - ) + ) { commandAndArgsW in + try environment.withCString( + encodedAs: UTF16.self + ) { environmentW in + try intendedWorkingDir.withOptionalNTPathRepresentation { intendedWorkingDirW in + CreateProcessWithLogonW( + usernameW, + domainW, + passwordW, + DWORD(LOGON_WITH_PROFILE), + applicationNameW, + UnsafeMutablePointer(mutating: commandAndArgsW), + createProcessFlags, + UnsafeMutableRawPointer(mutating: environmentW), + intendedWorkingDirW, + startupInfo.pointer(to: \.StartupInfo)!, + &processInfo + ) + } } } } @@ -284,72 +350,86 @@ extension Configuration { } } } - } - guard created else { - let windowsError = GetLastError() - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: inputWriteFileDescriptor, - outputRead: outputReadFileDescriptor, - outputWrite: outputWriteFileDescriptor, - errorRead: errorReadFileDescriptor, - errorWrite: errorWriteFileDescriptor - ) - // Match Darwin and Linux behavior and throw - // .executableNotFound or .failedToChangeWorkingDirectory accordingly - if windowsError == ERROR_FILE_NOT_FOUND { - throw SubprocessError( - code: .init(.executableNotFound(self.executable.description)), - underlyingError: .init(rawValue: windowsError) + guard created else { + let windowsError = GetLastError() + + if windowsError == ERROR_FILE_NOT_FOUND || windowsError == ERROR_PATH_NOT_FOUND { + // This executable path is not it. Try the next one + continue + } + + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: inputWriteFileDescriptor, + outputRead: outputReadFileDescriptor, + outputWrite: outputWriteFileDescriptor, + errorRead: errorReadFileDescriptor, + errorWrite: errorWriteFileDescriptor ) - } + // Match Darwin and Linux behavior and throw + // .failedToChangeWorkingDirectory instead of .spawnFailed + if windowsError == ERROR_DIRECTORY { + throw SubprocessError( + code: .init(.failedToChangeWorkingDirectory(self.workingDirectory?.string ?? "")), + underlyingError: .init(rawValue: windowsError) + ) + } - if windowsError == ERROR_DIRECTORY { throw SubprocessError( - code: .init(.failedToChangeWorkingDirectory(self.workingDirectory?.string ?? "")), + code: .init(.spawnFailed), underlyingError: .init(rawValue: windowsError) ) } - throw SubprocessError( - code: .init(.spawnFailed), - underlyingError: .init(rawValue: windowsError) + let pid = ProcessIdentifier( + value: processInfo.dwProcessId, + processDescriptor: processInfo.hProcess, + threadHandle: processInfo.hThread + ) + let execution = Execution( + processIdentifier: pid ) - } - let pid = ProcessIdentifier( - value: processInfo.dwProcessId, - processDescriptor: processInfo.hProcess, - threadHandle: processInfo.hThread - ) - let execution = Execution( - processIdentifier: pid - ) + do { + // After spawn finishes, close all child side fds + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: nil, + outputRead: nil, + outputWrite: outputWriteFileDescriptor, + errorRead: nil, + errorWrite: errorWriteFileDescriptor + ) + } catch { + // If spawn() throws, monitorProcessTermination + // won't have an opportunity to call release, so do it here to avoid leaking the handles. + pid.close() + throw error + } - do { - // After spawn finishes, close all child side fds - try self.safelyCloseMultiple( - inputRead: inputReadFileDescriptor, - inputWrite: nil, - outputRead: nil, - outputWrite: outputWriteFileDescriptor, - errorRead: nil, - errorWrite: errorWriteFileDescriptor + return SpawnResult( + execution: execution, + inputWriteEnd: inputWriteFileDescriptor?.createIOChannel(), + outputReadEnd: outputReadFileDescriptor?.createIOChannel(), + errorReadEnd: errorReadFileDescriptor?.createIOChannel() ) - } catch { - // If spawn() throws, monitorProcessTermination - // won't have an opportunity to call release, so do it here to avoid leaking the handles. - pid.close() - throw error } - return SpawnResult( - execution: execution, - inputWriteEnd: inputWriteFileDescriptor?.createIOChannel(), - outputReadEnd: outputReadFileDescriptor?.createIOChannel(), - errorReadEnd: errorReadFileDescriptor?.createIOChannel() + try self.safelyCloseMultiple( + inputRead: inputReadFileDescriptor, + inputWrite: inputWriteFileDescriptor, + outputRead: outputReadFileDescriptor, + outputWrite: outputWriteFileDescriptor, + errorRead: errorReadFileDescriptor, + errorWrite: errorWriteFileDescriptor + ) + + // If we reached this point, all possible executable paths have failed + throw SubprocessError( + code: .init(.executableNotFound(self.executable.description)), + underlyingError: .init(rawValue: DWORD(ERROR_FILE_NOT_FOUND)) ) } } @@ -670,6 +750,148 @@ extension Executable { return executablePath.string } } + + /// `CreateProcessW` allows users to specify the executable path via + /// `lpApplicationName` or via `lpCommandLine`. However, only `lpCommandLine` supports + /// path searching, whereas `lpApplicationName` does not. In order to support the + /// "argument 0 override" feature, Subprocess must use `lpApplicationName` instead of + /// relying on `lpCommandLine` (so we can potentially set a different value for `lpCommandLine`). + /// + /// This method replicates the executable searching behavior of `CreateProcessW`'s + /// `lpCommandLine`. Specifically, it follows the steps listed in `CreateProcessW`'s documentation: + /// + /// 1. The directory from which the application loaded. + /// 2. The current directory for the parent process. + /// 3. The 32-bit Windows system directory. + /// 4. The 16-bit Windows system directory. + /// 5. The Windows directory. + /// 6. The directories that are listed in the PATH environment variable. + /// + /// For more info: + /// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw + internal func possibleExecutablePaths( + withPathValue pathValue: String? + ) -> Set { + func insertExecutableAddingExtension( + _ name: String, + currentPath: String, + pathExtensions: Set, + storage: inout Set + ) { + let fullPath = FilePath(currentPath).appending(name) + if !name.hasExtension() { + for ext in pathExtensions { + var path = fullPath + path.extension = ext + storage.insert(path.string) + } + } else { + storage.insert(fullPath.string) + } + } + + switch self.storage { + case .executable(let name): + var possiblePaths: Set = [] + let currentEnvironmentValues = Environment.currentEnvironmentValues() + // If `name` does not include extensions, we need to try these extensions + var pathExtensions: Set = Set(["com", "exe", "cmd", "bat"]) + if let extensionList = currentEnvironmentValues["PATHEXT"] { + for var ext in extensionList.split(separator: ";") { + ext.removeFirst(1) + pathExtensions.insert(String(ext).lowercased()) + } + } + // 1. The directory from which the application loaded. + let applicationDirectory = try? fillNullTerminatedWideStringBuffer( + initialSize: DWORD(MAX_PATH), maxSize: DWORD(MAX_PATH) + ) { + return GetModuleFileNameW(nil, $0.baseAddress, DWORD($0.count)) + } + if let applicationDirectory { + insertExecutableAddingExtension( + name, + currentPath: applicationDirectory, + pathExtensions: pathExtensions, + storage: &possiblePaths + ) + } + // 2. Current directory + let directorySize = GetCurrentDirectoryW(0, nil) + let currentDirectory = try? fillNullTerminatedWideStringBuffer( + initialSize: directorySize >= 0 ? directorySize : DWORD(MAX_PATH), + maxSize: DWORD(MAX_PATH) + ) { + return GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) + } + if let currentDirectory { + insertExecutableAddingExtension( + name, + currentPath: currentDirectory, + pathExtensions: pathExtensions, + storage: &possiblePaths + ) + } + // 3. System directory (System32) + let systemDirectorySize = GetSystemDirectoryW(nil, 0) + let systemDirectory = try? fillNullTerminatedWideStringBuffer( + initialSize: systemDirectorySize >= 0 ? systemDirectorySize : DWORD(MAX_PATH), + maxSize: DWORD(MAX_PATH) + ) { + return GetSystemDirectoryW($0.baseAddress, DWORD($0.count)) + } + if let systemDirectory { + insertExecutableAddingExtension( + name, + currentPath: systemDirectory, + pathExtensions: pathExtensions, + storage: &possiblePaths + ) + } + // 4. 16 bit Systen Directory + // Windows documentation stats that + // "No such standard function (similar to GetSystemDirectory) + // exists for the 16-bit system folder". Use C:\Windows\System instead + let systemDirectory16 = FilePath(#"C:\Windows\System"#) + insertExecutableAddingExtension( + name, + currentPath: systemDirectory16.string, + pathExtensions: pathExtensions, + storage: &possiblePaths + ) + // 5. The Windows directory + let windowsDirectorySize = GetSystemWindowsDirectoryW(nil, 0) + let windowsDirectory = try? fillNullTerminatedWideStringBuffer( + initialSize: windowsDirectorySize >= 0 ? windowsDirectorySize : DWORD(MAX_PATH), + maxSize: DWORD(MAX_PATH) + ) { + return GetSystemWindowsDirectoryW($0.baseAddress, DWORD($0.count)) + } + if let windowsDirectory { + insertExecutableAddingExtension( + name, + currentPath: windowsDirectory, + pathExtensions: pathExtensions, + storage: &possiblePaths + ) + } + // 6. The directories that are listed in the PATH environment variable + if let pathValue { + let searchPaths = pathValue.split(separator: ";").map { String($0) } + for possiblePath in searchPaths { + insertExecutableAddingExtension( + name, + currentPath: possiblePath, + pathExtensions: pathExtensions, + storage: &possiblePaths + ) + } + } + return possiblePaths + case .path(let path): + return Set([path.string]) + } + } } // MARK: - Environment Resolution @@ -749,7 +971,7 @@ extension ProcessIdentifier: CustomStringConvertible, CustomDebugStringConvertib // MARK: - Private Utils extension Configuration { - private func preSpawn() throws -> ( + private func preSpawn(withPossibleExecutablePath executablePath: String) throws -> ( applicationName: String?, commandAndArgs: String, environment: String, @@ -788,10 +1010,13 @@ extension Configuration { let ( applicationName, commandAndArgs - ) = try self.generateWindowsCommandAndAgruments() - + ) = try self.generateWindowsCommandAndArguments( + withPossibleExecutablePath: executablePath + ) + // Omit applicationName (and therefore rely on commandAndArgs + // for executable path) if we don't need to override arg0 return ( - applicationName: applicationName, + applicationName: self.arguments.executablePathOverride == nil ? nil : applicationName, commandAndArgs: commandAndArgs, environment: environmentString, intendedWorkingDir: self.workingDirectory?.string @@ -824,7 +1049,7 @@ extension Configuration { _ body: (UnsafeMutablePointer) throws -> Result ) rethrows -> Result { var info: STARTUPINFOEXW = STARTUPINFOEXW() - info.StartupInfo.cb = DWORD(MemoryLayout.size) + info.StartupInfo.cb = DWORD(MemoryLayout.size(ofValue: info)) info.StartupInfo.dwFlags |= DWORD(STARTF_USESTDHANDLES) if self.platformOptions.windowStyle.storage != .normal { @@ -919,30 +1144,24 @@ extension Configuration { } } - private func generateWindowsCommandAndAgruments() throws -> ( + private func generateWindowsCommandAndArguments( + withPossibleExecutablePath executablePath: String + ) throws -> ( applicationName: String?, commandAndArgs: String ) { - // CreateProcess accepts partial names - let executableNameOrPath: String - switch self.executable.storage { - case .path(let path): - executableNameOrPath = path.string - case .executable(let name): - // Technically CreateProcessW accepts just the name - // of the executable, therefore we don't need to - // actually resolve the path. However, to maintain - // the same behavior as other platforms, still check - // here to make sure the executable actually exists - do { - _ = try self.executable.resolveExecutablePath( - withPathValue: self.environment.pathValue() - ) - } catch { - throw error - } - executableNameOrPath = name - } + // CreateProcessW behavior: + // - lpApplicationName: The actual executable path to run (can be NULL) + // - lpCommandLine: The command line string passed to the process + // + // If both are specified: + // - Windows runs the exe from lpApplicationName + // - The new process receives lpCommandLine as-is via GetCommandLine() + // - argv[0] is parsed from lpCommandLine, NOT from lpApplicationName + // + // For Unix-style argv[0] override (where argv[0] differs from actual exe): + // Set lpApplicationName = "C:\\path\\to\\real.exe" + // Set lpCommandLine = "fake_name.exe arg1 arg2" var args = self.arguments.storage.map { guard case .string(let stringValue) = $0 else { // We should never get here since the API @@ -951,22 +1170,16 @@ extension Configuration { } return stringValue } - // The first parameter of CreateProcessW, `lpApplicationName` - // is optional. If it's nil, CreateProcessW uses argument[0] - // as the execuatble name. - // We should only set lpApplicationName if it's different from - // argument[0] (i.e. executablePathOverride) - var applicationName: String? = nil + if case .string(let overrideName) = self.arguments.executablePathOverride { // Use the override as argument0 and set applicationName args.insert(overrideName, at: 0) - applicationName = executableNameOrPath } else { // Set argument[0] to be executableNameOrPath - args.insert(executableNameOrPath, at: 0) + args.insert(executablePath, at: 0) } return ( - applicationName: applicationName, + applicationName: executablePath, commandAndArgs: self.quoteWindowsCommandLine(args) ) } @@ -1181,6 +1394,11 @@ extension String { } } } + + internal func hasExtension() -> Bool { + let components = self.split(separator: ".") + return components.count > 1 && components.last?.count == 3 + } } @inline(__always) @@ -1220,4 +1438,39 @@ extension UInt8 { } } +/// Calls a Win32 API function that fills a (potentially long path) null-terminated string buffer by continually attempting to allocate more memory up until the true max path is reached. +/// This is especially useful for protecting against race conditions like with GetCurrentDirectoryW where the measured length may no longer be valid on subsequent calls. +/// - parameter initialSize: Initial size of the buffer (including the null terminator) to allocate to hold the returned string. +/// - parameter maxSize: Maximum size of the buffer (including the null terminator) to allocate to hold the returned string. +/// - parameter body: Closure to call the Win32 API function to populate the provided buffer. +/// Should return the number of UTF-16 code units (not including the null terminator) copied, 0 to indicate an error. +/// If the buffer is not of sufficient size, should return a value greater than or equal to the size of the buffer. +internal func fillNullTerminatedWideStringBuffer( + initialSize: DWORD, + maxSize: DWORD, + _ body: (UnsafeMutableBufferPointer) throws -> DWORD +) throws -> String { + var bufferCount = max(1, min(initialSize, maxSize)) + while bufferCount <= maxSize { + if let result = try withUnsafeTemporaryAllocation(of: WCHAR.self, capacity: Int(bufferCount), { buffer in + let count = try body(buffer) + switch count { + case 0: + throw SubprocessError.UnderlyingError(rawValue: GetLastError()) + case 1.. Bool { + private func isPotentiallyStillAlive() -> Bool { // Non-blockingly check whether the current execution has already exited // Note here we do NOT want to reap the exit status because we are still // running monitorProcessTermination() #if os(Windows) - var exitCode: DWORD = 0 - GetExitCodeProcess(self.processIdentifier.processDescriptor, &exitCode) - return exitCode == STILL_ACTIVE + return WaitForSingleObject(self.processIdentifier.processDescriptor, 0) == WAIT_TIMEOUT #else return kill(self.processIdentifier.value, 0) == 0 #endif diff --git a/Tests/SubprocessTests/AsyncIOTests.swift b/Tests/SubprocessTests/AsyncIOTests.swift index 03e0c90d..f364e0bf 100644 --- a/Tests/SubprocessTests/AsyncIOTests.swift +++ b/Tests/SubprocessTests/AsyncIOTests.swift @@ -42,8 +42,10 @@ extension SubprocessAsyncIOTests { @Test func testBasicReadWrite() async throws { let testData = randomData(count: 1024) try await runReadWriteTest { readIO, readTestBed in - let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) - #expect(Array(readData!) == testData) + let readData = try #require( + try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + ) + #expect(Array(readData) == testData) } writer: { writeIO, writeTestBed in _ = try await writeIO.write(testData, to: writeTestBed.ioChannel) try await writeTestBed.finish() @@ -64,9 +66,10 @@ extension SubprocessAsyncIOTests { let chunks = _chunks try await runReadWriteTest { readIO, readTestBed in for expectedChunk in chunks { - let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: expectedChunk.count) - #expect(readData != nil) - #expect(Array(readData!) == expectedChunk) + let readData = try #require( + try await readIO.read(from: readTestBed.ioChannel, upTo: expectedChunk.count) + ) + #expect(Array(readData) == expectedChunk) } // Final read should return nil @@ -119,8 +122,10 @@ extension SubprocessAsyncIOTests { @Test func testLargeReadWrite() async throws { let testData = randomData(count: 1024 * 1024) try await runReadWriteTest { readIO, readTestBed in - let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) - #expect(Array(readData!) == testData) + let readData = try #require( + try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + ) + #expect(Array(readData) == testData) } writer: { writeIO, writeTestBed in _ = try await writeIO.write(testData, to: writeTestBed.ioChannel) try await writeTestBed.finish() @@ -149,9 +154,9 @@ extension SubprocessAsyncIOTests { Issue.record("Expecting SubprocessError, but got \(error)") return } - #if canImport(Darwin) + #if canImport(Darwin) || os(FreeBSD) || os(OpenBSD) #expect(subprocessError.underlyingError == .init(rawValue: ECANCELED)) - #elseif os(Linux) + #elseif os(Linux) || os(Android) #expect(subprocessError.underlyingError == .init(rawValue: EBADF)) #endif } @@ -175,9 +180,9 @@ extension SubprocessAsyncIOTests { Issue.record("Expecting SubprocessError, but got \(error)") return } - #if canImport(Darwin) + #if canImport(Darwin) || os(FreeBSD) || os(OpenBSD) #expect(subprocessError.underlyingError == .init(rawValue: ECANCELED)) - #elseif os(Linux) + #elseif os(Linux) || os(Android) #expect(subprocessError.underlyingError == .init(rawValue: EBADF)) #endif } @@ -186,9 +191,10 @@ extension SubprocessAsyncIOTests { @Test func testBinaryDataWithNullBytes() async throws { let binaryData: [UInt8] = [0x00, 0x01, 0x02, 0x00, 0xFF, 0x00, 0xFE, 0xFD] try await runReadWriteTest { readIO, readTestBed in - let readData = try await readIO.read(from: readTestBed.ioChannel, upTo: .max) - #expect(readData != nil) - #expect(Array(readData!) == binaryData) + let readData = try #require( + try await readIO.read(from: readTestBed.ioChannel, upTo: .max) + ) + #expect(Array(readData) == binaryData) } writer: { writeIO, writeTestBed in let written = try await writeIO.write(binaryData, to: writeTestBed.ioChannel) #expect(written == binaryData.count) diff --git a/Tests/SubprocessTests/IntegrationTests.swift b/Tests/SubprocessTests/IntegrationTests.swift index fe403803..40927966 100644 --- a/Tests/SubprocessTests/IntegrationTests.swift +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -62,6 +62,7 @@ extension SubprocessIntegrationTests { ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 let output = result.standardOutput? .trimmingNewLineAndQuotes() // Windows echo includes quotes @@ -69,22 +70,29 @@ extension SubprocessIntegrationTests { } @Test func testExecutableNamedCannotResolve() async { - do { + #if os(Windows) + let expectedError = SubprocessError( + code: .init(.executableNotFound("do-not-exist")), + underlyingError: .init(rawValue: DWORD(ERROR_FILE_NOT_FOUND)) + ) + #else + let expectedError = SubprocessError( + code: .init(.executableNotFound("do-not-exist")), + underlyingError: .init(rawValue: ENOENT) + ) + #endif + + await #expect(throws: expectedError) { _ = try await Subprocess.run(.name("do-not-exist"), output: .discarded) - Issue.record("Expected to throw") - } catch { - guard let subprocessError: SubprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.executableNotFound("do-not-exist"))) } } @Test func testExecutableAtPath() async throws { #if os(Windows) + let cmdExe = ProcessInfo.processInfo.environment["COMSPEC"] ?? + #"C:\Windows\System32\cmd.exe"# let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .path(FilePath(cmdExe)), arguments: .init(["/c", "cd"]) ) #else @@ -102,6 +110,7 @@ extension SubprocessIntegrationTests { ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 let maybePath = result.standardOutput? .trimmingNewLineAndQuotes() let path = try #require(maybePath) @@ -111,18 +120,20 @@ extension SubprocessIntegrationTests { @Test func testExecutableAtPathCannotResolve() async { #if os(Windows) let fakePath = FilePath("D:\\does\\not\\exist") + let expectedError = SubprocessError( + code: .init(.executableNotFound("D:\\does\\not\\exist")), + underlyingError: .init(rawValue: DWORD(ERROR_FILE_NOT_FOUND)) + ) #else let fakePath = FilePath("/usr/bin/do-not-exist") + let expectedError = SubprocessError( + code: .init(.executableNotFound("/usr/bin/do-not-exist")), + underlyingError: .init(rawValue: ENOENT) + ) #endif - do { + + await #expect(throws: expectedError) { _ = try await Subprocess.run(.path(fakePath), output: .discarded) - Issue.record("Expected to throw SubprocessError") - } catch { - guard let subprocessError: SubprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.executableNotFound(fakePath.string))) } } } @@ -133,7 +144,7 @@ extension SubprocessIntegrationTests { let message = "Hello World!" #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo", message] ) #else @@ -150,6 +161,7 @@ extension SubprocessIntegrationTests { ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 let output = result.standardOutput? .trimmingNewLineAndQuotes() #expect( @@ -157,27 +169,41 @@ extension SubprocessIntegrationTests { ) } - #if !os(Windows) // Windows does not support argument 0 override // This test will not compile on Windows @Test func testArgumentsOverride() async throws { - let result = try await Subprocess.run( - .path("/bin/sh"), + #if os(Windows) + let setup = TestSetup( + executable: .name("powershell.exe"), + arguments: .init( + executablePathOverride: "apple", + remainingValues: ["-Command", "[Environment]::GetCommandLineArgs()[0]"] + ) + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), arguments: .init( executablePathOverride: "apple", remainingValues: ["-c", "echo $0"] - ), - output: .string(limit: 16) + ) + ) + #endif + let result = try await _run( + setup, + input: .none, + output: .string(limit: 16), + error: .discarded ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 let output = result.standardOutput? - .trimmingCharacters(in: .whitespacesAndNewlines) + .trimmingNewLineAndQuotes() #expect( output == "apple" ) } - #endif #if !os(Windows) // Windows does not support byte array arguments @@ -194,6 +220,7 @@ extension SubprocessIntegrationTests { ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 let output = result.standardOutput? .trimmingCharacters(in: .whitespacesAndNewlines) #expect( @@ -208,7 +235,7 @@ extension SubprocessIntegrationTests { @Test func testEnvironmentInherit() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo %Path%"], environment: .inherit ) @@ -228,23 +255,31 @@ extension SubprocessIntegrationTests { #expect(result.terminationStatus.isSuccess) let pathValue = try #require(result.standardOutput) #if os(Windows) - // As a sanity check, make sure there's - // `C:\Windows\system32` in PATH - // since we inherited the environment variables - #expect(pathValue.contains("C:\\Windows\\system32")) + let expected = try #require(ProcessInfo.processInfo.environment["Path"]) #else - // As a sanity check, make sure there's `/bin` in PATH - // since we inherited the environment variables - // rdar://138670128 - #expect(pathValue.contains("/bin")) + let expected = try #require(ProcessInfo.processInfo.environment["PATH"]) #endif + + let pathList = Set( + pathValue.split(separator: ":") + .map { String($0).trimmingNewLineAndQuotes() } + ) + let expectedList = Set( + expected.split(separator: ":") + .map { String($0).trimmingNewLineAndQuotes() } + ) + // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 + #expect( + pathList == expectedList + ) } @Test func testEnvironmentInheritOverride() async throws { #if os(Windows) let path = "C:\\My\\New\\Home" let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo %HOMEPATH%"], environment: .inherit.updating([ "HOMEPATH": path @@ -268,6 +303,7 @@ extension SubprocessIntegrationTests { ) #expect(result.terminationStatus.isSuccess) // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 let output = result.standardOutput? .trimmingNewLineAndQuotes() #expect( @@ -282,11 +318,11 @@ extension SubprocessIntegrationTests { func testEnvironmentCustom() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "set"], environment: .custom([ - "Path": "C:\\Windows\\system32;C:\\Windows", - "ComSpec": "C:\\Windows\\System32\\cmd.exe", + "Path": try #require(ProcessInfo.processInfo.environment["Path"]), + "ComSpec": try #require(ProcessInfo.processInfo.environment["ComSpec"]), ]) ) #else @@ -317,6 +353,7 @@ extension SubprocessIntegrationTests { // There shouldn't be any other environment variables besides // `PATH` that we set // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 #expect( output == "PATH=/bin:/usr/bin" ) @@ -331,7 +368,7 @@ extension SubprocessIntegrationTests { let workingDirectory = FileManager.default.currentDirectoryPath #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "cd"], workingDirectory: nil ) @@ -352,6 +389,7 @@ extension SubprocessIntegrationTests { // There shouldn't be any other environment variables besides // `PATH` that we set // rdar://138670128 + // https://github.com/swiftlang/swift/issues/77235 let output = result.standardOutput? .trimmingNewLineAndQuotes() let path = try #require(output) @@ -364,7 +402,7 @@ extension SubprocessIntegrationTests { ) #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "cd"], workingDirectory: workingDirectory ) @@ -407,10 +445,14 @@ extension SubprocessIntegrationTests { #if os(Windows) let invalidPath: FilePath = FilePath(#"X:\Does\Not\Exist"#) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "cd"], workingDirectory: invalidPath ) + let expectedError = SubprocessError( + code: .init(.failedToChangeWorkingDirectory(#"X:\Does\Not\Exist"#)), + underlyingError: .init(rawValue: DWORD(ERROR_DIRECTORY)) + ) #else let invalidPath: FilePath = FilePath("/does/not/exist") let setup = TestSetup( @@ -418,17 +460,14 @@ extension SubprocessIntegrationTests { arguments: [], workingDirectory: invalidPath ) + let expectedError = SubprocessError( + code: .init(.failedToChangeWorkingDirectory("/does/not/exist")), + underlyingError: .init(rawValue: ENOENT) + ) #endif - do { + await #expect(throws: expectedError) { _ = try await _run(setup, input: .none, output: .string(limit: .max), error: .discarded) - Issue.record("Expected to throw an error when working directory is invalid") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expecting SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.failedToChangeWorkingDirectory(invalidPath.string))) } } } @@ -438,7 +477,7 @@ extension SubprocessIntegrationTests { @Test func testNoInput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "more"] ) #else @@ -491,7 +530,7 @@ extension SubprocessIntegrationTests { let content = randomString(length: 64) #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "more"] ) #else @@ -517,7 +556,7 @@ extension SubprocessIntegrationTests { @Test func testFileDescriptorInput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -552,7 +591,7 @@ extension SubprocessIntegrationTests { @Test func testDataInput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -584,7 +623,7 @@ extension SubprocessIntegrationTests { @Test func testSpanInput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -616,7 +655,7 @@ extension SubprocessIntegrationTests { @Test func testAsyncSequenceInput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -659,7 +698,7 @@ extension SubprocessIntegrationTests { @Test func testStandardInputWriterInput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -678,31 +717,19 @@ extension SubprocessIntegrationTests { setup, error: .discarded ) { execution, standardInputWriter, standardOutput in - return try await withThrowingTaskGroup(of: Data?.self) { group in - group.addTask { - var buffer = Data() - for try await chunk in standardOutput { - let currentChunk = chunk.withUnsafeBytes { Data($0) } - buffer += currentChunk - } - return buffer + async let buffer = { + var _buffer = Data() + for try await chunk in standardOutput { + let currentChunk = chunk.withUnsafeBytes { Data($0) } + _buffer += currentChunk } + return _buffer + }() - group.addTask { - _ = try await standardInputWriter.write(Array(expected)) - try await standardInputWriter.finish() - return nil - } - - var buffer: Data! - while let result = try await group.next() { - if let result: Data = result { - buffer = result - } - } - return buffer - } + _ = try await standardInputWriter.write(Array(expected)) + try await standardInputWriter.finish() + return try await buffer } #expect(result.terminationStatus.isSuccess) #expect(result.value == expected) @@ -759,7 +786,7 @@ extension SubprocessIntegrationTests { @Test func testDiscardedOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo hello world"] ) #else @@ -780,7 +807,7 @@ extension SubprocessIntegrationTests { @Test func testStringOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -816,7 +843,7 @@ extension SubprocessIntegrationTests { @Test func testStringOutputExceedsLimit() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -830,27 +857,25 @@ extension SubprocessIntegrationTests { ) #endif - do { + let expectedError = SubprocessError( + code: .init(.outputBufferLimitExceeded(16)), + underlyingError: nil + ) + + await #expect(throws: expectedError) { _ = try await _run( setup, input: .none, output: .string(limit: 16), error: .discarded ) - Issue.record("Expected to throw") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) } } @Test func testBytesOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -880,7 +905,7 @@ extension SubprocessIntegrationTests { @Test func testBytesOutputExceedsLimit() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -894,20 +919,18 @@ extension SubprocessIntegrationTests { ) #endif - do { + let expectedError = SubprocessError( + code: .init(.outputBufferLimitExceeded(16)), + underlyingError: nil + ) + + await #expect(throws: expectedError) { _ = try await _run( setup, input: .none, output: .bytes(limit: 16), error: .discarded ) - Issue.record("Expected to throw") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) } } @@ -915,7 +938,7 @@ extension SubprocessIntegrationTests { let expected = randomString(length: 32) #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo \(expected)"] ) #else @@ -960,7 +983,7 @@ extension SubprocessIntegrationTests { @Test func testFileDescriptorOutputAutoClose() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo Hello World"] ) #else @@ -991,15 +1014,8 @@ extension SubprocessIntegrationTests { ) #expect(echoResult.terminationStatus.isSuccess) // Make sure the file descriptor is already closed - do { + #expect(throws: Errno.badFileDescriptor) { try outputFile.close() - Issue.record("Output file descriptor should be closed automatically") - } catch { - guard let typedError = error as? Errno else { - Issue.record("Wrong type of error thrown") - return - } - #expect(typedError == .badFileDescriptor) } } @@ -1007,7 +1023,7 @@ extension SubprocessIntegrationTests { @Test func testDataOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -1037,7 +1053,7 @@ extension SubprocessIntegrationTests { @Test func testDataOutputExceedsLimit() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x*", @@ -1051,20 +1067,18 @@ extension SubprocessIntegrationTests { ) #endif - do { + let expectedError = SubprocessError( + code: .init(.outputBufferLimitExceeded(16)), + underlyingError: nil + ) + + await #expect(throws: expectedError) { _ = try await _run( setup, input: .none, output: .data(limit: 16), error: .discarded ) - Issue.record("Expected to throw") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) } } #endif @@ -1072,7 +1086,7 @@ extension SubprocessIntegrationTests { @Test func testStringErrorOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x* 1>&2", @@ -1108,7 +1122,7 @@ extension SubprocessIntegrationTests { @Test func testStringErrorOutputExceedsLimit() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x* \(theMysteriousIsland.string) 1>&2", @@ -1121,27 +1135,25 @@ extension SubprocessIntegrationTests { ) #endif - do { + let expectedError = SubprocessError( + code: .init(.outputBufferLimitExceeded(16)), + underlyingError: nil + ) + + await #expect(throws: expectedError) { _ = try await _run( setup, input: .none, output: .discarded, error: .string(limit: 16) ) - Issue.record("Expected to throw") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) } } @Test func testBytesErrorOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x* 1>&2", @@ -1171,7 +1183,7 @@ extension SubprocessIntegrationTests { @Test func testBytesErrorOutputExceedsLimit() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x* \(theMysteriousIsland.string) 1>&2", @@ -1184,20 +1196,18 @@ extension SubprocessIntegrationTests { ) #endif - do { + let expectedError = SubprocessError( + code: .init(.outputBufferLimitExceeded(16)), + underlyingError: nil + ) + + await #expect(throws: expectedError) { _ = try await _run( setup, input: .none, output: .discarded, error: .bytes(limit: 16) ) - Issue.record("Expected to throw") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) } } @@ -1205,7 +1215,7 @@ extension SubprocessIntegrationTests { let expected = randomString(length: 32) #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo \(expected) 1>&2"] ) #else @@ -1250,7 +1260,7 @@ extension SubprocessIntegrationTests { @Test func testFileDescriptorErrorOutputAutoClose() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo Hello World", "1>&2"] ) #else @@ -1281,22 +1291,15 @@ extension SubprocessIntegrationTests { ) #expect(echoResult.terminationStatus.isSuccess) // Make sure the file descriptor is already closed - do { + #expect(throws: Errno.badFileDescriptor) { try outputFile.close() - Issue.record("Output file descriptor should be closed automatically") - } catch { - guard let typedError = error as? Errno else { - Issue.record("Wrong type of error thrown") - return - } - #expect(typedError == .badFileDescriptor) } } @Test func testFileDescriptorOutputErrorToSameFile() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo Hello Stdout & echo Hello Stderr 1>&2"] ) #else @@ -1338,13 +1341,15 @@ extension SubprocessIntegrationTests { String(data: outputData, encoding: .utf8) ).trimmingNewLineAndQuotes() #expect(echoResult.terminationStatus.isSuccess) + #expect(output.contains("Hello Stdout")) + #expect(output.contains("Hello Stderr")) } #if SubprocessFoundation @Test func testDataErrorOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x* 1>&2", @@ -1374,7 +1379,7 @@ extension SubprocessIntegrationTests { @Test func testDataErrorOutputExceedsLimit() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x* \(theMysteriousIsland.string) 1>&2", @@ -1387,20 +1392,18 @@ extension SubprocessIntegrationTests { ) #endif - do { + let expectedError = SubprocessError( + code: .init(.outputBufferLimitExceeded(16)), + underlyingError: nil + ) + + await #expect(throws: expectedError) { _ = try await _run( setup, input: .none, output: .discarded, error: .data(limit: 16) ) - Issue.record("Expected to throw") - } catch { - guard let subprocessError = error as? SubprocessError else { - Issue.record("Expected SubprocessError, got \(error)") - return - } - #expect(subprocessError.code == .init(.outputBufferLimitExceeded(16))) } } #endif @@ -1408,7 +1411,7 @@ extension SubprocessIntegrationTests { @Test func testStreamingErrorOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x* 1>&2", @@ -1427,31 +1430,19 @@ extension SubprocessIntegrationTests { setup, output: .discarded ) { execution, standardInputWriter, standardError in - return try await withThrowingTaskGroup(of: Data?.self) { group in - group.addTask { - var buffer = Data() - for try await chunk in standardError { - let currentChunk = chunk.withUnsafeBytes { Data($0) } - buffer += currentChunk - } - return buffer - } - - group.addTask { - _ = try await standardInputWriter.write(Array(expected)) - try await standardInputWriter.finish() - return nil + async let buffer = { + var _buffer = Data() + for try await chunk in standardError { + let currentChunk = chunk.withUnsafeBytes { Data($0) } + _buffer += currentChunk } + return _buffer + }() - var buffer: Data! - while let result = try await group.next() { - if let result: Data = result { - buffer = result - } - } - return buffer - } + _ = try await standardInputWriter.write(Array(expected)) + try await standardInputWriter.finish() + return try await buffer } #expect(result.terminationStatus.isSuccess) #expect(result.value == expected) @@ -1460,7 +1451,7 @@ extension SubprocessIntegrationTests { @Test func stressTestWithLittleOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo x & echo y 1>&2"] ) #else @@ -1486,7 +1477,7 @@ extension SubprocessIntegrationTests { @Test func stressTestWithLongOutput() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo x & echo y 1>&2"] ) #else @@ -1512,7 +1503,7 @@ extension SubprocessIntegrationTests { @Test func testInheritingOutputAndError() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo Standard Output Testing & echo Standard Error Testing 1>&2"] ) #else @@ -1560,7 +1551,7 @@ extension SubprocessIntegrationTests { @Test func testCaptureEmptyOutputError() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", ""] ) #else @@ -1586,7 +1577,7 @@ extension SubprocessIntegrationTests { @Test func testTerminateProcess() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "timeout /t 99999 >nul"]) #else let setup = TestSetup( @@ -1723,7 +1714,7 @@ extension SubprocessIntegrationTests { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: [ "/c", "findstr x* \(testFilePath._fileSystemPath)", @@ -1778,7 +1769,6 @@ extension SubprocessIntegrationTests { try await withThrowingTaskGroup(of: Void.self) { group in for _ in 0 ..< 8 { group.addTask { - // This invocation specifically requires bash semantics; sh (on FreeBSD at least) does not consistently support -s in this way let r = try await _run( setup, input: .string(string), @@ -1799,7 +1789,7 @@ extension SubprocessIntegrationTests { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "timeout /t 100000 /nobreak"] ) #else @@ -1850,7 +1840,7 @@ extension SubprocessIntegrationTests { for exitCode in UInt8.min ..< UInt8.max { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "exit \(exitCode)"] ) #else @@ -1873,7 +1863,7 @@ extension SubprocessIntegrationTests { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/Q", "/D"], environment: .inherit.updating(["PROMPT": "\"\""]) ) @@ -1989,7 +1979,7 @@ extension SubprocessIntegrationTests { group.addTask { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "echo apple"] ) // Set write handle to be inheritable only @@ -2096,7 +2086,7 @@ extension SubprocessIntegrationTests { @Test func testLineSequenceNoNewLines() async throws { #if os(Windows) let setup = TestSetup( - executable: .path(#"C:\Windows\System32\cmd.exe"#), + executable: .name("cmd.exe"), arguments: ["/c", "&2"] ) #else diff --git a/Tests/SubprocessTests/UnixTests.swift b/Tests/SubprocessTests/UnixTests.swift index 88b013ea..af8f1942 100644 --- a/Tests/SubprocessTests/UnixTests.swift +++ b/Tests/SubprocessTests/UnixTests.swift @@ -278,7 +278,7 @@ extension SubprocessUnixTests { let grandChildPid = try #require(pid_t(output)) // Make sure the grand child `/usr/bin/yes` actually exited // This is unfortunately racy because the pid isn't immediately invalided - // once the exit exits. Allow a few failures and delay to counter this + // once `kill` returns. Allow a few failures and delay to counter this for _ in 0 ..< 10 { let rc = kill(grandChildPid, 0) if rc == 0 { From 17c268b6f648c1ef46db7670f63fbf7406519a12 Mon Sep 17 00:00:00 2001 From: Charles Hu Date: Fri, 15 Aug 2025 13:53:06 -0700 Subject: [PATCH 3/4] Introduce ProcessMonitoringTests --- Sources/Subprocess/Error.swift | 2 +- .../Platforms/Subprocess+Linux.swift | 4 +- .../Platforms/Subprocess+Windows.swift | 63 ++-- Tests/SubprocessTests/DarwinTests.swift | 39 ++- Tests/SubprocessTests/IntegrationTests.swift | 63 +++- .../ProcessMonitoringTests.swift | 329 ++++++++++++++++++ Tests/SubprocessTests/UnixTests.swift | 18 +- 7 files changed, 465 insertions(+), 53 deletions(-) create mode 100644 Tests/SubprocessTests/ProcessMonitoringTests.swift diff --git a/Sources/Subprocess/Error.swift b/Sources/Subprocess/Error.swift index c62e24be..0a3b8e8d 100644 --- a/Sources/Subprocess/Error.swift +++ b/Sources/Subprocess/Error.swift @@ -111,7 +111,7 @@ extension SubprocessError: CustomStringConvertible, CustomDebugStringConvertible case .failedToWriteToSubprocess: return "Failed to write bytes to the child process." case .failedToMonitorProcess: - return "Failed to monitor the state of child process with underlying error: \(self.underlyingError!)" + return "Failed to monitor the state of child process with underlying error: \(self.underlyingError.map { "\($0)" } ?? "nil")" case .streamOutputExceedsLimit(let limit): return "Failed to create output from current buffer because the output limit (\(limit)) was reached." case .asyncIOFailed(let reason): diff --git a/Sources/Subprocess/Platforms/Subprocess+Linux.swift b/Sources/Subprocess/Platforms/Subprocess+Linux.swift index ac1bca96..a0a8e198 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Linux.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Linux.swift @@ -447,7 +447,7 @@ private struct MonitorThreadContext: Sendable { } } -private extension siginfo_t { +internal extension siginfo_t { var si_status: Int32 { #if canImport(Glibc) return _sifields._sigchld.si_status @@ -700,7 +700,7 @@ private let setup: () = { }() -private func _setupMonitorSignalHandler() { +internal func _setupMonitorSignalHandler() { // Only executed once setup } diff --git a/Sources/Subprocess/Platforms/Subprocess+Windows.swift b/Sources/Subprocess/Platforms/Subprocess+Windows.swift index e5d121bd..3831ae1a 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -592,7 +592,7 @@ internal func monitorProcessTermination( } } - try? await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in // Set up a callback that immediately resumes the continuation and does no // other work. let context = Unmanaged.passRetained(continuation as AnyObject).toOpaque() @@ -804,14 +804,14 @@ extension Executable { } // 1. The directory from which the application loaded. let applicationDirectory = try? fillNullTerminatedWideStringBuffer( - initialSize: DWORD(MAX_PATH), maxSize: DWORD(MAX_PATH) + initialSize: DWORD(MAX_PATH), maxSize: DWORD(Int16.max) ) { return GetModuleFileNameW(nil, $0.baseAddress, DWORD($0.count)) } if let applicationDirectory { insertExecutableAddingExtension( name, - currentPath: applicationDirectory, + currentPath: FilePath(applicationDirectory).removingLastComponent().string, pathExtensions: pathExtensions, storage: &possiblePaths ) @@ -820,7 +820,7 @@ extension Executable { let directorySize = GetCurrentDirectoryW(0, nil) let currentDirectory = try? fillNullTerminatedWideStringBuffer( initialSize: directorySize >= 0 ? directorySize : DWORD(MAX_PATH), - maxSize: DWORD(MAX_PATH) + maxSize: DWORD(Int16.max) ) { return GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) } @@ -836,7 +836,7 @@ extension Executable { let systemDirectorySize = GetSystemDirectoryW(nil, 0) let systemDirectory = try? fillNullTerminatedWideStringBuffer( initialSize: systemDirectorySize >= 0 ? systemDirectorySize : DWORD(MAX_PATH), - maxSize: DWORD(MAX_PATH) + maxSize: DWORD(Int16.max) ) { return GetSystemDirectoryW($0.baseAddress, DWORD($0.count)) } @@ -848,24 +848,13 @@ extension Executable { storage: &possiblePaths ) } - // 4. 16 bit Systen Directory - // Windows documentation stats that - // "No such standard function (similar to GetSystemDirectory) - // exists for the 16-bit system folder". Use C:\Windows\System instead - let systemDirectory16 = FilePath(#"C:\Windows\System"#) - insertExecutableAddingExtension( - name, - currentPath: systemDirectory16.string, - pathExtensions: pathExtensions, - storage: &possiblePaths - ) - // 5. The Windows directory - let windowsDirectorySize = GetSystemWindowsDirectoryW(nil, 0) + // 4. The Windows directory + let windowsDirectorySize = GetWindowsDirectoryW(nil, 0) let windowsDirectory = try? fillNullTerminatedWideStringBuffer( initialSize: windowsDirectorySize >= 0 ? windowsDirectorySize : DWORD(MAX_PATH), - maxSize: DWORD(MAX_PATH) + maxSize: DWORD(Int16.max) ) { - return GetSystemWindowsDirectoryW($0.baseAddress, DWORD($0.count)) + return GetWindowsDirectoryW($0.baseAddress, DWORD($0.count)) } if let windowsDirectory { insertExecutableAddingExtension( @@ -874,6 +863,18 @@ extension Executable { pathExtensions: pathExtensions, storage: &possiblePaths ) + + // 5. 16 bit Systen Directory + // Windows documentation stats that "No such standard function + // (similar to GetSystemDirectory) exists for the 16-bit system folder". + // Use "\(windowsDirectory)\System" instead + let systemDirectory16 = FilePath(windowsDirectory).appending("System") + insertExecutableAddingExtension( + name, + currentPath: systemDirectory16.string, + pathExtensions: pathExtensions, + storage: &possiblePaths + ) } // 6. The directories that are listed in the PATH environment variable if let pathValue { @@ -896,19 +897,17 @@ extension Executable { // MARK: - Environment Resolution extension Environment { - internal static let pathVariableName = "Path" - internal func pathValue() -> String? { switch self.config { case .inherit(let overrides): // If PATH value exists in overrides, use it - if let value = overrides[Self.pathVariableName] { + if let value = overrides.pathValue() { return value } // Fall back to current process - return Self.currentEnvironmentValues()[Self.pathVariableName] + return Self.currentEnvironmentValues().pathValue() case .custom(let fullEnvironment): - if let value = fullEnvironment[Self.pathVariableName] { + if let value = fullEnvironment.pathValue() { return value } return nil @@ -992,11 +991,10 @@ extension Configuration { } // On Windows, the PATH is required in order to locate dlls needed by // the process so we should also pass that to the child - let pathVariableName = Environment.pathVariableName - if env[pathVariableName] == nil, - let parentPath = Environment.currentEnvironmentValues()[pathVariableName] + if env.pathValue() == nil, + let parentPath = Environment.currentEnvironmentValues().pathValue() { - env[pathVariableName] = parentPath + env["Path"] = parentPath } // The environment string must be terminated by a double // null-terminator. Otherwise, CreateProcess will fail with @@ -1472,5 +1470,12 @@ internal func fillNullTerminatedWideStringBuffer( throw SubprocessError.UnderlyingError(rawValue: DWORD(ERROR_INSUFFICIENT_BUFFER)) } +// Windows environment key is case insensitive +extension Dictionary where Key == String, Value == String { + internal func pathValue() -> String? { + return self["Path"] ?? self["PATH"] ?? self["path"] + } +} + #endif // canImport(WinSDK) diff --git a/Tests/SubprocessTests/DarwinTests.swift b/Tests/SubprocessTests/DarwinTests.swift index 9bed5e1b..d8893415 100644 --- a/Tests/SubprocessTests/DarwinTests.swift +++ b/Tests/SubprocessTests/DarwinTests.swift @@ -39,7 +39,10 @@ struct SubprocessDarwinTests { #expect(idResult.terminationStatus == .exited(1234567)) } - @Test func testSubprocessPlatformOptionsPreExecProcessActionAndProcessConfigurator() async throws { + @Test( + .disabled("Constantly fails on macOS 26 and Swift 6.2"), + .bug("https://github.com/swiftlang/swift-subprocess/issues/148") + ) func testSubprocessPlatformOptionsPreExecProcessActionAndProcessConfigurator() async throws { let (readFD, writeFD) = try FileDescriptor.pipe() try await readFD.closeAfter { let childPID = try await writeFD.closeAfter { @@ -165,4 +168,38 @@ struct SubprocessDarwinTests { } } +extension FileDescriptor { + internal func readUntilEOF(upToLength maxLength: Int) async throws -> Data { + return try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + let dispatchIO = DispatchIO( + type: .stream, + fileDescriptor: self.rawValue, + queue: .global() + ) { error in + if error != 0 { + continuation.resume(throwing: POSIXError(.init(rawValue: error) ?? .ENODEV)) + } + } + var buffer: Data = Data() + dispatchIO.read( + offset: 0, + length: maxLength, + queue: .global() + ) { done, data, error in + guard error == 0 else { + continuation.resume(throwing: POSIXError(.init(rawValue: error) ?? .ENODEV)) + return + } + if let data = data { + buffer += Data(data) + } + if done { + dispatchIO.close() + continuation.resume(returning: buffer) + } + } + } + } +} + #endif // canImport(Darwin) diff --git a/Tests/SubprocessTests/IntegrationTests.swift b/Tests/SubprocessTests/IntegrationTests.swift index 40927966..99045d9b 100644 --- a/Tests/SubprocessTests/IntegrationTests.swift +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -90,9 +90,11 @@ extension SubprocessIntegrationTests { @Test func testExecutableAtPath() async throws { #if os(Windows) let cmdExe = ProcessInfo.processInfo.environment["COMSPEC"] ?? - #"C:\Windows\System32\cmd.exe"# + ProcessInfo.processInfo.environment["ComSpec"] ?? + ProcessInfo.processInfo.environment["comspec"] + let setup = TestSetup( - executable: .path(FilePath(cmdExe)), + executable: .path(FilePath(try #require(cmdExe))), arguments: .init(["/c", "cd"]) ) #else @@ -169,8 +171,6 @@ extension SubprocessIntegrationTests { ) } - // Windows does not support argument 0 override - // This test will not compile on Windows @Test func testArgumentsOverride() async throws { #if os(Windows) let setup = TestSetup( @@ -255,7 +255,10 @@ extension SubprocessIntegrationTests { #expect(result.terminationStatus.isSuccess) let pathValue = try #require(result.standardOutput) #if os(Windows) - let expected = try #require(ProcessInfo.processInfo.environment["Path"]) + let expectedPathValue = ProcessInfo.processInfo.environment["Path"] ?? + ProcessInfo.processInfo.environment["PATH"] ?? + ProcessInfo.processInfo.environment["path"] + let expected = try #require(expectedPathValue) #else let expected = try #require(ProcessInfo.processInfo.environment["PATH"]) #endif @@ -317,11 +320,14 @@ extension SubprocessIntegrationTests { ) func testEnvironmentCustom() async throws { #if os(Windows) + let pathValue = ProcessInfo.processInfo.environment["Path"] ?? + ProcessInfo.processInfo.environment["PATH"] ?? + ProcessInfo.processInfo.environment["path"] let setup = TestSetup( executable: .name("cmd.exe"), arguments: ["/c", "set"], environment: .custom([ - "Path": try #require(ProcessInfo.processInfo.environment["Path"]), + "Path": try #require(pathValue), "ComSpec": try #require(ProcessInfo.processInfo.environment["ComSpec"]), ]) ) @@ -1577,12 +1583,13 @@ extension SubprocessIntegrationTests { @Test func testTerminateProcess() async throws { #if os(Windows) let setup = TestSetup( - executable: .name("cmd.exe"), - arguments: ["/c", "timeout /t 99999 >nul"]) + executable: .name("powershell.exe"), + arguments: ["-Command", "Start-Sleep -Seconds 9999"] + ) #else let setup = TestSetup( - executable: .path("/bin/sleep"), - arguments: ["infinite"] + executable: .path("/usr/bin/tail"), + arguments: ["-f", "/dev/null"] ) #endif let stuckResult = try await _run( @@ -1789,13 +1796,13 @@ extension SubprocessIntegrationTests { #if os(Windows) let setup = TestSetup( - executable: .name("cmd.exe"), - arguments: ["/c", "timeout /t 100000 /nobreak"] + executable: .name("powershell.exe"), + arguments: ["-Command", "Start-Sleep -Seconds 9999"] ) #else let setup = TestSetup( - executable: .path("/bin/sleep"), - arguments: ["100000"] + executable: .path("/usr/bin/tail"), + arguments: ["-f", "/dev/null"] ) #endif for i in 0 ..< 100 { @@ -1811,7 +1818,7 @@ extension SubprocessIntegrationTests { setup, platformOptions: platformOptions, input: .none, - output: .string(limit: .max), + output: .discarded, error: .discarded ).terminationStatus } @@ -2267,3 +2274,29 @@ func _run( ) } +extension FileDescriptor { + /// Runs a closure and then closes the FileDescriptor, even if an error occurs. + /// + /// - Parameter body: The closure to run. + /// If the closure throws an error, + /// this method closes the file descriptor before it rethrows that error. + /// + /// - Returns: The value returned by the closure. + /// + /// If `body` throws an error + /// or an error occurs while closing the file descriptor, + /// this method rethrows that error. + public func closeAfter(_ body: () async throws -> R) async throws -> R { + // No underscore helper, since the closure's throw isn't necessarily typed. + let result: R + do { + result = try await body() + } catch { + _ = try? self.close() // Squash close error and throw closure's + throw error + } + try self.close() + return result + } +} + diff --git a/Tests/SubprocessTests/ProcessMonitoringTests.swift b/Tests/SubprocessTests/ProcessMonitoringTests.swift new file mode 100644 index 00000000..3f31d8c8 --- /dev/null +++ b/Tests/SubprocessTests/ProcessMonitoringTests.swift @@ -0,0 +1,329 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if canImport(System) +@preconcurrency import System +#else +@preconcurrency import SystemPackage +#endif + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Bionic) +import Bionic +#elseif canImport(Musl) +import Musl +#elseif canImport(WinSDK) +import WinSDK +#endif + +import Testing +import Dispatch +import Foundation +import TestResources +import _SubprocessCShims +@testable import Subprocess + +@Suite("Subprocess Process Monitoring Unit Tests", .serialized) +struct SubprocessProcessMonitoringTests { + + init() { + #if os(Linux) || os(Android) + _setupMonitorSignalHandler() + #endif + } + + private func immediateExitProcess(withExitCode code: Int) -> Configuration { + #if os(Windows) + return Configuration( + executable: .name("cmd.exe"), + arguments: ["/c", "exit \(code)"] + ) + #else + return Configuration( + executable: .path("/bin/sh"), + arguments: ["-c", "exit \(code)"] + ) + #endif + } + + private func longRunningProcess(withTimeOutSeconds timeout: Double? = nil) -> Configuration { + #if os(Windows) + let waitTime = timeout ?? 99999 + return Configuration( + executable: .name("powershell.exe"), + arguments: ["-Command", "Start-Sleep -Seconds \(waitTime)"] + ) + #else + let waitTime = timeout.map { "\($0)" } ?? "infinite" + return Configuration( + executable: .path("/bin/sleep"), + arguments: [waitTime] + ) + #endif + } + + private func devNullInputPipe() throws -> CreatedPipe { + #if os(Windows) + let devnullFd: FileDescriptor = try .openDevNull(withAccessMode: .writeOnly) + let devnull = HANDLE(bitPattern: _get_osfhandle(devnullFd.rawValue))! + #else + let devnull: FileDescriptor = try .openDevNull(withAccessMode: .readOnly) + #endif + return CreatedPipe( + readFileDescriptor: .init(devnull, closeWhenDone: true), + writeFileDescriptor: nil + ) + } + + private func devNullOutputPipe() throws -> CreatedPipe { + #if os(Windows) + let devnullFd: FileDescriptor = try .openDevNull(withAccessMode: .writeOnly) + let devnull = HANDLE(bitPattern: _get_osfhandle(devnullFd.rawValue))! + #else + let devnull: FileDescriptor = try .openDevNull(withAccessMode: .writeOnly) + #endif + return CreatedPipe( + readFileDescriptor: nil, + writeFileDescriptor: .init(devnull, closeWhenDone: true) + ) + } + + private func withSpawnedExecution( + config: Configuration, + _ body: (Execution) async throws -> Void + ) async throws { + let spawnResult = try config.spawn( + withInput: self.devNullInputPipe(), + outputPipe: self.devNullOutputPipe(), + errorPipe: self.devNullOutputPipe() + ) + defer { + spawnResult.execution.processIdentifier.close() + } + try await body(spawnResult.execution) + } +} + +// MARK: - Basic Functionality Tests +extension SubprocessProcessMonitoringTests { + @Test func testNormalExit() async throws { + let config = self.immediateExitProcess(withExitCode: 0) + try await withSpawnedExecution(config: config) { execution in + let monitorResult = try await monitorProcessTermination( + for: execution.processIdentifier + ) + + #expect(monitorResult.isSuccess) + } + } + + @Test func testExitCode() async throws { + let config = self.immediateExitProcess(withExitCode: 42) + try await withSpawnedExecution(config: config) { execution in + let monitorResult = try await monitorProcessTermination( + for: execution.processIdentifier + ) + + #expect(monitorResult == .exited(42)) + } + } + + #if !os(Windows) + @Test func testExitViaSignal() async throws { + let config = Configuration( + executable: .path("/usr/bin/tail"), + arguments: ["-f", "/dev/null"] + ) + try await withSpawnedExecution(config: config) { execution in + // Send signal to process + try execution.send(signal: .terminate) + + let result = try await monitorProcessTermination( + for: execution.processIdentifier + ) + #expect(result == .unhandledException(SIGTERM)) + } + } + #endif +} + +// MARK: - Edge Cases +extension SubprocessProcessMonitoringTests { + @Test func testAlreadyTerminatedProcess() async throws { + let config = self.immediateExitProcess(withExitCode: 0) + try await withSpawnedExecution(config: config) { execution in + // Manually wait for the process to make sure it exits + #if os(Windows) + WaitForSingleObject( + execution.processIdentifier.processDescriptor, + INFINITE + ) + #else + var siginfo = siginfo_t() + waitid( + P_PID, + id_t(execution.processIdentifier.value), + &siginfo, + WEXITED | WNOWAIT + ) + #endif + // Now make sure monitorProcessTermination() can still get the correct result + let monitorResult = try await monitorProcessTermination( + for: execution.processIdentifier + ) + #expect(monitorResult == .exited(0)) + } + } + + @Test func testCanMonitorLongRunningProcess() async throws { + let config = self.longRunningProcess(withTimeOutSeconds: 1) + try await withSpawnedExecution(config: config) { execution in + let monitorResult = try await monitorProcessTermination( + for: execution.processIdentifier + ) + + #expect(monitorResult.isSuccess) + } + } + + @Test func testInvalidProcessIdentifier() async throws { + #if os(Windows) + let expectedError = SubprocessError( + code: .init(.failedToMonitorProcess), + underlyingError: .init(rawValue: DWORD(ERROR_INVALID_PARAMETER)) + ) + let processIdentifier = ProcessIdentifier( + value: .max, processDescriptor: INVALID_HANDLE_VALUE, threadHandle: INVALID_HANDLE_VALUE + ) + #elseif os(Linux) || os (FreeBSD) + let expectedError = SubprocessError( + code: .init(.failedToMonitorProcess), + underlyingError: .init(rawValue: ECHILD) + ) + let processIdentifier = ProcessIdentifier( + value: .max, processDescriptor: -1 + ) + #else + let expectedError = SubprocessError( + code: .init(.failedToMonitorProcess), + underlyingError: .init(rawValue: ECHILD) + ) + let processIdentifier = ProcessIdentifier(value: .max) + #endif + await #expect(throws: expectedError) { + _ = try await monitorProcessTermination(for: processIdentifier) + } + } + + @Test func testDoesNotReapUnrelatedChildProcess() async throws { + // Make sure we don't reap child exit status that we didn't spawn + let child1 = self.immediateExitProcess(withExitCode: 0) + let child2 = self.immediateExitProcess(withExitCode: 0) + try await withSpawnedExecution(config: child1) { child1Execution in + try await withSpawnedExecution(config: child2) { child2Execution in + // Monitor child2, but make sure we don't reap child1's status + let status = try await monitorProcessTermination( + for: child2Execution.processIdentifier + ) + #expect(status.isSuccess) + // Make sure we can still fetch child 1 + #if os(Windows) + let rc = WaitForSingleObject( + child1Execution.processIdentifier.processDescriptor, + INFINITE + ) + #expect(rc == WAIT_OBJECT_0) + var child1Status: DWORD = 0 + let rc2 = GetExitCodeProcess( + child1Execution.processIdentifier.processDescriptor, + &child1Status + ) + #expect(rc2 == true) + #expect(child1Status == 0) + #else + var siginfo = siginfo_t() + let rc = waitid( + P_PID, + id_t(child1Execution.processIdentifier.value), + &siginfo, + WEXITED + ) + #expect(rc == 0) + #expect(siginfo.si_code == CLD_EXITED) + #expect(siginfo.si_status == 0) + #endif + } + } + } +} + +// MARK: Concurrency Tests +extension SubprocessProcessMonitoringTests { + @Test func testCanMonitorProcessConcurrently() async throws { + let testCount = 100 + try await withThrowingTaskGroup { group in + for _ in 0 ..< testCount { + group.addTask { + // Sleep for different random time intervals + let config = self.longRunningProcess( + withTimeOutSeconds: Double.random(in: 0 ..< 1.0) + ) + + try await withSpawnedExecution(config: config) { execution in + let monitorResult = try await monitorProcessTermination( + for: execution.processIdentifier + ) + #expect(monitorResult.isSuccess) + } + } + } + + try await group.waitForAll() + } + } + + @Test func testExitSignalCoalescing() async throws { + // Spawn many immediately exit processes in a row to trigger + // signal coalescing. Make sure we can handle this + let testCount = 100 + var spawnedProcesses: [ProcessIdentifier] = [] + + for _ in 0 ..< testCount { + let config = self.immediateExitProcess(withExitCode: 0) + let spawnResult = try config.spawn( + withInput: self.devNullInputPipe(), + outputPipe: self.devNullOutputPipe(), + errorPipe: self.devNullOutputPipe() + ) + spawnedProcesses.append(spawnResult.execution.processIdentifier) + } + + defer { + for pid in spawnedProcesses { + pid.close() + } + } + + try await withThrowingTaskGroup { group in + for pid in spawnedProcesses { + group.addTask { + let status = try await monitorProcessTermination(for: pid) + #expect(status.isSuccess) + } + } + + try await group.waitForAll() + } + } +} diff --git a/Tests/SubprocessTests/UnixTests.swift b/Tests/SubprocessTests/UnixTests.swift index af8f1942..66f4342e 100644 --- a/Tests/SubprocessTests/UnixTests.swift +++ b/Tests/SubprocessTests/UnixTests.swift @@ -458,14 +458,22 @@ extension SubprocessUnixTests { internal func assertNewSessionCreated( with result: CollectedResult< - StringOutput, - Output + StringOutput, + Output > ) throws { - #expect(result.terminationStatus.isSuccess) - let psValue = try #require( - result.standardOutput + try assertNewSessionCreated( + terminationStatus: result.terminationStatus, + output: #require(result.standardOutput) ) +} + +internal func assertNewSessionCreated( + terminationStatus: TerminationStatus, + output psValue: String +) throws { + #expect(terminationStatus.isSuccess) + let match = try #require(try #/\s*PID\s*PGID\s*TPGID\s*(?[\-]?[0-9]+)\s*(?[\-]?[0-9]+)\s*(?[\-]?[0-9]+)\s*/#.wholeMatch(in: psValue), "ps output was in an unexpected format:\n\n\(psValue)") // If setsid() has been called successfully, we should observe: // - pid == pgid From 80aa4c02d42a8cf35b325a7732a35e5ba7db11f3 Mon Sep 17 00:00:00 2001 From: Charles Hu Date: Mon, 18 Aug 2025 14:43:32 -0700 Subject: [PATCH 4/4] Make possibleExecutablePaths() preserve order --- Sources/Subprocess/Configuration.swift | 36 +++ Sources/Subprocess/IO/AsyncIO+Linux.swift | 242 +++++++++++------- .../Platforms/Subprocess+Unix.swift | 6 +- .../Platforms/Subprocess+Windows.swift | 24 +- Tests/SubprocessTests/AsyncIOTests.swift | 55 ++-- 5 files changed, 234 insertions(+), 129 deletions(-) diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index 28f13d78..34d4b159 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -998,3 +998,39 @@ internal func withAsyncTaskCleanupHandler( } } } + +internal struct _OrderedSet: Hashable, Sendable { + private var elements: [Element] + private var hashValueSet: Set + + internal init() { + self.elements = [] + self.hashValueSet = Set() + } + + internal init(_ arrayValue: [Element]) { + self.elements = [] + self.hashValueSet = Set() + + for element in arrayValue { + self.insert(element) + } + } + + mutating func insert(_ element: Element) { + guard !self.hashValueSet.contains(element.hashValue) else { + return + } + self.elements.append(element) + self.hashValueSet.insert(element.hashValue) + } +} + +extension _OrderedSet : Sequence { + typealias Iterator = Array.Iterator + + internal func makeIterator() -> Iterator { + return self.elements.makeIterator() + } +} + diff --git a/Sources/Subprocess/IO/AsyncIO+Linux.swift b/Sources/Subprocess/IO/AsyncIO+Linux.swift index 000e5481..a667aa2c 100644 --- a/Sources/Subprocess/IO/AsyncIO+Linux.swift +++ b/Sources/Subprocess/IO/AsyncIO+Linux.swift @@ -357,59 +357,75 @@ extension AsyncIO { ) var readLength: Int = 0 let signalStream = self.registerFileDescriptor(fileDescriptor, for: .read) - /// Outer loop: every iteration signals we are ready to read more data - for try await _ in signalStream { - /// Inner loop: repeatedly call `.read()` and read more data until: - /// 1. We reached EOF (read length is 0), in which case return the result - /// 2. We read `maxLength` bytes, in which case return the result - /// 3. `read()` returns -1 and sets `errno` to `EAGAIN` or `EWOULDBLOCK`. In - /// this case we `break` out of the inner loop and wait `.read()` to be - /// ready by `await`ing the next signal in the outer loop. - while true { - let bytesRead = resultBuffer.withUnsafeMutableBufferPointer { bufferPointer in - // Get a pointer to the memory at the specified offset - let targetCount = bufferPointer.count - readLength - - let offsetAddress = bufferPointer.baseAddress!.advanced(by: readLength) - - // Read directly into the buffer at the offset - return _subprocess_read(fileDescriptor.rawValue, offsetAddress, targetCount) - } - if bytesRead > 0 { - // Read some data - readLength += bytesRead - if maxLength == .max { - // Grow resultBuffer if needed - guard Double(readLength) > 0.8 * Double(resultBuffer.count) else { - continue + + do { + /// Outer loop: every iteration signals we are ready to read more data + for try await _ in signalStream { + /// Inner loop: repeatedly call `.read()` and read more data until: + /// 1. We reached EOF (read length is 0), in which case return the result + /// 2. We read `maxLength` bytes, in which case return the result + /// 3. `read()` returns -1 and sets `errno` to `EAGAIN` or `EWOULDBLOCK`. In + /// this case we `break` out of the inner loop and wait `.read()` to be + /// ready by `await`ing the next signal in the outer loop. + while true { + let bytesRead = resultBuffer.withUnsafeMutableBufferPointer { bufferPointer in + // Get a pointer to the memory at the specified offset + let targetCount = bufferPointer.count - readLength + + let offsetAddress = bufferPointer.baseAddress!.advanced(by: readLength) + + // Read directly into the buffer at the offset + return _subprocess_read(fileDescriptor.rawValue, offsetAddress, targetCount) + } + let capturedErrno = errno + if bytesRead > 0 { + // Read some data + readLength += bytesRead + if maxLength == .max { + // Grow resultBuffer if needed + guard Double(readLength) > 0.8 * Double(resultBuffer.count) else { + continue + } + resultBuffer.append( + contentsOf: Array(repeating: 0, count: resultBuffer.count) + ) + } else if readLength >= maxLength { + // When we reached maxLength, return! + try self.removeRegistration(for: fileDescriptor) + return resultBuffer } - resultBuffer.append( - contentsOf: Array(repeating: 0, count: resultBuffer.count) - ) - } else if readLength >= maxLength { - // When we reached maxLength, return! + } else if bytesRead == 0 { + // We reached EOF. Return whatever's left try self.removeRegistration(for: fileDescriptor) + guard readLength > 0 else { + return nil + } + resultBuffer.removeLast(resultBuffer.count - readLength) return resultBuffer - } - } else if bytesRead == 0 { - // We reached EOF. Return whatever's left - try self.removeRegistration(for: fileDescriptor) - guard readLength > 0 else { - return nil - } - resultBuffer.removeLast(resultBuffer.count - readLength) - return resultBuffer - } else { - if self.shouldWaitForNextSignal(with: errno) { - // No more data for now wait for the next signal - break } else { - // Throw all other errors - try self.removeRegistration(for: fileDescriptor) - throw SubprocessError.UnderlyingError(rawValue: errno) + if self.shouldWaitForNextSignal(with: capturedErrno) { + // No more data for now wait for the next signal + break + } else { + // Throw all other errors + try self.removeRegistration(for: fileDescriptor) + throw SubprocessError( + code: .init(.failedToReadFromSubprocess), + underlyingError: .init(rawValue: capturedErrno) + ) + } } } } + } catch { + // Reset error code to .failedToRead to match other platforms + guard let originalError = error as? SubprocessError else { + throw error + } + throw SubprocessError( + code: .init(.failedToReadFromSubprocess), + underlyingError: originalError.underlyingError + ) } resultBuffer.removeLast(resultBuffer.count - readLength) return resultBuffer @@ -432,37 +448,52 @@ extension AsyncIO { let fileDescriptor = diskIO.channel let signalStream = self.registerFileDescriptor(fileDescriptor, for: .write) var writtenLength: Int = 0 - /// Outer loop: every iteration signals we are ready to read more data - for try await _ in signalStream { - /// Inner loop: repeatedly call `.write()` and write more data until: - /// 1. We've written bytes.count bytes. - /// 3. `.write()` returns -1 and sets `errno` to `EAGAIN` or `EWOULDBLOCK`. In - /// this case we `break` out of the inner loop and wait `.write()` to be - /// ready by `await`ing the next signal in the outer loop. - while true { - let written = bytes.withUnsafeBytes { ptr in - let remainingLength = ptr.count - writtenLength - let startPtr = ptr.baseAddress!.advanced(by: writtenLength) - return _subprocess_write(fileDescriptor.rawValue, startPtr, remainingLength) - } - if written > 0 { - writtenLength += written - if writtenLength >= bytes.count { - // Wrote all data - try self.removeRegistration(for: fileDescriptor) - return writtenLength + do { + /// Outer loop: every iteration signals we are ready to read more data + for try await _ in signalStream { + /// Inner loop: repeatedly call `.write()` and write more data until: + /// 1. We've written bytes.count bytes. + /// 3. `.write()` returns -1 and sets `errno` to `EAGAIN` or `EWOULDBLOCK`. In + /// this case we `break` out of the inner loop and wait `.write()` to be + /// ready by `await`ing the next signal in the outer loop. + while true { + let written = bytes.withUnsafeBytes { ptr in + let remainingLength = ptr.count - writtenLength + let startPtr = ptr.baseAddress!.advanced(by: writtenLength) + return _subprocess_write(fileDescriptor.rawValue, startPtr, remainingLength) } - } else { - if self.shouldWaitForNextSignal(with: errno) { - // No more data for now wait for the next signal - break + let capturedErrno = errno + if written > 0 { + writtenLength += written + if writtenLength >= bytes.count { + // Wrote all data + try self.removeRegistration(for: fileDescriptor) + return writtenLength + } } else { - // Throw all other errors - try self.removeRegistration(for: fileDescriptor) - throw SubprocessError.UnderlyingError(rawValue: errno) + if self.shouldWaitForNextSignal(with: capturedErrno) { + // No more data for now wait for the next signal + break + } else { + // Throw all other errors + try self.removeRegistration(for: fileDescriptor) + throw SubprocessError( + code: .init(.failedToWriteToSubprocess), + underlyingError: .init(rawValue: capturedErrno) + ) + } } } } + } catch { + // Reset error code to .failedToWrite to match other platforms + guard let originalError = error as? SubprocessError else { + throw error + } + throw SubprocessError( + code: .init(.failedToWriteToSubprocess), + underlyingError: originalError.underlyingError + ) } return 0 } @@ -478,37 +509,52 @@ extension AsyncIO { let fileDescriptor = diskIO.channel let signalStream = self.registerFileDescriptor(fileDescriptor, for: .write) var writtenLength: Int = 0 - /// Outer loop: every iteration signals we are ready to read more data - for try await _ in signalStream { - /// Inner loop: repeatedly call `.write()` and write more data until: - /// 1. We've written bytes.count bytes. - /// 3. `.write()` returns -1 and sets `errno` to `EAGAIN` or `EWOULDBLOCK`. In - /// this case we `break` out of the inner loop and wait `.write()` to be - /// ready by `await`ing the next signal in the outer loop. - while true { - let written = span.withUnsafeBytes { ptr in - let remainingLength = ptr.count - writtenLength - let startPtr = ptr.baseAddress!.advanced(by: writtenLength) - return _subprocess_write(fileDescriptor.rawValue, startPtr, remainingLength) - } - if written > 0 { - writtenLength += written - if writtenLength >= span.byteCount { - // Wrote all data - try self.removeRegistration(for: fileDescriptor) - return writtenLength + do { + /// Outer loop: every iteration signals we are ready to read more data + for try await _ in signalStream { + /// Inner loop: repeatedly call `.write()` and write more data until: + /// 1. We've written bytes.count bytes. + /// 3. `.write()` returns -1 and sets `errno` to `EAGAIN` or `EWOULDBLOCK`. In + /// this case we `break` out of the inner loop and wait `.write()` to be + /// ready by `await`ing the next signal in the outer loop. + while true { + let written = span.withUnsafeBytes { ptr in + let remainingLength = ptr.count - writtenLength + let startPtr = ptr.baseAddress!.advanced(by: writtenLength) + return _subprocess_write(fileDescriptor.rawValue, startPtr, remainingLength) } - } else { - if self.shouldWaitForNextSignal(with: errno) { - // No more data for now wait for the next signal - break + let capturedErrno = errno + if written > 0 { + writtenLength += written + if writtenLength >= span.byteCount { + // Wrote all data + try self.removeRegistration(for: fileDescriptor) + return writtenLength + } } else { - // Throw all other errors - try self.removeRegistration(for: fileDescriptor) - throw SubprocessError.UnderlyingError(rawValue: errno) + if self.shouldWaitForNextSignal(with: capturedErrno) { + // No more data for now wait for the next signal + break + } else { + // Throw all other errors + try self.removeRegistration(for: fileDescriptor) + throw SubprocessError( + code: .init(.failedToWriteToSubprocess), + underlyingError: .init(rawValue: capturedErrno) + ) + } } } } + } catch { + // Reset error code to .failedToWrite to match other platforms + guard let originalError = error as? SubprocessError else { + throw error + } + throw SubprocessError( + code: .init(.failedToWriteToSubprocess), + underlyingError: originalError.underlyingError + ) } return 0 } diff --git a/Sources/Subprocess/Platforms/Subprocess+Unix.swift b/Sources/Subprocess/Platforms/Subprocess+Unix.swift index 5acd49d6..92f11d4e 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -303,10 +303,10 @@ extension Executable { internal func possibleExecutablePaths( withPathValue pathValue: String? - ) -> Set { + ) -> _OrderedSet { switch self.storage { case .executable(let executableName): - var results: Set = [] + var results: _OrderedSet = .init() // executableName could be a full path results.insert(executableName) // Get $PATH from environment @@ -324,7 +324,7 @@ extension Executable { } return results case .path(let executablePath): - return Set([executablePath.string]) + return _OrderedSet([executablePath.string]) } } } diff --git a/Sources/Subprocess/Platforms/Subprocess+Windows.swift b/Sources/Subprocess/Platforms/Subprocess+Windows.swift index 3831ae1a..38827835 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -63,14 +63,14 @@ extension Configuration { // automatic PATH lookup so we only need to call `CreateProcessW` once. However, if // user wants to override executable path in arguments, we have to use `lpApplicationName` // to specify the executable path. In this case, manually loop over all possible paths. - let possibleExecutablePaths: Set + let possibleExecutablePaths: _OrderedSet if _fastPath(self.arguments.executablePathOverride == nil) { // Fast path: we can rely on `CreateProcessW`'s built in Path searching switch self.executable.storage { case .executable(let executable): - possibleExecutablePaths = Set([executable]) + possibleExecutablePaths = _OrderedSet([executable]) case .path(let path): - possibleExecutablePaths = Set([path.string]) + possibleExecutablePaths = _OrderedSet([path.string]) } } else { // Slow path: user requested arg0 override, therefore we must manually @@ -255,14 +255,14 @@ extension Configuration { // automatic PATH lookup so we only need to call `CreateProcessW` once. However, if // user wants to override executable path in arguments, we have to use `lpApplicationName` // to specify the executable path. In this case, manually loop over all possible paths. - let possibleExecutablePaths: Set + let possibleExecutablePaths: _OrderedSet if _fastPath(self.arguments.executablePathOverride == nil) { // Fast path: we can rely on `CreateProcessW`'s built in Path searching switch self.executable.storage { case .executable(let executable): - possibleExecutablePaths = Set([executable]) + possibleExecutablePaths = _OrderedSet([executable]) case .path(let path): - possibleExecutablePaths = Set([path.string]) + possibleExecutablePaths = _OrderedSet([path.string]) } } else { // Slow path: user requested arg0 override, therefore we must manually @@ -771,12 +771,12 @@ extension Executable { /// https://learn.microsoft.com/en-us/windows/win32/api/processthreadsapi/nf-processthreadsapi-createprocessw internal func possibleExecutablePaths( withPathValue pathValue: String? - ) -> Set { + ) -> _OrderedSet { func insertExecutableAddingExtension( _ name: String, currentPath: String, - pathExtensions: Set, - storage: inout Set + pathExtensions: _OrderedSet, + storage: inout _OrderedSet ) { let fullPath = FilePath(currentPath).appending(name) if !name.hasExtension() { @@ -792,10 +792,10 @@ extension Executable { switch self.storage { case .executable(let name): - var possiblePaths: Set = [] + var possiblePaths: _OrderedSet = .init() let currentEnvironmentValues = Environment.currentEnvironmentValues() // If `name` does not include extensions, we need to try these extensions - var pathExtensions: Set = Set(["com", "exe", "cmd", "bat"]) + var pathExtensions: _OrderedSet = _OrderedSet(["com", "exe", "bat", "cmd"]) if let extensionList = currentEnvironmentValues["PATHEXT"] { for var ext in extensionList.split(separator: ";") { ext.removeFirst(1) @@ -890,7 +890,7 @@ extension Executable { } return possiblePaths case .path(let path): - return Set([path.string]) + return _OrderedSet([path.string]) } } } diff --git a/Tests/SubprocessTests/AsyncIOTests.swift b/Tests/SubprocessTests/AsyncIOTests.swift index f364e0bf..7a3a07ad 100644 --- a/Tests/SubprocessTests/AsyncIOTests.swift +++ b/Tests/SubprocessTests/AsyncIOTests.swift @@ -146,20 +146,32 @@ extension SubprocessAsyncIOTests { try writeChannel.safelyClose() - do { + await #expect { _ = try await AsyncIO.shared.write([100], to: writeChannel) - Issue.record("Expected write to closed pipe to throw an error") - } catch { + } throws: { error in guard let subprocessError = error as? SubprocessError else { - Issue.record("Expecting SubprocessError, but got \(error)") - return + return false } - #if canImport(Darwin) || os(FreeBSD) || os(OpenBSD) + guard subprocessError.code == .init(.failedToWriteToSubprocess) else { + return false + } + + #if os(Windows) + #expect(subprocessError.underlyingError == .init(rawValue: DWORD(ERROR_INVALID_HANDLE))) + #elseif canImport(Darwin) || os(FreeBSD) || os(OpenBSD) #expect(subprocessError.underlyingError == .init(rawValue: ECANCELED)) - #elseif os(Linux) || os(Android) - #expect(subprocessError.underlyingError == .init(rawValue: EBADF)) + #else + // On Linux, depending on timing, either epoll_ctl or write + // could throw error first + #expect( + subprocessError.underlyingError == .init(rawValue: EBADF) || + subprocessError.underlyingError == .init(rawValue: EINVAL) || + subprocessError.underlyingError == .init(rawValue: EPERM) + ) #endif + return true } + } @Test func testReadFromClosedPipe() async throws { @@ -172,19 +184,30 @@ extension SubprocessAsyncIOTests { try readChannel.safelyClose() - do { + await #expect { _ = try await AsyncIO.shared.read(from: readChannel, upTo: .max) - Issue.record("Expected write to closed pipe to throw an error") - } catch { + } throws: { error in guard let subprocessError = error as? SubprocessError else { - Issue.record("Expecting SubprocessError, but got \(error)") - return + return false } - #if canImport(Darwin) || os(FreeBSD) || os(OpenBSD) + guard subprocessError.code == .init(.failedToReadFromSubprocess) else { + return false + } + + #if os(Windows) + #expect(subprocessError.underlyingError == .init(rawValue: DWORD(ERROR_INVALID_HANDLE))) + #elseif canImport(Darwin) || os(FreeBSD) || os(OpenBSD) #expect(subprocessError.underlyingError == .init(rawValue: ECANCELED)) - #elseif os(Linux) || os(Android) - #expect(subprocessError.underlyingError == .init(rawValue: EBADF)) + #else + // On Linux, depending on timing, either epoll_ctl or read + // could throw error first + #expect( + subprocessError.underlyingError == .init(rawValue: EBADF) || + subprocessError.underlyingError == .init(rawValue: EINVAL) || + subprocessError.underlyingError == .init(rawValue: EPERM) + ) #endif + return true } }