diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index 6e6cd38..4b6176f 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -79,33 +79,39 @@ 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(forExecution: _spawnResult.execution) + // 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( + forExecution: _spawnResult.execution + ) - 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 + ) + } } } @@ -329,12 +335,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 { @@ -864,7 +870,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 4d355f3..11cf94a 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 783b04c..0499615 100644 --- a/Sources/Subprocess/IO/AsyncIO+Linux.swift +++ b/Sources/Subprocess/IO/AsyncIO+Linux.swift @@ -67,8 +67,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 { @@ -204,11 +205,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 _ = _SubprocessCShims.write(currentState.shutdownFileDescriptor, &one, MemoryLayout.stride) @@ -226,7 +231,6 @@ final class AsyncIO: Sendable { } } - private func registerFileDescriptor( _ fileDescriptor: FileDescriptor, for event: Event @@ -277,11 +281,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 @@ -344,6 +349,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 @@ -407,6 +415,7 @@ extension AsyncIO { } } } + resultBuffer.removeLast(resultBuffer.count - readLength) return resultBuffer } @@ -421,6 +430,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 @@ -464,6 +476,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 72fd6a2..031126b 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 715428e..55db67d 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 1759091..1096a30 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 122e5c5..3fabede 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -120,9 +120,10 @@ extension Execution { ) throws { let pid = shouldSendToProcessGroup ? -(processIdentifier.value) : processIdentifier.value guard kill(pid, signal.rawValue) == 0 else { + let capturedError = errno throw SubprocessError( code: .init(.failedToSendSignal(signal.rawValue)), - underlyingError: .init(rawValue: errno) + underlyingError: .init(rawValue: capturedError) ) } } @@ -362,13 +363,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 47ed990..9005fac 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) @@ -737,17 +778,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, @@ -757,7 +788,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 @@ -772,26 +803,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 @@ -803,7 +841,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 @@ -815,7 +856,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 @@ -825,7 +869,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 d5dd551..cb11eed 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.processInformation.hProcess, &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 6eb185e..a5e87f0 100644 --- a/Sources/_SubprocessCShims/include/process_shims.h +++ b/Sources/_SubprocessCShims/include/process_shims.h @@ -84,6 +84,8 @@ int _shims_snprintf( #if TARGET_OS_WINDOWS +#include + #ifndef _WINDEF_ typedef unsigned long DWORD; typedef int BOOL; @@ -92,6 +94,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 c6a70d1..53225c1 100644 --- a/Sources/_SubprocessCShims/process_shims.c +++ b/Sources/_SubprocessCShims/process_shims.c @@ -790,5 +790,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 0000000..03e0c90 --- /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 0000000..fe40380 --- /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 2a317c6..0000000 --- a/Tests/SubprocessTests/SubprocessTests+Unix.swift +++ /dev/null @@ -1,1147 +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/cat"), - error: .discarded - ) { subprocess, standardOutput in - // Make sure we can send signals to terminate the process - try subprocess.send(signal: .terminate) - for try await _ in standardOutput {} - } - 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 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) -} - -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 b8e75a1..c13dc1d 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 0000000..88b013e --- /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 52% rename from Tests/SubprocessTests/SubprocessTests+Windows.swift rename to Tests/SubprocessTests/WindowsTests.swift index dc799e5..2e9242f 100644 --- a/Tests/SubprocessTests/SubprocessTests+Windows.swift +++ b/Tests/SubprocessTests/WindowsTests.swift @@ -30,430 +30,12 @@ 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..