diff --git a/.gitignore b/.gitignore index 25433b5..cf7851a 100644 --- a/.gitignore +++ b/.gitignore @@ -11,3 +11,4 @@ vendor .bundle docs docs.tar.xz +.vscode diff --git a/Package.swift b/Package.swift index 644c1d1..adc0205 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ let package = Package( name: "swsh", dependencies: [ .target(name: "linuxSpawn", condition: .when(platforms: [.linux])), + .target(name: "windowsSpawn", condition: .when(platforms: [.windows])), ] ), .testTarget( @@ -28,5 +29,9 @@ let package = Package( name: "linuxSpawn", dependencies: [] ), + .target( + name: "windowsSpawn", + dependencies: [] + ), ] ) diff --git a/Sources/swsh/Command+async.swift b/Sources/swsh/Command+async.swift index b9bdafb..a7d5899 100644 --- a/Sources/swsh/Command+async.swift +++ b/Sources/swsh/Command+async.swift @@ -35,11 +35,11 @@ extension Command { /// - Returns: output as Data @available(macOS 10.15, *) public func runData() async throws -> Data { - let pipe = Pipe() - let write = pipe.fileHandleForWriting - let result = async(fdMap: [ .stdout: write.fd ]) - close(write.fileDescriptor) - let data = pipe.fileHandleForReading.readDataToEndOfFile() + let pipe = FDPipe() + let result = async(fdMap: [ .stdout: pipe.fileHandleForWriting.fileDescriptor ]) + pipe.fileHandleForWriting.close() + let data = pipe.fileHandleForReading.handle.readDataToEndOfFile() + pipe.fileHandleForReading.close() try await result.succeed() return data } diff --git a/Sources/swsh/Command.swift b/Sources/swsh/Command.swift index ccd4d8a..2644386 100644 --- a/Sources/swsh/Command.swift +++ b/Sources/swsh/Command.swift @@ -34,11 +34,11 @@ extension Command { } /// Run the command asynchronously, and return a stream open on process's stdout - public func asyncStream() -> FileHandle { - let pipe = Pipe() - let write = pipe.fileHandleForWriting - _ = async(fdMap: [ .stdout: write.fd ]) - close(write.fileDescriptor) + public func asyncStream() -> FDFileHandle { + let pipe = FDPipe() + _ = async(fdMap: [ .stdout: pipe.fileHandleForWriting.fileDescriptor ]) + pipe.fileHandleForWriting.close() + // TODO: Previously, this returned a FileHandle, but the test using it fails without returning an FDFileHandle, because otherwise, on return, the FDFileHandle is deallocate and calls close(). Can maintain liveness of the FDFileHandle in some way without changing the interface of this call? return pipe.fileHandleForReading } @@ -68,11 +68,10 @@ extension Command { /// - Throws: if command fails /// - Returns: output as Data public func runData() throws -> Data { - let pipe = Pipe() - let write = pipe.fileHandleForWriting - let result = async(fdMap: [ .stdout: write.fd ]) - close(write.fileDescriptor) - let data = pipe.fileHandleForReading.readDataToEndOfFile() + let pipe = FDPipe() + let result = async(fdMap: [ .stdout: pipe.fileHandleForWriting.fileDescriptor ]) + pipe.fileHandleForWriting.close() + let data = pipe.fileHandleForReading.handle.readDataToEndOfFile() try result.succeed() return data } @@ -89,6 +88,7 @@ extension Command { throw InvalidString(data: data, encoding: encoding) } guard let trimStop = string.lastIndex(where: { $0 != "\n" }) else { + // TODO: Shoudn't this return `string`? return "" } return String(string[...trimStop]) diff --git a/Sources/swsh/Errors.swift b/Sources/swsh/Errors.swift index 820ae53..cf87e06 100644 --- a/Sources/swsh/Errors.swift +++ b/Sources/swsh/Errors.swift @@ -48,7 +48,19 @@ public class SyscallError: Error, CommandResult, CustomStringConvertible { } public var description: String { - "\(name) failed with error code \(errno): \(String(cString: strerror(errno)))" + let errorMessage: String + #if os(Windows) + let errlen = 1024 // Is this enough? Windows is badly designed and poorly documented + errorMessage = withUnsafeTemporaryAllocation(of: CChar.self, capacity: errlen + 1) { buffer in + strerror_s(buffer.baseAddress, errlen, errno) + // Ensure we have at least 1 null terminator, not sure if this is needed + buffer[errlen] = 0 + return String(cString: buffer.baseAddress!) + } + #else + errorMessage = String(cString: strerror(errno)) + #endif + return "\(name) failed with error code \(errno): \(errorMessage)" } public func succeed() throws { @@ -59,3 +71,7 @@ public class SyscallError: Error, CommandResult, CustomStringConvertible { throw self } } + +enum PlatformError: Error { + case killUnsupportedOnWindows +} diff --git a/Sources/swsh/ExternalCommand.swift b/Sources/swsh/ExternalCommand.swift index cabd1c4..48dc06c 100644 --- a/Sources/swsh/ExternalCommand.swift +++ b/Sources/swsh/ExternalCommand.swift @@ -1,4 +1,7 @@ import Foundation +#if os(Windows) +import WinSDK +#endif /// Represents an external program invocation. It is the lowest-level command, that will spawn a subprocess when run. public class ExternalCommand: Command, CustomStringConvertible { @@ -9,7 +12,7 @@ public class ExternalCommand: Command, CustomStringConvertible { public let description: String - /// like "set -x", this will cause all external commands to print themselves when they run + /// Like "set -x", this will cause all external commands to print themselves when they run public static var verbose: Bool = false internal var spawner: ProcessSpawner @@ -50,6 +53,8 @@ public class ExternalCommand: Command, CustomStringConvertible { self.spawner = PosixSpawn() #elseif canImport(Glibc) self.spawner = LinuxSpawn() + #elseif canImport(ucrt) + self.spawner = WindowsSpawn() #endif } @@ -58,34 +63,60 @@ public class ExternalCommand: Command, CustomStringConvertible { let name: String var command: Command - let pid: pid_t + let process: ProcessInformation + private var _exitCode: Int32? private var _exitSemaphore = DispatchSemaphore(value: 0) private var _exitContinuations: [() -> Void] = [] - init(command: ExternalCommand, pid: pid_t) { + init(command: ExternalCommand, process: ProcessInformation) { self.command = command self.name = command.command - self.pid = pid + self.process = process - command.spawner.reapAsync(pid: pid, queue: Result.reaperQueue) { [weak self] exitCode in + command.spawner.reapAsync(process: process, queue: Result.reaperQueue) { [weak self] exitCode in self?._exitCode = exitCode self?._exitSemaphore.signal() self?._exitContinuations.forEach { $0() } self?._exitContinuations = [] } - try? kill(signal: SIGCONT) + try? command.spawner.resume(process: process) } var isRunning: Bool { Result.reaperQueue.sync { _exitCode == nil } } + + static let NtSuspendProcess: (ProcessInformation) -> Int32 = { + // TODO: Tie into (undocumented) NtSuspendProcess() syscall + return { _ in 0 } + }() + + static let NtResumeProcess: (ProcessInformation) -> Int32 = { + // TODO: Tie into (undocumented) NtResumeProcess() syscall + return { _ in 0 } + }() func kill(signal: Int32) throws { - guard Foundation.kill(pid, signal) == 0 else { + #if os(Windows) + // TODO: figure out how to do this in-executable? + if signal == SIGTERM || signal == SIGKILL { + try cmd("taskkill.exe", "/F", "/pid", "\(process.id)").run() + } else if signal == SIGSTOP { + // Method borrowed from https://github.com/giampaolo/psutil/blob/a7e70bb66d5823f2cdcdf0f950bdbf26875058b4/psutil/arch/windows/proc.c#L539 + // See also https://ntopcode.wordpress.com/2018/01/16/anatomy-of-the-thread-suspension-mechanism-in-windows-windows-internals/ + _ = Self.NtSuspendProcess(process) + } else if signal == SIGCONT { + _ = Self.NtResumeProcess(process) + } else { + throw PlatformError.killUnsupportedOnWindows + } + #else + guard Foundation.kill(process.id, signal) == 0 else { throw SyscallError(name: "kill", command: command, errno: errno) } + #endif } func succeed() throws { try defaultSucceed(name: name) } @@ -125,22 +156,22 @@ public class ExternalCommand: Command, CustomStringConvertible { fdMap: fdMap, pathResolve: true ) { - case .success(let pid): - return Result(command: self, pid: pid) + case .success(let process): + return Result(command: self, process: process) case .error(let err): return SyscallError(name: "launching \"\(command)\"", command: self, errno: err) } } } -/// Convenience function for creating an extternal command. Does **not** run the command. +/// Convenience function for creating an external command. Does **not** run the command. /// - Parameter command: The executable to run /// - Parameter arguments: The command line arguments to pass. No substitution is performed public func cmd(_ command: String, arguments: [String], addEnv: [String: String] = [:]) -> Command { ExternalCommand(command, arguments: arguments, addEnv: addEnv) } -/// Convenience function for creating an extternal command. Does **not** run the command. +/// Convenience function for creating an external command. Does **not** run the command. /// - Parameter command: The executable to run /// - Parameter arguments: The command line arguments to pass. No substitution is performed public func cmd(_ command: String, _ arguments: String..., addEnv: [String: String] = [:]) -> Command { diff --git a/Sources/swsh/FDFileHandle.swift b/Sources/swsh/FDFileHandle.swift new file mode 100644 index 0000000..c6dc700 --- /dev/null +++ b/Sources/swsh/FDFileHandle.swift @@ -0,0 +1,50 @@ +import Foundation + +/// A version of `FileHandle` that can be accessed by handle or fd at the same time +public class FDFileHandle: CustomDebugStringConvertible { + public let fileDescriptor: FileDescriptor + public let handle: FileHandle + private let closeOnDealloc: Bool + private var isClosed: Bool + + public convenience init(fileDescriptor: FileDescriptor, closeOnDealloc: Bool) { + // Construct the handle around the fd, but do not use closeOnDealloc, as this closes the fd! + let handle = FileHandle(fileDescriptor: fileDescriptor.rawValue, closeOnDealloc: false) + self.init(fileDescriptor: fileDescriptor, handle: handle, closeOnDealloc: closeOnDealloc) + } + + public init(fileDescriptor: FileDescriptor, handle: FileHandle, closeOnDealloc: Bool) { + self.fileDescriptor = fileDescriptor + self.handle = handle + self.closeOnDealloc = closeOnDealloc + self.isClosed = false + // print("Created FDFileHandle for \(debugDescription)") + } + + public func close() { + precondition(!isClosed) + close_fd(fileDescriptor) + isClosed = true + // print("Closed FDFileHandle for \(debugDescription)") + } + + public var debugDescription: String { + return "fd: \(fileDescriptor.rawValue) handle: \(handle) closeOnDealloc: \(closeOnDealloc)" + } + + deinit { + if closeOnDealloc && !isClosed { + close() + } + } +} + +fileprivate func close_fd(_ fileDescriptor: FileDescriptor) { + #if os(Windows) + printOSCall("_close", fileDescriptor.rawValue) + _close(fileDescriptor.rawValue) + #else + printOSCall("close", fileDescriptor.rawValue) + close(fileDescriptor.rawValue) + #endif +} diff --git a/Sources/swsh/FDPipe.swift b/Sources/swsh/FDPipe.swift new file mode 100644 index 0000000..327fd81 --- /dev/null +++ b/Sources/swsh/FDPipe.swift @@ -0,0 +1,91 @@ +import Foundation +#if os(Windows) +import WinSDK +import ucrt +#endif + +/// A version of `Pipe` that works more uniformly between windows and posix +public class FDPipe { + public let fileHandleForReading: FDFileHandle + public let fileHandleForWriting: FDFileHandle + + public init() { + #if os(Windows) + // Adapted from libuv: + // https://github.com/libuv/libuv/blob/34db4c21b1f3182a74091d927b10bb9830ef6717/src/win/pipe.c#L249 + let uniquePipeName = "\\\\.\\pipe\\swsh-\(UUID().uuidString)-\(GetCurrentProcessId())" + let pipeName: UnsafeMutablePointer? = uniquePipeName.withCString(encodedAs: UTF8.self) { _strdup($0) } + defer { free(pipeName) } + let pipeMode = DWORD(PIPE_TYPE_BYTE) | DWORD(PIPE_READMODE_BYTE) | DWORD(PIPE_WAIT) + let serverAccess = + DWORD(PIPE_ACCESS_INBOUND) | + DWORD(PIPE_ACCESS_OUTBOUND) | + DWORD(WRITE_DAC) | + DWORD(FILE_FLAG_FIRST_PIPE_INSTANCE) + let clientAccess = + DWORD(GENERIC_READ) | + // DWORD(FILE_READ_ATTRIBUTES) | + DWORD(GENERIC_WRITE) | + // DWORD(FILE_WRITE_ATTRIBUTES) | + DWORD(WRITE_DAC) + + printOSCall("CreateNamedPipeA", pipeName, serverAccess, pipeMode, 1, 65536, 65536, 0, nil) + let serverPipe = CreateNamedPipeA( + /* lpName */ pipeName, + /* dwOpenMode */ serverAccess, + /* dwPipeMode */ pipeMode, + /* nMaxInstances */ 1, + /* nOutBufferSize */ 65536, + /* nInBufferSize */ 65536, + /* nDefaultTimeOut */ 0, + /* lpSecurityAttributes */ nil + ) + guard serverPipe != INVALID_HANDLE_VALUE else { + fatalError("Server pipe creation failed with error: \(WindowsSpawnImpl.Error(systemError: GetLastError()))") + } + + var clientSecurityAttributes = SECURITY_ATTRIBUTES() + clientSecurityAttributes.nLength = DWORD(MemoryLayout.size) + clientSecurityAttributes.lpSecurityDescriptor = nil + clientSecurityAttributes.bInheritHandle = true + printOSCall("CreateFileA", pipeName, clientAccess, 0, "ptr(\(clientSecurityAttributes))", "OPEN_EXISTING", 0, nil) + let clientPipe = CreateFileA( + /* lpFileName */ pipeName, + /* dwDesiredAccess */ clientAccess, + /* dwShareMode */ 0, + /* lpSecurityAttributes */ &clientSecurityAttributes, + /* dwCreationDisposition */ DWORD(OPEN_EXISTING), + /* dwFlagsAndAttributes */ 0, + /* hTemplateFile */ nil + ) + guard clientPipe != INVALID_HANDLE_VALUE else { + fatalError("Client pipe creation failed with error: \(WindowsSpawnImpl.Error(systemError: GetLastError()))") + } + + printOSCall("ConnectNamedPipe", serverPipe, nil) + guard ConnectNamedPipe(serverPipe, nil) || GetLastError() == ERROR_PIPE_CONNECTED else { + fatalError("Pipe connection failed with error: \(WindowsSpawnImpl.Error(systemError: GetLastError()))") + } + + printOSCall("_open_osfhandle", clientPipe, 0) + let fileDescriptorForReading = FileDescriptor(_open_osfhandle(.init(bitPattern: clientPipe), _O_APPEND)) + printOSCall("_open_osfhandle", serverPipe, 0) + let fileDescriptorForWriting = FileDescriptor(_open_osfhandle(.init(bitPattern: serverPipe), _O_APPEND)) + fileHandleForReading = FDFileHandle(fileDescriptor: fileDescriptorForReading, closeOnDealloc: true) + fileHandleForWriting = FDFileHandle(fileDescriptor: fileDescriptorForWriting, closeOnDealloc: true) + + #else + let pipe = Pipe() + fileHandleForReading = FDFileHandle( + fileDescriptor: FileDescriptor(pipe.fileHandleForReading.fileDescriptor), + handle: pipe.fileHandleForReading, + closeOnDealloc: true + ) + fileHandleForWriting = FDFileHandle( + fileDescriptor: FileDescriptor(pipe.fileHandleForWriting.fileDescriptor), + handle: pipe.fileHandleForWriting, + closeOnDealloc: true + ) + #endif + } +} diff --git a/Sources/swsh/FDWrapperCommand.swift b/Sources/swsh/FDWrapperCommand.swift index 5daedbe..c03dee7 100644 --- a/Sources/swsh/FDWrapperCommand.swift +++ b/Sources/swsh/FDWrapperCommand.swift @@ -6,6 +6,9 @@ // import Foundation +#if os(Windows) +import WinSDK +#endif /// Wraps an inner command with file handle manipulation internal class FDWrapperCommand: Command { @@ -26,15 +29,31 @@ internal class FDWrapperCommand: Command { convenience init(inner: Command, opening path: String, toHandle dstFd: FileDescriptor, oflag: Int32) { self.init(inner: inner) { command in - let fd = open(path, oflag, 0o666) + #if os(Windows) + let pmode: Int32 = _S_IREAD | _S_IWRITE + #else + let pmode: mode_t = 0o666 + #endif + printOSCall("open", path, oflag, pmode) + let fd = open(path, oflag, pmode) guard fd >= 0 else { return .failure(SyscallError(name: "open(\"\(path)\", ...)", command: command, errno: errno)) } - let handle = FileHandle(fileDescriptor: fd, closeOnDealloc: true) - return .success(fdMap: [dstFd: FileDescriptor(fd)], ref: handle) + #if os(Windows) + if oflag & O_APPEND != 0 { + _lseek(fd, 0, SEEK_END) + } + #endif + let io = FDFileHandle(fileDescriptor: FileDescriptor(fd), closeOnDealloc: true) + return .success(fdMap: [dstFd: io.fileDescriptor], ref: io) + // #endif } } + convenience init(inner: Command, openingNullDeviceToHandle dstFd: FileDescriptor, oflag: Int32) { + self.init(inner: inner, opening: FileManager.nullDevicePath, toHandle: dstFd, oflag: oflag) + } + struct Result: CommandResult, AsyncCommandResult { let innerResult: CommandResult let command: Command @@ -138,17 +157,41 @@ extension Command { /// - Parameter fd: File descriptor to bind. Defaults to stdin public func input(_ data: Data, fd: FileDescriptor = .stdin) -> Command { FDWrapperCommand(inner: self) { _ in - let pipe = Pipe() + #if os(Windows) + let pipe = FDPipe() + let queue = DispatchQueue(label: "swsh.FDWrapperCommand.input.\(UUID().uuidString)") + queue.async { + do { + try pipe.fileHandleForWriting.handle.write(contentsOf: data) + } catch { + print("Failed to write to FD \(pipe.fileHandleForWriting.fileDescriptor.rawValue). Error: \(error)") + } + pipe.fileHandleForWriting.close() + } + return .success( + fdMap: [fd: pipe.fileHandleForReading.fileDescriptor], + ref: pipe.fileHandleForReading + ) + #else + let pipe = FDPipe() let dispatchData = data.withUnsafeBytes { DispatchData(bytes: $0) } - let writeHandle = pipe.fileHandleForWriting + + printOSCall("DispatchIO.write", pipe.fileHandleForWriting.fileDescriptor.rawValue, data, "DispatchQueue.global()") DispatchIO.write( - toFileDescriptor: writeHandle.fileDescriptor, + toFileDescriptor: pipe.fileHandleForWriting.fileDescriptor.rawValue, data: dispatchData, runningHandlerOn: DispatchQueue.global() - ) { [weak writeHandle] _, _ in - writeHandle?.closeFile() + ) { [pipe = pipe] _, error in + if error != 0 { + print("Failed to write to FD \(pipe.fileHandleForWriting.fileDescriptor.rawValue). Error: \(error)") + } + pipe.fileHandleForWriting.close() } - return .success(fdMap: [fd: pipe.fileHandleForReading.fd], ref: pipe) + return .success( + fdMap: [fd: pipe.fileHandleForReading.fileDescriptor], + ref: pipe.fileHandleForReading + ) + #endif } } diff --git a/Sources/swsh/FileDescriptor.swift b/Sources/swsh/FileDescriptor.swift index bfcf967..05741a9 100644 --- a/Sources/swsh/FileDescriptor.swift +++ b/Sources/swsh/FileDescriptor.swift @@ -27,6 +27,7 @@ extension FileDescriptor: Hashable, Equatable, CustomStringConvertible { extension FileHandle { /// The underlying FD number + @available(Windows, unavailable) public var fd: FileDescriptor { FileDescriptor(fileDescriptor) } diff --git a/Sources/swsh/LinuxSpawn.swift b/Sources/swsh/LinuxSpawn.swift index 5a88225..3556c67 100644 --- a/Sources/swsh/LinuxSpawn.swift +++ b/Sources/swsh/LinuxSpawn.swift @@ -7,11 +7,11 @@ import linuxSpawn /// A process spawned with something less nice than `posix_spawn` public struct LinuxSpawn: ProcessSpawner { public func spawn( - command: String, - arguments: [String], - env: [String: String], - fdMap: FDMap, - pathResolve: Bool + command: String, + arguments: [String], + env: [String: String], + fdMap: FDMap, + pathResolve: Bool ) -> SpawnResult { var cFdMap = [Int32]() for op in fdMap.createFdOperations() { @@ -47,20 +47,32 @@ public struct LinuxSpawn: ProcessSpawner { guard res == 0 else { return .error(errno: res) } - return .success(pid) + let process = ProcessInformation( + command: command, + arguments: arguments, + env: env, + id: pid + ) + return .success(process) } public func reapAsync( - pid: pid_t, - queue: DispatchQueue, - callback: @escaping (Int32) -> Void + process: ProcessInformation, + queue: DispatchQueue, + callback: @escaping (Int32) -> Void ) { Thread.detachNewThread { - let status = spawnWait(pid) + let status = spawnWait(process.id) queue.async { callback(status) } } } + + public func resume( + process: ProcessInformation + ) throws { + kill(process.id, SIGCONT) + } } #endif diff --git a/Sources/swsh/OSCompatibility.swift b/Sources/swsh/OSCompatibility.swift new file mode 100644 index 0000000..4617529 --- /dev/null +++ b/Sources/swsh/OSCompatibility.swift @@ -0,0 +1,10 @@ +#if os(Windows) + +// "_getpid" returns int, so I guess that's what pid_t should be?? +// https://learn.microsoft.com/en-us/cpp/c-runtime-library/reference/getpid +public typealias pid_t = Int +public let SIGKILL: Int32 = 9 +public let SIGTERM: Int32 = 15 +public let SIGSTOP: Int32 = 19 +public let SIGCONT: Int32 = 18 +#endif diff --git a/Sources/swsh/Pipeline.swift b/Sources/swsh/Pipeline.swift index 5a144d9..2bc579b 100644 --- a/Sources/swsh/Pipeline.swift +++ b/Sources/swsh/Pipeline.swift @@ -32,17 +32,21 @@ public class Pipeline: Command { } func kill(signal: Int32) throws { - var signalError: Error? + var signalErrors: [Error?] = [] for result in results { do { try result.kill(signal: signal) + signalErrors.append(nil) } catch let error { - signalError = signalError ?? error + signalErrors.append(error) } } - try signalError.map { throw $0 } + if !signalErrors.contains(where: { $0 == nil }) { + try signalErrors.first?.map { throw $0 } + } } + #if compiler(>=5.5) && canImport(_Concurrency) @available(macOS 10.15, *) func asyncFinish() async { @@ -54,9 +58,9 @@ public class Pipeline: Command { } public func coreAsync(fdMap baseFDMap: FDMap) -> CommandResult { - let pipes = rest.map { _ in Pipe() } - let inputs = [.stdin] + pipes.map { $0.fileHandleForReading.fd } - let outputs = pipes.map { $0.fileHandleForWriting.fd } + [.stdout] + let pipes = rest.map { _ in FDPipe() } + let inputs = [FileDescriptor.stdin] + pipes.map(\.fileHandleForReading.fileDescriptor) + let outputs = pipes.map(\.fileHandleForWriting.fileDescriptor) + [FileDescriptor.stdout] var results = [CommandResult]() for (command, (input, output)) in zip([first] + rest, zip(inputs, outputs)) { var fdMap = baseFDMap diff --git a/Sources/swsh/PosixSpawn.swift b/Sources/swsh/PosixSpawn.swift index 87dce44..aaabb2e 100644 --- a/Sources/swsh/PosixSpawn.swift +++ b/Sources/swsh/PosixSpawn.swift @@ -9,11 +9,11 @@ private let empty_spawnattrs: posix_spawnattr_t? = nil /// A process spawned with `posix_spawn` public struct PosixSpawn: ProcessSpawner { public func spawn( - command: String, - arguments: [String], - env: [String: String], - fdMap: FDMap, - pathResolve: Bool + command: String, + arguments: [String], + env: [String: String], + fdMap: FDMap, + pathResolve: Bool ) -> SpawnResult { var fileActions = empty_file_actions posix_spawn_file_actions_init(&fileActions) @@ -57,7 +57,13 @@ public struct PosixSpawn: ProcessSpawner { return .error(errno: res) } - return .success(pid) + let process = ProcessInformation( + command: command, + arguments: arguments, + env: env, + id: pid + ) + return .success(process) } // C macros are unfortunately not bridged to swift, borrowed from Foundation/Process @@ -67,14 +73,14 @@ public struct PosixSpawn: ProcessSpawner { private static func WIFSIGNALED(_ status: Int32) -> Bool { _WSTATUS(status) != _WSTOPPED && _WSTATUS(status) != 0 } public func reapAsync( - pid: pid_t, - queue: DispatchQueue, - callback: @escaping (Int32) -> Void + process: ProcessInformation, + queue: DispatchQueue, + callback: @escaping (Int32) -> Void ) { - let processSource = DispatchSource.makeProcessSource(identifier: pid, eventMask: .exit, queue: queue) + let processSource = DispatchSource.makeProcessSource(identifier: process.id, eventMask: .exit, queue: queue) processSource.setEventHandler { [processSource] in var status: Int32 = 0 - waitpid(pid, &status, 0) + waitpid(process.id, &status, 0) if Self.WIFEXITED(status) { callback(PosixSpawn.WEXITSTATUS(status)) processSource.cancel() @@ -85,6 +91,12 @@ public struct PosixSpawn: ProcessSpawner { } processSource.activate() } + + public func resume( + process: ProcessInformation + ) throws { + kill(process.id, SIGCONT) + } } #endif diff --git a/Sources/swsh/ProcessSpawner.swift b/Sources/swsh/ProcessSpawner.swift index edfe58f..5236a65 100644 --- a/Sources/swsh/ProcessSpawner.swift +++ b/Sources/swsh/ProcessSpawner.swift @@ -6,10 +6,40 @@ import Darwin.C import Glibc #endif +/// Data used to identify a process +public struct ProcessInformation: CustomDebugStringConvertible { + let command: String + let arguments: [String] + let env: [String: String] + let id: pid_t + let handle: UnsafeMutableRawPointer? + let mainThreadHandle: UnsafeMutableRawPointer? + + public init( + command: String, + arguments: [String], + env: [String: String], + id: pid_t, + handle: UnsafeMutableRawPointer? = nil, + mainThreadHandle: UnsafeMutableRawPointer? = nil) + { + self.command = command + self.arguments = arguments + self.env = env + self.id = id + self.handle = handle + self.mainThreadHandle = mainThreadHandle + } + + public var debugDescription: String { + return "\(id) \(command) \(arguments.joined(separator: " "))" + } +} + /// The result of a spawn public enum SpawnResult { - /// A successful spawn with child process pid - case success(pid_t) + /// A successful spawn with child process + case success(ProcessInformation) /// A failed spawn with error `errno` case error(errno: Int32) } @@ -25,11 +55,11 @@ public protocol ProcessSpawner { /// - Parameter pathResolve: if true, search for executable in PATH /// - Returns: pid of spawned process or error if failed func spawn( - command: String, - arguments: [String], - env: [String: String], - fdMap: FDMap, - pathResolve: Bool + command: String, + arguments: [String], + env: [String: String], + fdMap: FDMap, + pathResolve: Bool ) -> SpawnResult /// Add a callback for child process exiting @@ -37,8 +67,12 @@ public protocol ProcessSpawner { /// - Parameter callback: called with exit code when child exits /// - Parameter queue: queue the callback is executed on func reapAsync( - pid: pid_t, - queue: DispatchQueue, - callback: @escaping (Int32) -> Void + process: ProcessInformation, + queue: DispatchQueue, + callback: @escaping (Int32) -> Void ) + + func resume( + process: ProcessInformation + ) throws } diff --git a/Sources/swsh/Utilities.swift b/Sources/swsh/Utilities.swift index 13188cd..94ece19 100644 --- a/Sources/swsh/Utilities.swift +++ b/Sources/swsh/Utilities.swift @@ -20,4 +20,20 @@ extension FileManager { defer { _ = changeCurrentDirectoryPath(oldPath) } return try body() } + + /// Path to a file representing the `nullDevice` for use when a `FileHandle` is insufficient + public static var nullDevicePath: String { + #if os(Windows) + return "NUL" + #else + return "/dev/null" + #endif + } +} + +public var printOSCalls = false +func printOSCall(_ name: String, _ args: Any?...) { + if printOSCalls { + print("OS Call: \(name)(\(args.map { $0.map { String(describing: $0) } ?? "null" }.joined(separator: ", ")))") + } } diff --git a/Sources/swsh/WindowsSpawn.swift b/Sources/swsh/WindowsSpawn.swift new file mode 100644 index 0000000..67b1d48 --- /dev/null +++ b/Sources/swsh/WindowsSpawn.swift @@ -0,0 +1,446 @@ +#if canImport(ucrt) + +import ucrt +import Foundation +import windowsSpawn +import WinSDK + +/// A process spawned with `CreateProcessW` +struct WindowsSpawn: ProcessSpawner { + public enum Error: Swift.Error { + case systemError(String, DWORD) + } + + public func spawn( + command: String, + arguments: [String], + env: [String: String], + fdMap: FDMap, + pathResolve: Bool + ) -> SpawnResult { + let intFDMap = Dictionary(uniqueKeysWithValues: fdMap.map { ($0.key.rawValue, $0.value.rawValue) }) + do { + // print("Spawning: \(command) \(arguments.joined(separator: " "))") + switch WindowsSpawnImpl.spawn( + command: command, + arguments: arguments, + env: env, + fdMap: intFDMap, + pathResolve: pathResolve + ) { + case .success(let info): + let process = ProcessInformation( + command: command, + arguments: arguments, + env: env, + id: Int(info.dwProcessId), + handle: info.hProcess, + mainThreadHandle: info.hThread + ) + // print("Spawned: \(command) \(arguments.joined(separator: " "))") + return .success(process) + case .failure(let error): + // TODO: Can pass error context string along somehow? + print(error) + return .error(errno: error.errno) + } + } + } + + public func reapAsync( + process: ProcessInformation, + queue: DispatchQueue, + callback: @escaping (Int32) -> Void + ) { + // Setup notifications for when the child process exits + var waitHandle: HANDLE? = INVALID_HANDLE_VALUE + let context = exit_wait_context(process: process, queue: queue, callback: callback) + let contextUnmanaged = Unmanaged.passRetained(context) + let flags = DWORD(WT_EXECUTEINWAITTHREAD) | DWORD(WT_EXECUTEONLYONCE) + printOSCall("RegisterWaitForSingleObject", "ptr(waitHandle)", process.handle, exit_wait_callback, "ptr(\(context))", "INFINITE", flags) + let succeeded = RegisterWaitForSingleObject( + /* phNewWaitObject */ &waitHandle, + /* hObject */ process.handle, + /* Callback */ exit_wait_callback, + /* Context */ contextUnmanaged.toOpaque(), + /* dwMilliseconds */ INFINITE, + /* dwFlags */ flags + ) + guard succeeded else { + fatalError("RegisterWaitForSingleObject error: \(WindowsSpawnImpl.Error(systemError: GetLastError()))"); + } + } + + public func resume( + process: ProcessInformation + ) throws { + printOSCall("ResumeThread", process.mainThreadHandle) + guard ResumeThread(process.mainThreadHandle) != DWORD(bitPattern: -1) else { + let err = GetLastError() + TerminateProcess(process.handle, 1) + throw Error.systemError("Resuming process failed: ", err) + } + } +} + +class exit_wait_context { + let process: ProcessInformation + let queue: DispatchQueue + let callback: (Int32) -> Void + + public init(process: ProcessInformation, queue: DispatchQueue, callback: @escaping (Int32) -> Void) { + self.process = process + self.queue = queue + self.callback = callback + } +} + +@_cdecl("exit_wait_callback") +func exit_wait_callback(data: UnsafeMutableRawPointer!, didTimeout: UInt8) { + let context: exit_wait_context = Unmanaged.fromOpaque(data!).takeUnretainedValue() + let process = context.process + let queue = context.queue + let callback = context.callback + + queue.sync { + var exitCode: DWORD = 0 + printOSCall("GetExitCodeProcess", process.handle, "ptr(\(exitCode))") + guard GetExitCodeProcess(process.handle, &exitCode) != false else { + callback(Int32(bitPattern: DWORD(GetLastError()))) // TODO: What should this be if the exit code cannot be determined? + return + } + + printOSCall("CloseHandle", process.mainThreadHandle) + CloseHandle(process.mainThreadHandle) + printOSCall("CloseHandle", process.handle) + CloseHandle(process.handle) + // print("Reaped(\(exitCode)): \(process)") + + callback(Int32(bitPattern: exitCode)) + Unmanaged.fromOpaque(data!).release() + } +} + +// Adapted from libuv: +// https://github.com/libuv/libuv/blob/00357f87328def30a32af82c841e5d1667a2a827/src/win/process.c +public enum WindowsSpawnImpl { + public enum Error: Swift.Error { + case allocationError + case tooManyHandles + case envPathUnset + case systemError(DWORD, String) + + init(_ context: String = "", systemError: DWORD) { + var messageBuffer: LPWSTR? + // This call is terrible because the type of the pointer depends on the first argument to the call + let size = withUnsafeMutableBytes(of: &messageBuffer) { bufferPtr in + FormatMessageW( + DWORD(FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM | FORMAT_MESSAGE_IGNORE_INSERTS), + nil, + systemError, + .init(bitPattern: 0), + bufferPtr.baseAddress!.assumingMemoryBound(to: WCHAR.self), + 0, + nil + ) + } + guard size > 0, let messageBuffer = messageBuffer else { + self = .systemError(systemError, "\(context)Unknown error \(systemError)") + return + } + defer { LocalFree(messageBuffer) } + self = .systemError(systemError, context + String(utf16CodeUnits: messageBuffer, count: Int(size))) + } + + public var errno: Int32 { + switch self { + case .systemError(let errno, _): return Int32(errno) + default: return -1 + } + } + } + + // Adapted from libuv: + // https://github.com/libuv/libuv/blob/00357f87328def30a32af82c841e5d1667a2a827/src/win/process-stdio.c + /* + * The `child_stdio_buffer` buffer has the following layout: + * int number_of_fds + * unsigned char crt_flags[number_of_fds] + * // [cobbal] no alignment? + * HANDLE os_handle[number_of_fds] + */ + class ChildHandleBuffer { + private let shouldDuplicate = true + private let intSize = MemoryLayout.size + private let byteSize = MemoryLayout.size + private let ptrSize = MemoryLayout.size + + let handles: [HANDLE?] + let buffer: UnsafeMutableRawBufferPointer + let count: Int + + static func create(_ fdMap: [Int32: Int32]) -> Result { + let parentFDMax = fdMap.keys.max() ?? -1 + var childHandleArray = Array(repeating: nil, count: Int(parentFDMax + 1)) + for (parentFD, childFD) in fdMap { + childHandleArray[Int(parentFD)] = Self.osHandle(for: childFD) + } + + guard let buffer = ChildHandleBuffer(childHandleArray) else { return .failure(.tooManyHandles) } + return .success(buffer) + } + + private init?(_ handles: [HANDLE?]) { + let handles = !shouldDuplicate ? handles : handles.map { Self.duplicate($0) } + let count = handles.count + + // Pack the handles into a buffer suitable for passing to CreateProcessW + let byteLength = intSize + byteSize * count + ptrSize * count + guard byteLength < UInt16.max else { return nil } + buffer = UnsafeMutableRawBufferPointer.allocate(byteCount: byteLength, alignment: 16) + buffer.storeBytes(of: Int32(count), toByteOffset: 0, as: Int32.self) + for i in 0.. HANDLE? { + guard index < count else { return INVALID_HANDLE_VALUE } + return buffer.loadUnaligned(fromByteOffset: intSize + byteSize * count + ptrSize * index, as: HANDLE.self) + } + + deinit { + if shouldDuplicate { + for handle in handles { + if let handle = handle { + printOSCall("CloseHandle", handle) + CloseHandle(handle) + // print("Closed \(handle)") + } + } + } + buffer.deallocate() + } + + // Adapted from libuv: + // https://github.com/libuv/libuv/blob/1479b76310a38d98eda94db2b7f8a40e04b3ff32/src/win/handle-inl.h#L166 + private static func osHandle(for fileDescriptor: Int32) -> HANDLE? { + // TODO: Can disable assert-in-debug-builds-only nonsense for _get_osfhandle()? + printOSCall("_get_osfhandle", fileDescriptor) + return HANDLE(bitPattern: _get_osfhandle(fileDescriptor)) + } + + // Adapted from libuv: + // https://github.com/libuv/libuv/blob/1479b76310a38d98eda94db2b7f8a40e04b3ff32/src/win/process-stdio.c#L273 + private static func flags(for handle: HANDLE?) -> UInt8 { + let FOPEN: UInt8 = 0x01 + // let FEOFLAG: UInt8 = 0x02 + // let FCRLF: UInt8 = 0x04 + let FPIPE: UInt8 = 0x08 + // let FNOINHERIT: UInt8 = 0x10 + // let FAPPEND: UInt8 = 0x20 + let FDEV: UInt8 = 0x40 + // let FTEXT: UInt8 = 0x80 + + printOSCall("GetFileType", handle) + switch Int32(GetFileType(handle)) { + case FILE_TYPE_DISK: return FOPEN + case FILE_TYPE_PIPE: return FOPEN | FPIPE + case FILE_TYPE_CHAR: return FOPEN | FDEV + case FILE_TYPE_REMOTE: return FOPEN | FDEV + case FILE_TYPE_UNKNOWN: return FOPEN | FDEV // TODO: What if GetFileType returns an error? + default: preconditionFailure("Windows lied about the file type. Should not happen.") + } + } + + // Adaped from libuv: + // https://github.com/libuv/libuv/blob/00357f87328def30a32af82c841e5d1667a2a827/src/win/process-stdio.c#L96 + private static func duplicate(_ handle: HANDLE?) -> HANDLE? { + guard handle != INVALID_HANDLE_VALUE, handle != nil, handle != HANDLE(bitPattern: -2) else { + return nil + } + + printOSCall("GetCurrentProcess") + let currentProcess = GetCurrentProcess() + var duplicated: HANDLE? + printOSCall("DuplicateHandle", currentProcess, handle, currentProcess, "ptr(duplicated)", 0, true, "DUPLICATE_SAME_ACCESS") + guard DuplicateHandle( + /* hSourceProcessHandle: */ currentProcess, + /* hSourceHandle: */ handle, + /* hTargetProcessHandle: */ currentProcess, + /* lpTargetHandle: */ &duplicated, + /* dwDesiredAccess: */ 0, + /* bInheritHandle: */ true, + /* dwOptions: */ DWORD(DUPLICATE_SAME_ACCESS) + ) else { + return nil + } + // print("Duplicated \(String(describing: handle)) to \(String(describing: duplicated))") + + return duplicated + } + } + + // Adapted from libuv: + // https://github.com/libuv/libuv/blob/00357f87328def30a32af82c841e5d1667a2a827/src/win/process.c#L934 + public static func spawn( + command: String, + arguments: [String], + env: [String: String], + fdMap: [Int32: Int32], + pathResolve: Bool + ) -> Result { + // Find the path environment variable + guard let envPathKey = env.keys.first(where: { $0.uppercased == "PATH" }) else { + return .failure(Error.envPathUnset) + } + let path: UnsafeMutablePointer? = env[envPathKey]!.withCString(encodedAs: UTF16.self, _wcsdup) + defer { free(path) } + // print("path: \(path.map { String(utf16CodeUnits: $0, count: wcslen($0)) } ?? "")") + + // Find the current working directory + printOSCall("GetCurrentDirectoryW", 0, nil) + let cwdLength = GetCurrentDirectoryW(0, nil) + guard cwdLength > 0 else { + return .failure(Error("Could not find current working directory", systemError: DWORD(GetLastError()))) + } + let cwd: UnsafeMutablePointer? = calloc(MemoryLayout.size, Int(cwdLength)).assumingMemoryBound(to: wchar_t.self) + defer { free(cwd) } + printOSCall("GetCurrentDirectoryW", cwdLength, cwd) + let r = GetCurrentDirectoryW(cwdLength, cwd) + guard r != 0 && r < cwdLength else { + return .failure(Error("Could not load current working directory", systemError: DWORD(GetLastError()))) + } + // print("cwd: \(cwd.map { String(utf16CodeUnits: $0, count: wcslen($0)) } ?? "")") + + // Search the path and working directory for the application that is to be used to execute the command, in a form sutable to use in CreateProcessW() + printOSCall("windowsSpawn.search_path", command, cwd, path) + let applicationPath = command.withCString(encodedAs: UTF16.self) { windowsSpawn.search_path($0, cwd, path) } + defer { free(applicationPath) } + guard applicationPath != nil else { + return .failure(Error("Could not find application for command \"\(command)\". ", systemError: DWORD(ERROR_FILE_NOT_FOUND))) + } + // print("applicationPath: \(applicationPath.map { String(utf16CodeUnits: $0, count: wcslen($0)) } ?? "")") + + // Convert the command (not the application path!) and arguments to a null-terminated list of null-terminated UTF8 strings, + // then process them into properly quoted wide strings suitable for use in CreateProcessW() + var args = ([command] + arguments).map { _strdup($0) } + [nil] + defer { args.forEach { free($0) } } + var commandLine: UnsafeMutablePointer? + printOSCall("windowsSpawn.make_program_args", "ptr(\(args))", 0, "ptr(commandLine)") + let makeArgsStatus = windowsSpawn.make_program_args(&args, 0, &commandLine) + defer { free(commandLine) } + guard makeArgsStatus == 0 else { + return .failure(Error("Unable to convert command arguments. Error: ", systemError: DWORD(makeArgsStatus))) + } + // print("commandLine: \(commandLine.map { String(utf16CodeUnits: $0, count: wcslen($0)) } ?? "")") + + // Convert the environment variable keys and values to a null-terminated list of null-terminated UTF8 strings, + // then process them into properly quoted wide strings suitable for use in CreateProcessW() + let envVarStrings = env.map { "\($0)=\($1)" } + var vars = envVarStrings.map { _strdup($0) } + [nil] + defer { vars.forEach { free($0) } } + var environment: UnsafeMutablePointer? + printOSCall("windowsSpawn.make_program_env", "ptr(\(vars))", "ptr(environment)") + let makeEnvStatus = windowsSpawn.make_program_env(&vars, &environment) + defer { free(environment) } + guard makeEnvStatus == 0 else { + return .failure(Error("Unable to convert environment. Error: ", systemError: DWORD(makeArgsStatus))) + } + // print("environment: \(environment.map { String(utf16CodeUnits: $0, count: wcslen($0)) } ?? "")") + + // Package the file descriptors map as a list of handles, + // then process them into a form suitable for use in the startup information object passed to CreateProcessW() + // print("fdMap: \(fdMap)") + let childHandleStructure: ChildHandleBuffer + switch ChildHandleBuffer.create(fdMap) { + case .success(let buffer): childHandleStructure = buffer + case .failure(let error): return .failure(error) + } + // print("childHandleStructure: \((0...size) + startup.lpReserved = nil + startup.lpDesktop = nil + startup.lpTitle = nil + startup.dwFlags = STARTF_USESTDHANDLES + startup.cbReserved2 = UInt16(childHandleStructure.buffer.count) + startup.lpReserved2 = .init(bitPattern: UInt(bitPattern: childHandleStructure.buffer.baseAddress)) + startup.hStdInput = childHandleStructure[0] + startup.hStdOutput = childHandleStructure[1] + startup.hStdError = childHandleStructure[2] + let startupNonExBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: MemoryLayout.size, alignment: 16) + defer { startupNonExBuffer.deallocate() } + startupNonExBuffer.storeBytes(of: startup, toByteOffset: 0, as: STARTUPINFOW.self) + var startupPtr: LPSTARTUPINFOW = startupNonExBuffer.baseAddress!.bindMemory(to: STARTUPINFOW.self, capacity: 1) + var creationFlags = DWORD(CREATE_UNICODE_ENVIRONMENT | CREATE_DEFAULT_ERROR_MODE | CREATE_SUSPENDED) + + // Restrict the handles inherited by the child process to only those specified here + #if true /* restrictInheritance */ + let nonNilHandles = childHandleStructure.handles.compactMap { $0 } + let inheritedHandles = UnsafeMutableRawBufferPointer.allocate(byteCount: nonNilHandles.count * MemoryLayout.size, alignment: 16) + defer { inheritedHandles.deallocate() } + nonNilHandles.enumerated().forEach { inheritedHandles.storeBytes(of: $0.1, toByteOffset: $0.0 * MemoryLayout.size, as: HANDLE?.self) } + // print("inheritedHandles: \(inheritedHandles.count) bytes: \(inheritedHandles.map { $0 })") + var attributeListSize: SIZE_T = 0 + printOSCall("InitializeProcThreadAttributeList", nil, 1, 0, "ptr(attributeListSize)") + InitializeProcThreadAttributeList(nil, 1, 0, &attributeListSize) + var startupEx = STARTUPINFOEXW() + startupEx.StartupInfo = startup + startupEx.StartupInfo.cb = DWORD(MemoryLayout.size) + startupEx.lpAttributeList = .init(HeapAlloc(GetProcessHeap(), 0, attributeListSize)) + defer { HeapFree(GetProcessHeap(), 0, .init(startupEx.lpAttributeList)) } + printOSCall("InitializeProcThreadAttributeList", startupEx.lpAttributeList, 1, 0, "ptr(\(attributeListSize))") + guard InitializeProcThreadAttributeList(startupEx.lpAttributeList, 1, 0, &attributeListSize) else { + return .failure(Error("InitializeProcThreadAttributeList failed: ", systemError: DWORD(GetLastError()))) + } + let PROC_THREAD_ATTRIBUTE_HANDLE_LIST = DWORD_PTR(ProcThreadAttributeHandleList.rawValue | PROC_THREAD_ATTRIBUTE_INPUT) + printOSCall("UpdateProcThreadAttribute", startupEx.lpAttributeList, 0, "PROC_THREAD_ATTRIBUTE_HANDLE_LIST", inheritedHandles.baseAddress, inheritedHandles.count, nil, nil) + guard UpdateProcThreadAttribute( + /* lpAttributeList */ startupEx.lpAttributeList, + /* dwFlags */ 0, + /* Attribute */ PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + /* lpValue */ inheritedHandles.baseAddress, + /* cbSize */ SIZE_T(inheritedHandles.count), + /* lpPreviousValue */ nil, + /* lpReturnSize */ nil + ) else { + return .failure(Error("UpdateProcThreadAttribute failed: ", systemError: DWORD(GetLastError()))) + } + defer { DeleteProcThreadAttributeList(startupEx.lpAttributeList) } + let startupExBuffer = UnsafeMutableRawBufferPointer.allocate(byteCount: MemoryLayout.size, alignment: 16) + defer { startupExBuffer.deallocate() } + startupExBuffer.storeBytes(of: startupEx, toByteOffset: 0, as: STARTUPINFOEXW.self) + startupPtr = startupExBuffer.baseAddress!.bindMemory(to: STARTUPINFOW.self, capacity: 1) + creationFlags |= UInt32(EXTENDED_STARTUPINFO_PRESENT) + #endif + + // Spawn a child process to execute the desired command, requesting that it be in a suspended state to be resumed later + var info = PROCESS_INFORMATION() + printOSCall("CreateProcessW", applicationPath, commandLine, nil, nil, true, creationFlags, environment, cwd, "ptr(\(startup))", "ptr(info)") + guard CreateProcessW( + /* lpApplicationName: */ applicationPath, + /* lpCommandLine: */ commandLine, + /* lpProcessAttributes: */ nil, + /* lpThreadAttributes: */ nil, + /* bInheritHandles: */ true, + /* dwCreationFlags: */ creationFlags, + /* lpEnvironment: */ environment, + /* lpCurrentDirectory: */ cwd, + /* lpStartupInfo: */ startupPtr, + /* lpProcessInformation: */ &info + ) else { + return .failure(Error("CreateProcessW failed: ", systemError: DWORD(GetLastError()))) + } + return .success(info) + } +} + +#endif diff --git a/Sources/windowsSpawn/include/path.h b/Sources/windowsSpawn/include/path.h new file mode 100644 index 0000000..d36362e --- /dev/null +++ b/Sources/windowsSpawn/include/path.h @@ -0,0 +1,12 @@ +#ifdef _WIN32 + +/* Path search and command quoting functions from libuv */ +/* https://github.com/libuv/libuv/blob/00357f87328def30a32af82c841e5d1667a2a827/src/win/process.c#L151 */ + +#include + +wchar_t* search_path(const wchar_t *file, wchar_t *cwd, const wchar_t *path); +int make_program_args(char** args, int verbatim_arguments, wchar_t** dst_ptr); +int make_program_env(char* env_block[], wchar_t** dst_ptr); + +#endif diff --git a/Sources/windowsSpawn/path.c b/Sources/windowsSpawn/path.c new file mode 100644 index 0000000..e11375b --- /dev/null +++ b/Sources/windowsSpawn/path.c @@ -0,0 +1,781 @@ +#ifdef _WIN32 + +/* Path search and command quoting functions from libuv */ +/* https://github.com/libuv/libuv/blob/00357f87328def30a32af82c841e5d1667a2a827/src/win/process.c#L151 */ + +#include "include/path.h" +#include +#include +#include + +/* Macros to map libuv functions to stdlib counterparts */ +#define uv__malloc(size) malloc(size) +#define uv__free(ptr) free(ptr) +#define uv_fatal_error(error, call) _exit(error) +#define alloca(size) _alloca(size) +#ifndef ssize_t +#define ssize_t intptr_t +#endif +#define UV_ENOMEM -1 + +/* Table of required environment variables */ +typedef struct env_var { + const WCHAR* const wide; + const WCHAR* const wide_eq; + const size_t len; /* including null or '=' */ +} env_var_t; +#define E_V(str) { L##str, L##str L"=", sizeof(str) } +static const env_var_t required_vars[] = { /* keep me sorted */ + E_V("HOMEDRIVE"), + E_V("HOMEPATH"), + E_V("LOGONSERVER"), + E_V("PATH"), + E_V("SYSTEMDRIVE"), + E_V("SYSTEMROOT"), + E_V("TEMP"), + E_V("USERDOMAIN"), + E_V("USERNAME"), + E_V("USERPROFILE"), + E_V("WINDIR"), +}; +#define ARRAY_SIZE(a) (sizeof(a) / sizeof((a)[0])) +#define ARRAY_END(a) ((a) + ARRAY_SIZE(a)) + +/* + * String helper functions + */ + + static int32_t uv__wtf8_decode1(const char** input) { + uint32_t code_point; + uint8_t b1; + uint8_t b2; + uint8_t b3; + uint8_t b4; + + b1 = **input; + if (b1 <= 0x7F) + return b1; /* ASCII code point */ + if (b1 < 0xC2) + return -1; /* invalid: continuation byte */ + code_point = b1; + + b2 = *++*input; + if ((b2 & 0xC0) != 0x80) + return -1; /* invalid: not a continuation byte */ + code_point = (code_point << 6) | (b2 & 0x3F); + if (b1 <= 0xDF) + return 0x7FF & code_point; /* two-byte character */ + + b3 = *++*input; + if ((b3 & 0xC0) != 0x80) + return -1; /* invalid: not a continuation byte */ + code_point = (code_point << 6) | (b3 & 0x3F); + if (b1 <= 0xEF) + return 0xFFFF & code_point; /* three-byte character */ + + b4 = *++*input; + if ((b4 & 0xC0) != 0x80) + return -1; /* invalid: not a continuation byte */ + code_point = (code_point << 6) | (b4 & 0x3F); + if (b1 <= 0xF4) { + code_point &= 0x1FFFFF; + if (code_point <= 0x10FFFF) + return code_point; /* four-byte character */ + } + + /* code point too large */ + return -1; +} + +ssize_t uv_wtf8_length_as_utf16(const char* source_ptr) { + size_t w_target_len = 0; + int32_t code_point; + + do { + code_point = uv__wtf8_decode1(&source_ptr); + if (code_point < 0) + return -1; + if (code_point > 0xFFFF) + w_target_len++; + w_target_len++; + } while (*source_ptr++); + + return w_target_len; +} + +void uv_wtf8_to_utf16(const char* source_ptr, + uint16_t* w_target, + size_t w_target_len) { + int32_t code_point; + + do { + code_point = uv__wtf8_decode1(&source_ptr); + /* uv_wtf8_length_as_utf16 should have been called and checked first. */ + assert(code_point >= 0); + if (code_point > 0x10000) { + assert(code_point < 0x10FFFF); + *w_target++ = (((code_point - 0x10000) >> 10) + 0xD800); + *w_target++ = ((code_point - 0x10000) & 0x3FF) + 0xDC00; + w_target_len -= 2; + } else { + *w_target++ = code_point; + w_target_len -= 1; + } + } while (*source_ptr++); + + (void)w_target_len; + assert(w_target_len == 0); +} + +/* + * Path search functions + */ + +/* + * Helper function for search_path + */ +static WCHAR* search_path_join_test(const WCHAR* dir, + size_t dir_len, + const WCHAR* name, + size_t name_len, + const WCHAR* ext, + size_t ext_len, + const WCHAR* cwd, + size_t cwd_len) { + WCHAR *result, *result_pos; + DWORD attrs; + if (dir_len > 2 && + ((dir[0] == L'\\' || dir[0] == L'/') && + (dir[1] == L'\\' || dir[1] == L'/'))) { + /* It's a UNC path so ignore cwd */ + cwd_len = 0; + } else if (dir_len >= 1 && (dir[0] == L'/' || dir[0] == L'\\')) { + /* It's a full path without drive letter, use cwd's drive letter only */ + cwd_len = 2; + } else if (dir_len >= 2 && dir[1] == L':' && + (dir_len < 3 || (dir[2] != L'/' && dir[2] != L'\\'))) { + /* It's a relative path with drive letter (ext.g. D:../some/file) + * Replace drive letter in dir by full cwd if it points to the same drive, + * otherwise use the dir only. + */ + if (cwd_len < 2 || _wcsnicmp(cwd, dir, 2) != 0) { + cwd_len = 0; + } else { + dir += 2; + dir_len -= 2; + } + } else if (dir_len > 2 && dir[1] == L':') { + /* It's an absolute path with drive letter + * Don't use the cwd at all + */ + cwd_len = 0; + } + + /* Allocate buffer for output */ + result = result_pos = (WCHAR*)uv__malloc(sizeof(WCHAR) * + (cwd_len + 1 + dir_len + 1 + name_len + 1 + ext_len + 1)); + + /* Copy cwd */ + wcsncpy(result_pos, cwd, cwd_len); + result_pos += cwd_len; + + /* Add a path separator if cwd didn't end with one */ + if (cwd_len && wcsrchr(L"\\/:", result_pos[-1]) == NULL) { + result_pos[0] = L'\\'; + result_pos++; + } + + /* Copy dir */ + wcsncpy(result_pos, dir, dir_len); + result_pos += dir_len; + + /* Add a separator if the dir didn't end with one */ + if (dir_len && wcsrchr(L"\\/:", result_pos[-1]) == NULL) { + result_pos[0] = L'\\'; + result_pos++; + } + + /* Copy filename */ + wcsncpy(result_pos, name, name_len); + result_pos += name_len; + + if (ext_len) { + /* Add a dot if the filename didn't end with one */ + if (name_len && result_pos[-1] != '.') { + result_pos[0] = L'.'; + result_pos++; + } + + /* Copy extension */ + wcsncpy(result_pos, ext, ext_len); + result_pos += ext_len; + } + + /* Null terminator */ + result_pos[0] = L'\0'; + + attrs = GetFileAttributesW(result); + + if (attrs != INVALID_FILE_ATTRIBUTES && + !(attrs & FILE_ATTRIBUTE_DIRECTORY)) { + return result; + } + + uv__free(result); + return NULL; +} + + +/* + * Helper function for search_path + */ +static WCHAR* path_search_walk_ext(const WCHAR *dir, + size_t dir_len, + const WCHAR *name, + size_t name_len, + WCHAR *cwd, + size_t cwd_len, + int name_has_ext) { + WCHAR* result; + + /* If the name itself has a nonempty extension, try this extension first */ + if (name_has_ext) { + result = search_path_join_test(dir, dir_len, + name, name_len, + L"", 0, + cwd, cwd_len); + if (result != NULL) { + return result; + } + } + + /* Try .com extension */ + result = search_path_join_test(dir, dir_len, + name, name_len, + L"com", 3, + cwd, cwd_len); + if (result != NULL) { + return result; + } + + /* Try .exe extension */ + result = search_path_join_test(dir, dir_len, + name, name_len, + L"exe", 3, + cwd, cwd_len); + if (result != NULL) { + return result; + } + + return NULL; +} + + +/* + * search_path searches the system path for an executable filename - + * the windows API doesn't provide this as a standalone function nor as an + * option to CreateProcess. + * + * It tries to return an absolute filename. + * + * Furthermore, it tries to follow the semantics that cmd.exe, with this + * exception that PATHEXT environment variable isn't used. Since CreateProcess + * can start only .com and .exe files, only those extensions are tried. This + * behavior equals that of msvcrt's spawn functions. + * + * - Do not search the path if the filename already contains a path (either + * relative or absolute). + * + * - If there's really only a filename, check the current directory for file, + * then search all path directories. + * + * - If filename specified has *any* extension, search for the file with the + * specified extension first. + * + * - If the literal filename is not found in a directory, try *appending* + * (not replacing) .com first and then .exe. + * + * - The path variable may contain relative paths; relative paths are relative + * to the cwd. + * + * - Directories in path may or may not end with a trailing backslash. + * + * - CMD does not trim leading/trailing whitespace from path/pathex entries + * nor from the environment variables as a whole. + * + * - When cmd.exe cannot read a directory, it will just skip it and go on + * searching. However, unlike posix-y systems, it will happily try to run a + * file that is not readable/executable; if the spawn fails it will not + * continue searching. + * + * UNC path support: we are dealing with UNC paths in both the path and the + * filename. This is a deviation from what cmd.exe does (it does not let you + * start a program by specifying an UNC path on the command line) but this is + * really a pointless restriction. + * + */ +static WCHAR* search_path(const WCHAR *file, + WCHAR *cwd, + const WCHAR *path) { + int file_has_dir; + WCHAR* result = NULL; + WCHAR *file_name_start; + WCHAR *dot; + const WCHAR *dir_start, *dir_end, *dir_path; + size_t dir_len; + int name_has_ext; + + size_t file_len = wcslen(file); + size_t cwd_len = wcslen(cwd); + + /* If the caller supplies an empty filename, + * we're not gonna return c:\windows\.exe -- GFY! + */ + if (file_len == 0 + || (file_len == 1 && file[0] == L'.')) { + return NULL; + } + + /* Find the start of the filename so we can split the directory from the + * name. */ + for (file_name_start = (WCHAR*)file + file_len; + file_name_start > file + && file_name_start[-1] != L'\\' + && file_name_start[-1] != L'/' + && file_name_start[-1] != L':'; + file_name_start--); + + file_has_dir = file_name_start != file; + + /* Check if the filename includes an extension */ + dot = wcschr(file_name_start, L'.'); + name_has_ext = (dot != NULL && dot[1] != L'\0'); + + if (file_has_dir) { + /* The file has a path inside, don't use path */ + result = path_search_walk_ext( + file, file_name_start - file, + file_name_start, file_len - (file_name_start - file), + cwd, cwd_len, + name_has_ext); + + } else { + dir_end = path; + + if (NeedCurrentDirectoryForExePathW(L"")) { + /* The file is really only a name; look in cwd first, then scan path */ + result = path_search_walk_ext(L"", 0, + file, file_len, + cwd, cwd_len, + name_has_ext); + } + + while (result == NULL) { + if (dir_end == NULL || *dir_end == L'\0') { + break; + } + + /* Skip the separator that dir_end now points to */ + if (dir_end != path || *path == L';') { + dir_end++; + } + + /* Next slice starts just after where the previous one ended */ + dir_start = dir_end; + + /* If path is quoted, find quote end */ + if (*dir_start == L'"' || *dir_start == L'\'') { + dir_end = wcschr(dir_start + 1, *dir_start); + if (dir_end == NULL) { + dir_end = wcschr(dir_start, L'\0'); + } + } + /* Slice until the next ; or \0 is found */ + dir_end = wcschr(dir_end, L';'); + if (dir_end == NULL) { + dir_end = wcschr(dir_start, L'\0'); + } + + /* If the slice is zero-length, don't bother */ + if (dir_end - dir_start == 0) { + continue; + } + + dir_path = dir_start; + dir_len = dir_end - dir_start; + + /* Adjust if the path is quoted. */ + if (dir_path[0] == '"' || dir_path[0] == '\'') { + ++dir_path; + --dir_len; + } + + if (dir_path[dir_len - 1] == '"' || dir_path[dir_len - 1] == '\'') { + --dir_len; + } + + result = path_search_walk_ext(dir_path, dir_len, + file, file_len, + cwd, cwd_len, + name_has_ext); + } + } + + return result; +} + + +/* + * Quotes command line arguments + * Returns a pointer to the end (next char to be written) of the buffer + */ +WCHAR* quote_cmd_arg(const WCHAR *source, WCHAR *target) { + size_t len = wcslen(source); + size_t i; + int quote_hit; + WCHAR* start; + + if (len == 0) { + /* Need double quotation for empty argument */ + *(target++) = L'"'; + *(target++) = L'"'; + return target; + } + + if (NULL == wcspbrk(source, L" \t\"")) { + /* No quotation needed */ + wcsncpy(target, source, len); + target += len; + return target; + } + + if (NULL == wcspbrk(source, L"\"\\")) { + /* + * No embedded double quotes or backlashes, so I can just wrap + * quote marks around the whole thing. + */ + *(target++) = L'"'; + wcsncpy(target, source, len); + target += len; + *(target++) = L'"'; + return target; + } + + /* + * Expected input/output: + * input : hello"world + * output: "hello\"world" + * input : hello""world + * output: "hello\"\"world" + * input : hello\world + * output: hello\world + * input : hello\\world + * output: hello\\world + * input : hello\"world + * output: "hello\\\"world" + * input : hello\\"world + * output: "hello\\\\\"world" + * input : hello world\ + * output: "hello world\\" + */ + + *(target++) = L'"'; + start = target; + quote_hit = 1; + + for (i = len; i > 0; --i) { + *(target++) = source[i - 1]; + + if (quote_hit && source[i - 1] == L'\\') { + *(target++) = L'\\'; + } else if(source[i - 1] == L'"') { + quote_hit = 1; + *(target++) = L'\\'; + } else { + quote_hit = 0; + } + } + target[0] = L'\0'; + _wcsrev(start); + *(target++) = L'"'; + return target; +} + + +int make_program_args(char** args, int verbatim_arguments, WCHAR** dst_ptr) { + char** arg; + WCHAR* dst = NULL; + WCHAR* temp_buffer = NULL; + size_t dst_len = 0; + size_t temp_buffer_len = 0; + WCHAR* pos; + int arg_count = 0; + int err = 0; + + /* Count the required size. */ + for (arg = args; *arg; arg++) { + ssize_t arg_len; + + arg_len = uv_wtf8_length_as_utf16(*arg); + if (arg_len < 0) + return arg_len; + + dst_len += arg_len; + + if ((size_t) arg_len > temp_buffer_len) + temp_buffer_len = arg_len; + + arg_count++; + } + + /* Adjust for potential quotes. Also assume the worst-case scenario that + * every character needs escaping, so we need twice as much space. */ + dst_len = dst_len * 2 + arg_count * 2; + + /* Allocate buffer for the final command line. */ + dst = uv__malloc(dst_len * sizeof(WCHAR)); + if (dst == NULL) { + err = UV_ENOMEM; + goto error; + } + + /* Allocate temporary working buffer. */ + temp_buffer = uv__malloc(temp_buffer_len * sizeof(WCHAR)); + if (temp_buffer == NULL) { + err = UV_ENOMEM; + goto error; + } + + pos = dst; + for (arg = args; *arg; arg++) { + ssize_t arg_len; + + /* Convert argument to wide char. */ + arg_len = uv_wtf8_length_as_utf16(*arg); + assert(arg_len > 0); + assert(temp_buffer_len >= (size_t) arg_len); + uv_wtf8_to_utf16(*arg, temp_buffer, arg_len); + + if (verbatim_arguments) { + /* Copy verbatim. */ + wcscpy(pos, temp_buffer); + pos += arg_len - 1; + } else { + /* Quote/escape, if needed. */ + pos = quote_cmd_arg(temp_buffer, pos); + } + + *pos++ = *(arg + 1) ? L' ' : L'\0'; + assert(pos <= dst + dst_len); + } + + uv__free(temp_buffer); + + *dst_ptr = dst; + return 0; + +error: + uv__free(dst); + uv__free(temp_buffer); + return err; +} + + +int env_strncmp(const wchar_t* a, int na, const wchar_t* b) { + wchar_t* a_eq; + wchar_t* b_eq; + wchar_t* A; + wchar_t* B; + int nb; + int r; + + if (na < 0) { + a_eq = wcschr(a, L'='); + assert(a_eq); + na = (int)(long)(a_eq - a); + } else { + na--; + } + b_eq = wcschr(b, L'='); + assert(b_eq); + nb = b_eq - b; + + A = _alloca((na+1) * sizeof(wchar_t)); + B = _alloca((nb+1) * sizeof(wchar_t)); + + r = LCMapStringW(LOCALE_INVARIANT, LCMAP_UPPERCASE, a, na, A, na); + assert(r==na); + A[na] = L'\0'; + r = LCMapStringW(LOCALE_INVARIANT, LCMAP_UPPERCASE, b, nb, B, nb); + assert(r==nb); + B[nb] = L'\0'; + + for (;;) { + wchar_t AA = *A++; + wchar_t BB = *B++; + if (AA < BB) { + return -1; + } else if (AA > BB) { + return 1; + } else if (!AA && !BB) { + return 0; + } + } +} + + +static int qsort_wcscmp(const void *a, const void *b) { + wchar_t* astr = *(wchar_t* const*)a; + wchar_t* bstr = *(wchar_t* const*)b; + return env_strncmp(astr, -1, bstr); +} + + +/* + * The way windows takes environment variables is different than what C does; + * Windows wants a contiguous block of null-terminated strings, terminated + * with an additional null. + * + * Windows has a few "essential" environment variables. winsock will fail + * to initialize if SYSTEMROOT is not defined; some APIs make reference to + * TEMP. SYSTEMDRIVE is probably also important. We therefore ensure that + * these get defined if the input environment block does not contain any + * values for them. + * + * Also add variables known to Cygwin to be required for correct + * subprocess operation in many cases: + * https://github.com/Alexpux/Cygwin/blob/b266b04fbbd3a595f02ea149e4306d3ab9b1fe3d/winsup/cygwin/environ.cc#L955 + * + */ +int make_program_env(char* env_block[], WCHAR** dst_ptr) { + WCHAR* dst; + WCHAR* ptr; + char** env; + size_t env_len = 0; + size_t len; + size_t i; + size_t var_size; + size_t env_block_count = 1; /* 1 for null-terminator */ + WCHAR* dst_copy; + WCHAR** ptr_copy; + WCHAR** env_copy; + size_t required_vars_value_len[ARRAY_SIZE(required_vars)]; + + /* first pass: determine size in UTF-16 */ + for (env = env_block; *env; env++) { + ssize_t len; + if (strchr(*env, '=')) { + len = uv_wtf8_length_as_utf16(*env); + if (len < 0) + return len; + env_len += len; + env_block_count++; + } + } + + /* second pass: copy to UTF-16 environment block */ + dst_copy = uv__malloc(env_len * sizeof(WCHAR)); + if (dst_copy == NULL && env_len > 0) { + return UV_ENOMEM; + } + env_copy = _alloca(env_block_count * sizeof(WCHAR*)); + + ptr = dst_copy; + ptr_copy = env_copy; + for (env = env_block; *env; env++) { + ssize_t len; + if (strchr(*env, '=')) { + len = uv_wtf8_length_as_utf16(*env); + assert(len > 0); + assert((size_t) len <= env_len - (ptr - dst_copy)); + uv_wtf8_to_utf16(*env, ptr, len); + *ptr_copy++ = ptr; + ptr += len; + } + } + *ptr_copy = NULL; + assert(env_len == 0 || env_len == (size_t) (ptr - dst_copy)); + + /* sort our (UTF-16) copy */ + qsort(env_copy, env_block_count-1, sizeof(wchar_t*), qsort_wcscmp); + + /* third pass: check for required variables */ + for (ptr_copy = env_copy, i = 0; i < ARRAY_SIZE(required_vars); ) { + int cmp; + if (!*ptr_copy) { + cmp = -1; + } else { + cmp = env_strncmp(required_vars[i].wide_eq, + required_vars[i].len, + *ptr_copy); + } + if (cmp < 0) { + /* missing required var */ + var_size = GetEnvironmentVariableW(required_vars[i].wide, NULL, 0); + required_vars_value_len[i] = var_size; + if (var_size != 0) { + env_len += required_vars[i].len; + env_len += var_size; + } + i++; + } else { + ptr_copy++; + if (cmp == 0) + i++; + } + } + + /* final pass: copy, in sort order, and inserting required variables */ + dst = uv__malloc((1+env_len) * sizeof(WCHAR)); + if (!dst) { + uv__free(dst_copy); + return UV_ENOMEM; + } + + for (ptr = dst, ptr_copy = env_copy, i = 0; + *ptr_copy || i < ARRAY_SIZE(required_vars); + ptr += len) { + int cmp; + if (i >= ARRAY_SIZE(required_vars)) { + cmp = 1; + } else if (!*ptr_copy) { + cmp = -1; + } else { + cmp = env_strncmp(required_vars[i].wide_eq, + required_vars[i].len, + *ptr_copy); + } + if (cmp < 0) { + /* missing required var */ + len = required_vars_value_len[i]; + if (len) { + wcscpy(ptr, required_vars[i].wide_eq); + ptr += required_vars[i].len; + var_size = GetEnvironmentVariableW(required_vars[i].wide, + ptr, + (int) (env_len - (ptr - dst))); + if (var_size != (DWORD) (len - 1)) { /* TODO: handle race condition? */ + uv_fatal_error(GetLastError(), "GetEnvironmentVariableW"); + } + } + i++; + } else { + /* copy var from env_block */ + len = wcslen(*ptr_copy) + 1; + wmemcpy(ptr, *ptr_copy, len); + ptr_copy++; + if (cmp == 0) + i++; + } + } + + /* Terminate with an extra NULL. */ + assert(env_len == (size_t) (ptr - dst)); + *ptr = L'\0'; + + uv__free(dst_copy); + *dst_ptr = dst; + return 0; +} + +#endif diff --git a/Tests/swshTests/integration-tests/IntegrationTests.swift b/Tests/swshTests/integration-tests/IntegrationTests.swift index 0685bca..529c61a 100644 --- a/Tests/swshTests/integration-tests/IntegrationTests.swift +++ b/Tests/swshTests/integration-tests/IntegrationTests.swift @@ -1,5 +1,8 @@ import swsh import XCTest +#if os(Windows) +import WinSDK +#endif final class IntegrationTests: XCTestCase { override func setUp() { @@ -63,7 +66,11 @@ final class IntegrationTests: XCTestCase { } func testAbsPath() { + #if os(Windows) + XCTAssertEqual(try? cmd("C:\\Windows\\System32\\cmd.exe", "/C", "ECHO 1").runString(), "1\r\n") + #else XCTAssertTrue(cmd("/bin/sh", "-c", "true").runBool()) + #endif } func testNonExistantProgram() { @@ -96,10 +103,10 @@ final class IntegrationTests: XCTestCase { } func testIsRunning() throws { - let pipe = Pipe() - let proc = cmd("cat").async(stdin: pipe.fileHandleForReading.fd) + let pipe = FDPipe() + let proc = cmd("cat").async(stdin: pipe.fileHandleForReading.fileDescriptor) XCTAssertTrue(proc.isRunning) - pipe.fileHandleForWriting.closeFile() + pipe.fileHandleForWriting.close() try proc.succeed() } @@ -111,6 +118,7 @@ final class IntegrationTests: XCTestCase { func testKillRunningProcess() throws { let res = cmd("bash", "-c", "while true; do sleep 1; done").async() + Thread.sleep(forTimeInterval: 0.5) try res.kill() XCTAssertEqual(res.exitCode(), 1) } @@ -119,12 +127,17 @@ final class IntegrationTests: XCTestCase { let res = cmd("true").async() try res.succeed() XCTAssertThrowsError(try res.kill()) { error in + #if os(Windows) + XCTAssertEqual("\(error)", "command \"taskkill.exe\" failed with exit code 128") + #else XCTAssertEqual("\(error)", "kill failed with error code 3: No such process") + #endif } } func testKillStop() throws { let res = try (cmd("bash", "-c", "while true; do sleep 1; done") | cmd("cat") | cmd("cat")).input("").async() + Thread.sleep(forTimeInterval: 0.5) try res.kill(signal: SIGSTOP) XCTAssert(res.isRunning) try res.kill(signal: SIGKILL) @@ -137,18 +150,56 @@ final class IntegrationTests: XCTestCase { } func testRemapCycle() throws { - let pipes = [Pipe(), Pipe()] - let write = pipes.map { $0.fileHandleForWriting.fd } - let res = cmd("bash", "-c", "echo thing1 >&\(write[0]); echo thing2 >&\(write[1])").async(fdMap: [ + let pipes = [FDPipe(), FDPipe()] + let write = pipes.map { $0.fileHandleForWriting.fileDescriptor } + + let id = UUID().uuidString + #if os(Windows) + let cProgram = """ + #include + #include + + int main(int argc, char** argv) { + _write(\(write[0]), "stuff-to-a", strlen("stuff-to-a")); + _write(\(write[1]), "stuff-to-b", strlen("stuff-to-b")); + return 0; + } + + """ + let cProgramFile = "writer-\(id).c" + let cProgramExecutable = "writer-\(id).exe" + #else + let cProgram = """ + #include + #include + + int main(int argc, char** argv) { + write(\(write[0]), "stuff-to-a", strlen("stuff-to-a")); + write(\(write[1]), "stuff-to-b", strlen("stuff-to-b")); + return 0; + } + + """ + let cProgramFile = "writer-\(id).c" + let cProgramExecutable = "writer-\(id)" + #endif + + try cmd("cat").input(cProgram).output(overwritingFile: cProgramFile).run() + try cmd("clang", "-o", cProgramExecutable, cProgramFile).run() + + let res = cmd("./\(cProgramExecutable)").async(fdMap: [ write[0]: write[1], write[1]: write[0], ]) - pipes.forEach { $0.fileHandleForWriting.closeFile() } + pipes.forEach { $0.fileHandleForWriting.close() } let output = pipes.map { - String(data: $0.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) + String(data: $0.fileHandleForReading.handle.readDataToEndOfFile(), encoding: .utf8) } try res.succeed() - XCTAssertEqual(output, ["thing2\n", "thing1\n"]) + XCTAssertEqual(output, ["stuff-to-b", "stuff-to-a"]) + + try cmd("rm", cProgramFile).run() + try cmd("rm", cProgramExecutable).run() } func testCdSuccess() throws { diff --git a/Tests/swshTests/mocks/MockCommand.swift b/Tests/swshTests/mocks/MockCommand.swift index 6dabe23..052dfeb 100644 --- a/Tests/swshTests/mocks/MockCommand.swift +++ b/Tests/swshTests/mocks/MockCommand.swift @@ -8,18 +8,18 @@ class MockCommand: Command, Equatable, CustomStringConvertible { private var _exitCode: Int32? private var _exitSemaphore = DispatchSemaphore(value: 0) public var fdMap: FDMap - public var handles: [FileDescriptor: FileHandle] + public var handles: [FileDescriptor: FDFileHandle] public init(command: MockCommand, fdMap: FDMap) { _command = command self.fdMap = fdMap - handles = [FileDescriptor: FileHandle]() + handles = [FileDescriptor: FDFileHandle]() for (dst, src) in fdMap { - handles[dst] = handles[src] ?? FileHandle(fileDescriptor: dup(src.rawValue), closeOnDealloc: true) + handles[dst] = handles[src] ?? FDFileHandle(fileDescriptor: duplicate(src), closeOnDealloc: true) } } - subscript(_ fd: FileDescriptor) -> FileHandle! { handles[fd] } + subscript(_ fd: FileDescriptor) -> FileHandle! { handles[fd]?.handle } public func setExit(code: Int32) { let old = _exitCode @@ -43,6 +43,14 @@ class MockCommand: Command, Equatable, CustomStringConvertible { throw error } } + + private func duplicate(_ fd: FileDescriptor) -> FileDescriptor { + #if os(Windows) + return FileDescriptor(_dup(fd.rawValue)) + #else + return FileDescriptor(dup(fd.rawValue)) + #endif + } } public var killResponse: Error? diff --git a/Tests/swshTests/unit-tests/CommandExtensionTests.swift b/Tests/swshTests/unit-tests/CommandExtensionTests.swift index 8145a8d..9e408b9 100644 --- a/Tests/swshTests/unit-tests/CommandExtensionTests.swift +++ b/Tests/swshTests/unit-tests/CommandExtensionTests.swift @@ -48,6 +48,7 @@ class CommandExtensionTests: XCTestCase { } func testAsync() throws { + // TODO: Succeeds on Windows from PowerShell, but fails from VSCode test system. Why? let res = try unwrap(cmd.async(stdin: 4, stdout: 5, stderr: 6) as? MockCommand.Result) XCTAssertEqual(res.fdMap, [0: 4, 1: 5, 2: 6]) } @@ -58,7 +59,7 @@ class CommandExtensionTests: XCTestCase { res[2].write(data) res[1].closeFile() res[2].closeFile() - XCTAssertEqual(handle.readDataToEndOfFile(), data) + XCTAssertEqual(handle.handle.readDataToEndOfFile(), data) } func testRunSucceeds() { diff --git a/Tests/swshTests/unit-tests/FDWrapperCommandTests.swift b/Tests/swshTests/unit-tests/FDWrapperCommandTests.swift index 066bbd1..20c7b52 100644 --- a/Tests/swshTests/unit-tests/FDWrapperCommandTests.swift +++ b/Tests/swshTests/unit-tests/FDWrapperCommandTests.swift @@ -3,7 +3,7 @@ import XCTest final class FDWrapperCommandTests: XCTestCase { let inner = MockCommand(description: "inner") - lazy var cmd = FDWrapperCommand( inner: inner, opening: "/dev/null", toHandle: 0, oflag: O_RDONLY) + lazy var cmd = FDWrapperCommand(inner: inner, openingNullDeviceToHandle: 0, oflag: O_RDONLY) lazy var invalidCmd = FDWrapperCommand( inner: inner, opening: "\(UUID())", toHandle: 0, oflag: O_RDONLY) func result() throws -> (outer: FDWrapperCommand.Result, inner: MockCommand.Result) { @@ -149,8 +149,9 @@ final class FDWrapperCommandExtensionsTests: XCTestCase { } func testDuplicateFd() throws { - try succeed(inner.duplicateFd(source: 42, destination: 35)) - XCTAssertEqual(innerResult.fdMap[35], 42) + let pipe = FDPipe() + try succeed(inner.duplicateFd(source: pipe.fileHandleForReading.fileDescriptor, destination: 35)) + XCTAssertEqual(innerResult.fdMap[35], pipe.fileHandleForReading.fileDescriptor) } func testCombineError() throws { diff --git a/Tests/swshTests/unit-tests/PipelineTests.swift b/Tests/swshTests/unit-tests/PipelineTests.swift index c0eb80c..dd87918 100644 --- a/Tests/swshTests/unit-tests/PipelineTests.swift +++ b/Tests/swshTests/unit-tests/PipelineTests.swift @@ -71,22 +71,38 @@ class PipelineTests: XCTestCase { try pipeline.async().kill() } - class AnError: Error, Equatable { + class AnError: Error, Equatable, CustomStringConvertible { static func == (lhs: AnError, rhs: AnError) -> Bool { lhs.id == rhs.id } let id = UUID() + + public var description: String { + "AnError id: \(id)" + } } func testPipeKillFailure() throws { + let err0 = AnError() let err1 = AnError() let err2 = AnError() + cmd0.killResponse = err0 cmd1.killResponse = err1 cmd2.killResponse = err2 XCTAssertThrowsError(try pipeline.async().kill()) { error in - XCTAssertEqual(error as? AnError, err1) + XCTAssertEqual(error as? AnError, err0) } } + func testPipeKillPartialSuccess() throws { + let err0 = AnError() + let err2 = AnError() + cmd0.killResponse = err0 + cmd1.killResponse = nil + cmd2.killResponse = err2 + + try pipeline.async().kill() + } + func testPipeDescription() throws { XCTAssertEqual(pipeline.description, "cmd0 | cmd1 | cmd2") } diff --git a/scripts/trigger-release.sh b/scripts/trigger-release.sh old mode 100755 new mode 100644