├── .gitignore ├── .swift-format ├── .swiftpm └── xcode │ ├── package.xcworkspace │ └── xcuserdata │ │ └── sam.xcuserdatad │ │ └── UserInterfaceState.xcuserstate │ └── xcuserdata │ └── sam.xcuserdatad │ └── xcschemes │ └── xcschememanagement.plist ├── .vscode ├── settings.json └── tasks.json ├── IMPLEMENTATION.md ├── Package.resolved ├── Package.swift ├── README.md ├── Sources └── Runner │ ├── Error.swift │ ├── Output.swift │ ├── RunState.swift │ ├── Runner.swift │ ├── Session.swift │ └── URL+SystemPath.swift └── Tests └── RunnerTests ├── Resources ├── args.sh ├── long-running.sh ├── non-zero-status.sh └── zero-status.sh └── RunnerTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .build 2 | .DS_Store -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy": { 3 | "accessLevel": "private" 4 | }, 5 | "indentation": { 6 | "spaces": 2 7 | }, 8 | "indentConditionalCompilationBlocks": true, 9 | "indentSwitchCaseLabels": true, 10 | "lineBreakAroundMultilineExpressionChainComponents": false, 11 | "lineBreakBeforeControlFlowKeywords": true, 12 | "lineBreakBeforeEachArgument": false, 13 | "lineBreakBeforeEachGenericRequirement": false, 14 | "lineLength": 160, 15 | "maximumBlankLines": 2, 16 | "multiElementCollectionTrailingCommas": true, 17 | "noAssignmentInExpressions": { 18 | "allowedFunctions": [ 19 | "XCTAssertNoThrow" 20 | ] 21 | }, 22 | "prioritizeKeepingFunctionOutputTogether": true, 23 | "respectsExistingLineBreaks": true, 24 | "rules": { 25 | "AllPublicDeclarationsHaveDocumentation": false, 26 | "AlwaysUseLiteralForEmptyCollectionInit": false, 27 | "AlwaysUseLowerCamelCase": true, 28 | "AmbiguousTrailingClosureOverload": true, 29 | "BeginDocumentationCommentWithOneLineSummary": false, 30 | "DoNotUseSemicolons": true, 31 | "DontRepeatTypeInStaticProperties": true, 32 | "FileScopedDeclarationPrivacy": true, 33 | "FullyIndirectEnum": true, 34 | "GroupNumericLiterals": true, 35 | "IdentifiersMustBeASCII": true, 36 | "NeverForceUnwrap": false, 37 | "NeverUseForceTry": false, 38 | "NeverUseImplicitlyUnwrappedOptionals": false, 39 | "NoAccessLevelOnExtensionDeclaration": true, 40 | "NoAssignmentInExpressions": true, 41 | "NoBlockComments": true, 42 | "NoCasesWithOnlyFallthrough": true, 43 | "NoEmptyTrailingClosureParentheses": true, 44 | "NoLabelsInCasePatterns": true, 45 | "NoLeadingUnderscores": false, 46 | "NoParensAroundConditions": true, 47 | "NoPlaygroundLiterals": true, 48 | "NoVoidReturnOnFunctionSignature": true, 49 | "OmitExplicitReturns": false, 50 | "OneCasePerLine": true, 51 | "OneVariableDeclarationPerLine": true, 52 | "OnlyOneTrailingClosureArgument": true, 53 | "OrderedImports": true, 54 | "ReplaceForEachWithForLoop": true, 55 | "ReturnVoidInsteadOfEmptyTuple": true, 56 | "TypeNamesShouldBeCapitalized": true, 57 | "UseEarlyExits": false, 58 | "UseLetInEveryBoundCaseVariable": true, 59 | "UseShorthandTypeNames": true, 60 | "UseSingleLinePropertyGetter": true, 61 | "UseSynthesizedInitializer": true, 62 | "UseTripleSlashForDocumentationComments": true, 63 | "UseWhereClausesInForLoops": false, 64 | "ValidateDocumentationComments": false 65 | }, 66 | "spacesAroundRangeFormationOperators": false, 67 | "tabWidth": 8, 68 | "version": 1 69 | } -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcuserdata/sam.xcuserdatad/UserInterfaceState.xcuserstate: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/elegantchaos/Runner/e5b8e0fc4b5119b238935343dc60171c0140ee1f/.swiftpm/xcode/package.xcworkspace/xcuserdata/sam.xcuserdatad/UserInterfaceState.xcuserstate -------------------------------------------------------------------------------- /.swiftpm/xcode/xcuserdata/sam.xcuserdatad/xcschemes/xcschememanagement.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | SchemeUserState 6 | 7 | Runner-Package.xcscheme_^#shared#^_ 8 | 9 | orderHint 10 | 22 11 | 12 | Runner.xcscheme_^#shared#^_ 13 | 14 | orderHint 15 | 20 16 | 17 | 18 | SuppressBuildableAutocreation 19 | 20 | Runner 21 | 22 | primary 23 | 24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "swift.actionAfterBuildError": "Do Nothing", 3 | "swift.debugger.useDebugAdapterFromToolchain": true, 4 | "editor.formatOnSave": true, 5 | "editor.formatOnPaste": true, 6 | "editor.tabSize": 2, 7 | "editor.useTabStops": false 8 | } -------------------------------------------------------------------------------- /.vscode/tasks.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0.0", 3 | "tasks": [ 4 | { 5 | "type": "swift", 6 | "args": [ 7 | "build", 8 | "--build-tests", 9 | "-Xswiftc", 10 | "-diagnostic-style=llvm" 11 | ], 12 | "env": {}, 13 | "cwd": "${workspaceFolder}", 14 | "disableTaskQueue": true, 15 | "showBuildStatus": "swiftStatus", 16 | "group": { 17 | "kind": "build", 18 | "isDefault": true 19 | }, 20 | "problemMatcher": [], 21 | "label": "swift: Build All", 22 | "detail": "swift build --build-tests -Xswiftc -diagnostic-style=llvm" 23 | }, 24 | { 25 | "type": "swift", 26 | "args": [ 27 | "test", 28 | "--parallel", 29 | "--enable-test-discovery", 30 | "-Xswiftc", 31 | "-diagnostic-style=llvm" 32 | ], 33 | "env": {}, 34 | "cwd": "${workspaceFolder}", 35 | "disableTaskQueue": true, 36 | "showBuildStatus": "swiftStatus", 37 | "group": { 38 | "kind": "test", 39 | "isDefault": true 40 | }, 41 | "problemMatcher": [], 42 | "label": "swift: Test All", 43 | "detail": "swift test --parallel --enable-test-discovery -Xswiftc -diagnostic-style=llvm" 44 | }, 45 | ] 46 | } -------------------------------------------------------------------------------- /IMPLEMENTATION.md: -------------------------------------------------------------------------------- 1 | # Implementation Notes 2 | 3 | Mostly of the "note to self" variety... 4 | 5 | 6 | 7 | 8 | ## Useful References 9 | 10 | - https://mastodon.org.uk/@samdeane/113084744147937093 11 | - https://developer.apple.com/forums/thread/690310 12 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "originHash" : "f41d6805f70bb3059b9c61053f558310948b6df3917ab71c67b6361e87962d05", 3 | "pins" : [ 4 | { 5 | "identity" : "chaosbytestreams", 6 | "kind" : "remoteSourceControl", 7 | "location" : "https://github.com/elegantchaos/ChaosByteStreams", 8 | "state" : { 9 | "revision" : "dc5fc08a831e8e2878197418e3ed35f234bcfe07", 10 | "version" : "1.0.6" 11 | } 12 | } 13 | ], 14 | "version" : 3 15 | } 16 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | import PackageDescription 4 | 5 | let package = Package( 6 | name: "Runner", 7 | 8 | platforms: [.macOS(.v12), .iOS(.v15), .watchOS(.v8), .tvOS(.v15)], 9 | 10 | products: [.library(name: "Runner", targets: ["Runner"])], 11 | 12 | dependencies: [ 13 | .package( 14 | url: "https://github.com/elegantchaos/ChaosByteStreams", 15 | from: "1.0.6" 16 | ) 17 | ], 18 | 19 | targets: [ 20 | .target( 21 | name: "Runner", 22 | dependencies: [ 23 | .product(name: "ChaosByteStreams", package: "ChaosByteStreams") 24 | ] 25 | ), 26 | 27 | .testTarget( 28 | name: "RunnerTests", 29 | dependencies: ["Runner"], 30 | resources: [.process("Resources")] 31 | ), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | # Runner 3 | 4 | Support for executing subprocesses, using Foundation.Process, and capturing their 5 | output asynchronously. Swift 6 ready. 6 | 7 | Currently doesn't support a STDIN stream -- only STDOUT and STDERR -- but it should be easy enough to add when I (or someone else?) hit a use case. 8 | 9 | Usage examples: 10 | 11 | ### Run And Capture Stdout 12 | 13 | ```swift 14 | 15 | let url = /* url to the executable */ 16 | let runner = Runner(for: url) 17 | 18 | // execute with some arguments 19 | let session = runner.run(["some", "arguments"]) 20 | 21 | // process the output asynchronously 22 | for await l in session.stdout.lines { 23 | print(l) 24 | } 25 | ``` 26 | 27 | ### Run In A Different Working Directory 28 | 29 | ```swift 30 | // run in a different working directory 31 | runner.cwd = /* url to the directory */ 32 | let _ = runner.run(["blah"]) 33 | ``` 34 | 35 | ### Transfer Execution 36 | 37 | ```swift 38 | // transfer execution to the subprocess 39 | runner.exec(url) 40 | ``` 41 | 42 | ## Lookup Executable In Path 43 | 44 | ```swift 45 | 46 | let runner = Runner(command: "git") /// we'll find git in $PATH if it's there 47 | let session = runner.run("status") 48 | print(await session.stdout.string) 49 | ``` 50 | 51 | 52 | ### Run And Wait For Termination 53 | 54 | ```swift 55 | let url = /* url to the executable */ 56 | let runner = Runner(for: url) 57 | 58 | // execute with some arguments 59 | let session = runner.run(["some", "arguments"]) 60 | 61 | // wait for termination and read state 62 | if await session.waitUntilExit() == .succeeded { 63 | print("all good") 64 | } 65 | ``` 66 | 67 | ### Run Passing Stdout/Stderr Through 68 | 69 | ```swift 70 | let url = /* url to the executable */ 71 | let runner = Runner(for: url) 72 | let session = runner.run(stdoutMode: .forward, stderrMode: .forward) 73 | let _ = session.waitUntilExit() 74 | ``` -------------------------------------------------------------------------------- /Sources/Runner/Error.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane on 04/09/24. 3 | // All code (c) 2024 - present day, Elegant Chaos Limited. 4 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 5 | 6 | /// Errors conforming to this protocol can provide a description of themselves. 7 | /// The function that returns the description has access to the session in which 8 | /// the error occurred, and so can use captured output and the termination status 9 | /// to provide a more detailed error message. 10 | extension Runner { 11 | public protocol Error: Swift.Error, Sendable { 12 | func description(for session: Runner.Session) async -> String 13 | } 14 | 15 | /// A wrapped error that includes an expanded description, 16 | /// along with the original error. 17 | public struct WrappedError: Swift.Error, CustomStringConvertible, Sendable { 18 | public let error: Error 19 | public let description: String 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Sources/Runner/Output.swift: -------------------------------------------------------------------------------- 1 | import ChaosByteStreams 2 | import Foundation 3 | 4 | extension Runner { 5 | /// Helper for managing the output of a process. 6 | public struct Output: Sendable { 7 | 8 | /// The mode for handling the stream. 9 | public enum Mode { 10 | /// Forward the output to stdout/stderr. 11 | case forward 12 | /// Capture the output. 13 | case capture 14 | /// Capture the output and forward it to stdout/stderr. 15 | case both 16 | /// Discard the output. 17 | case discard 18 | } 19 | 20 | /// A custom pipe to capture output, if we're in capture mode. 21 | let pipe: Pipe? 22 | 23 | /// The file to capture output, if we're not capturing. 24 | let handle: FileHandle 25 | 26 | let buffer: DataBuffer? 27 | 28 | /// Return a byte stream for the given mode. 29 | /// If the mode is .forward, we use the standard handle for the process. 30 | /// If the mode is .capture, we make a new pipe and use that. 31 | /// If the mode is .both, we make a new pipe and set it up to forward to the standard handle. 32 | /// If the mode is .discard, we use /dev/null. 33 | init(mode: Mode, standardHandle: FileHandle, name: String) { 34 | switch mode { case .forward: 35 | pipe = nil 36 | handle = standardHandle 37 | buffer = nil 38 | 39 | case .capture: 40 | let (b, p, h) = Self.setupBuffer(name: name) 41 | self.pipe = p 42 | self.handle = h 43 | self.buffer = b 44 | 45 | case .both: 46 | let (b, p, h) = Self.setupBuffer(name: name, forwardingTo: standardHandle) 47 | self.pipe = p 48 | self.handle = h 49 | self.buffer = b 50 | 51 | case .discard: 52 | pipe = nil 53 | handle = FileHandle.nullDevice 54 | buffer = nil 55 | } 56 | } 57 | 58 | static func setupBuffer( 59 | name: String, 60 | forwardingTo forwardHandle: FileHandle? = nil 61 | ) -> (DataBuffer, Pipe, FileHandle) { 62 | let pipe = Pipe() 63 | let handle = pipe.fileHandleForReading 64 | let buffer = DataBuffer() 65 | handle.readabilityHandler = { handle in 66 | let data = handle.availableData 67 | try? forwardHandle?.write(contentsOf: data) 68 | if data.isEmpty { 69 | debug("\(name) closing") 70 | handle.readabilityHandler = nil 71 | Task.detached { 72 | await buffer.close() 73 | await debugAsync("\(name) closed - '\(String(data: await buffer.buffer, encoding: .utf8)!)'") 74 | } 75 | 76 | } 77 | else { 78 | Task.detached { 79 | await buffer.append(data) 80 | debug("\(name) appended \(String(data: data, encoding: .utf8)!)") 81 | } 82 | } 83 | 84 | } 85 | return (buffer, pipe, handle) 86 | } 87 | 88 | /// A sequence of bytes from the stream. 89 | public var bytes: DataBuffer.AsyncBytes { 90 | get async { await buffer?.bytes ?? DataBuffer.noBytes } 91 | } 92 | 93 | /// A sequence of lines from the stream. 94 | public var lines: AsyncLineSequence { 95 | get async { 96 | await buffer?.lines ?? DataBuffer.noBytes.lines 97 | } 98 | } 99 | 100 | /// The whole stream as a `String`. 101 | public var string: String { 102 | get async { 103 | await buffer?.string ?? "" 104 | } 105 | } 106 | 107 | /// The whole stream as a `Data` object. 108 | public var data: Data { 109 | get async { 110 | await buffer?.data ?? Data() 111 | } 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /Sources/Runner/RunState.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane on 04/09/24. 3 | // All code (c) 2024 - present day, Elegant Chaos Limited. 4 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 5 | 6 | import Foundation 7 | 8 | /// Representation of the state of the process. 9 | public enum RunState: Comparable, Sendable { 10 | case succeeded 11 | case failed(Int32) 12 | case uncaughtSignal 13 | case startup(String) 14 | case unknown 15 | 16 | public struct Sequence: AsyncSequence, Sendable { 17 | /// The runner we're reporting on. 18 | let process: Process 19 | 20 | public func makeAsyncIterator() -> AsyncStream.Iterator { 21 | Runner.debug("makeIterator") 22 | return makeStream().makeAsyncIterator() 23 | } 24 | 25 | public func makeStream() -> AsyncStream { 26 | Runner.debug("makeStream") 27 | return AsyncStream { continuation in 28 | Runner.debug("registering callback") 29 | process.terminationHandler = { _ in 30 | Runner.debug("terminated") 31 | // cleanup(stream: process.standardOutput, name: "stdout") 32 | // cleanup(stream: process.standardError, name: "stderr") 33 | continuation.yield(process.finalState) 34 | continuation.finish() 35 | } 36 | 37 | do { try process.run() } 38 | catch { 39 | continuation.yield(.startup(String(describing: error))) // TODO: better to send the error here, but we then need to make RunState Comparable 40 | continuation.finish() 41 | } 42 | 43 | continuation.onTermination = { termination in 44 | Runner.debug("state continuation \(termination)") 45 | } 46 | 47 | } 48 | } 49 | 50 | func cleanup(stream: Any?, name: String) { // TODO: this is probably unnecessary; remove it 51 | let handle = 52 | (stream as? Pipe)?.fileHandleForReading ?? (stream as? FileHandle) 53 | if let handle { 54 | Runner.debug("syncing \(name)") 55 | try? handle.synchronize() 56 | } 57 | } 58 | 59 | } 60 | 61 | } 62 | 63 | extension Process { 64 | /// Return the final state of the process. 65 | var finalState: RunState { 66 | assert(!isRunning) 67 | 68 | switch terminationReason { case .exit: 69 | return terminationStatus == 0 ? .succeeded : .failed(terminationStatus) 70 | case .uncaughtSignal: return .uncaughtSignal 71 | default: return .unknown 72 | } 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /Sources/Runner/Runner.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane, 26/08/2024. 3 | // All code (c) 2024 - present day, Elegant Chaos Limited. 4 | // For licensing terms, see http://elegantchaos.com/license/liberal/. 5 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 6 | 7 | import ChaosByteStreams 8 | import Foundation 9 | 10 | open class Runner { 11 | /// The environment to run the command in. 12 | var environment: [String: String] 13 | 14 | /// The URL of the executable to run. 15 | let executable: URL 16 | 17 | /// The current working directory to run the command in. 18 | public var cwd: URL? 19 | 20 | /// Log a message if internal logging is enabled. 21 | static internal func debug(_ message: @autoclosure () -> String) { 22 | #if DEBUG_RUNNER 23 | print(message) 24 | #endif 25 | } 26 | 27 | /// Log a message if internal logging is enabled. 28 | static internal func debugAsync(_ message: @autoclosure () async -> String) async { 29 | #if DEBUG_RUNNER 30 | print(await message()) 31 | #endif 32 | } 33 | 34 | /// Initialise with an explicit URL to the executable. 35 | public init( 36 | for executable: URL, 37 | cwd: URL? = nil, 38 | environment: [String: String] = ProcessInfo.processInfo.environment 39 | ) { 40 | self.executable = executable 41 | self.environment = environment 42 | self.cwd = cwd 43 | } 44 | 45 | /// Initialise with a command name. 46 | /// The command will be searched for using $PATH. 47 | public init( 48 | command: String, 49 | cwd: URL? = nil, 50 | environment: [String: String] = ProcessInfo.processInfo.environment 51 | ) { 52 | self.executable = URL( 53 | inSystemPathWithName: command, 54 | fallback: "/usr/bin/\(command)" 55 | ) 56 | self.environment = environment 57 | self.cwd = cwd 58 | } 59 | 60 | /// Invoke a command and some optional arguments asynchronously. 61 | /// Returns a running session which contains three async streams. 62 | /// The first stream is the stdout output of the process. 63 | /// The second stream is the stderr output of the process. 64 | /// The third stream is the state of the process. 65 | public func run( 66 | _ arguments: [String] = [], 67 | stdoutMode: Output.Mode = .capture, 68 | stderrMode: Output.Mode = .capture 69 | ) -> Session { 70 | 71 | let process = Process() 72 | if let cwd = cwd { process.currentDirectoryURL = cwd } 73 | process.executableURL = executable 74 | process.arguments = arguments 75 | process.environment = environment 76 | 77 | let stdout = Output( 78 | mode: stdoutMode, 79 | standardHandle: FileHandle.standardOutput, 80 | name: "stdout" 81 | ) 82 | process.standardOutput = stdout.pipe ?? stdout.handle 83 | let stderr = Output( 84 | mode: stderrMode, 85 | standardHandle: FileHandle.standardError, 86 | name: "stderr" 87 | ) 88 | process.standardError = stderr.pipe ?? stderr.handle 89 | let state = RunState.Sequence(process: process).makeStream() 90 | let session = Session(stdout: stdout, stderr: stderr, state: state) 91 | 92 | return session 93 | } 94 | 95 | /// Invoke a command and some optional arguments. 96 | /// Control is transferred to the launched process, and this function doesn't return. 97 | public func exec(arguments: [String] = []) -> Never { 98 | let process = Process() 99 | if let cwd = cwd { process.currentDirectoryURL = cwd } 100 | 101 | process.executableURL = executable 102 | process.arguments = arguments 103 | process.environment = environment 104 | 105 | do { try process.run() } 106 | catch { fatalError("Failed to launch \(executable).\n\n\(error)") } 107 | 108 | process.waitUntilExit() 109 | exit(process.terminationStatus) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /Sources/Runner/Session.swift: -------------------------------------------------------------------------------- 1 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 2 | // Created by Sam Deane on 04/09/24. 3 | // All code (c) 2024 - present day, Elegant Chaos Limited. 4 | // -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 5 | 6 | import ChaosByteStreams 7 | import Foundation 8 | 9 | extension Runner { 10 | 11 | public struct Session: Sendable { 12 | /// Captured output stream from the process. 13 | public let stdout: Output 14 | 15 | /// Capture error stream from the process. 16 | public let stderr: Output 17 | 18 | /// One-shot stream of the state of the process. 19 | /// This will only ever yield one value, and then complete. 20 | /// You can await this value if you want to wait for the process to finish. 21 | public let state: AsyncStream 22 | 23 | /// Wait for the process to finish and return the final state. 24 | public func waitUntilExit() async -> RunState { 25 | for await state in self.state { 26 | debug("termination state was \(state)") 27 | return state 28 | } 29 | fatalError("somehow process didn't yield a state") 30 | } 31 | 32 | /// Check the state of the process and perform an action if it failed. 33 | nonisolated public func ifFailed( 34 | _ e: @Sendable @escaping () async -> Void 35 | ) async throws { 36 | let s = await waitUntilExit() 37 | if s != .succeeded { 38 | debug("failed") 39 | Task.detached { await e() } 40 | } 41 | } 42 | 43 | /// Check the state of the process and throw an error if it failed. 44 | /// Creation of the error is deferred until the state is known, to 45 | /// avoid doing extra work. 46 | /// 47 | /// The error is allowed to be nil, in which case no error is thrown. 48 | /// This is useful if you want to throw an error only in certain circumstances. 49 | public func throwIfFailed( 50 | _ e: @autoclosure @Sendable @escaping () async -> Swift.Error? 51 | ) async throws { 52 | let s = await waitUntilExit() 53 | if s != .succeeded { 54 | debug("failed") 55 | var error = await e() 56 | if let e = error as? Runner.Error { 57 | let d = await e.description(for: self) 58 | error = Runner.WrappedError(error: e, description: d) 59 | } 60 | 61 | if let error { 62 | debug("throwing \(error)") 63 | throw error 64 | } 65 | } 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Runner/URL+SystemPath.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | extension URL { 4 | /// Create a URL to an item in the system path. 5 | /// On unix systems, this is the $PATH environment variable. 6 | /// Returns nil if it can't find the item. 7 | /// 8 | /// Note that the item doesn't have to be the name of an 9 | /// executable, and it can contain subdirectories; it just 10 | /// has to be in the system path somewhere. 11 | public init?(inSystemPathWithName item: String) { 12 | let fm = FileManager.default 13 | if let path = ProcessInfo.processInfo.environment["PATH"] { 14 | for root in path.split(separator: ":") { 15 | let url = URL(fileURLWithPath: String(root)).appendingPathComponent(item) 16 | if fm.fileExists(atPath: url.path) { 17 | self = url 18 | return 19 | } 20 | } 21 | } 22 | 23 | return nil 24 | } 25 | 26 | /// Create a URL to an item in the system path. 27 | /// On unix systems, this is the $PATH environment variable. 28 | /// Returns a fallback URL if it can't find the item. 29 | /// 30 | /// Note that the item doesn't have to be the name of an 31 | /// executable, and it can contain subdirectories; it just 32 | /// has to be in the system path somewhere. 33 | public init(inSystemPathWithName item: String, fallback: String) { 34 | self = URL(inSystemPathWithName: item) ?? URL(fileURLWithPath: fallback) 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/RunnerTests/Resources/args.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 4 | # Created by Sam Deane on 27/03/2020. 5 | # All code (c) 2020 - present day, Elegant Chaos Limited. 6 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 7 | 8 | echo "args $@" 9 | -------------------------------------------------------------------------------- /Tests/RunnerTests/Resources/long-running.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 4 | # Created by Sam Deane on 27/03/2020. 5 | # All code (c) 2020 - present day, Elegant Chaos Limited. 6 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 7 | 8 | 9 | echo "hello" 10 | sleep 1 11 | echo "goodbye" 12 | -------------------------------------------------------------------------------- /Tests/RunnerTests/Resources/non-zero-status.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 4 | # Created by Sam Deane on 27/03/2020. 5 | # All code (c) 2020 - present day, Elegant Chaos Limited. 6 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 7 | 8 | echo "stdout" 9 | echo "stderr" 1>&2 10 | exit 123 11 | -------------------------------------------------------------------------------- /Tests/RunnerTests/Resources/zero-status.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 4 | # Created by Sam Deane on 27/03/2020. 5 | # All code (c) 2020 - present day, Elegant Chaos Limited. 6 | # -=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=-=- 7 | 8 | echo "stdout" 9 | echo "stderr" 1>&2 10 | -------------------------------------------------------------------------------- /Tests/RunnerTests/RunnerTests.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import Testing 3 | 4 | @testable import Runner 5 | 6 | /// Test wait for termination. 7 | @Test func testWait() async throws { 8 | let runner = Runner( 9 | for: Bundle.module.url(forResource: "zero-status", withExtension: "sh")! 10 | ) 11 | let session = runner.run() 12 | 13 | let state = await session.waitUntilExit() 14 | #expect(state == .succeeded) 15 | } 16 | 17 | /// Test with a task that has a zero status. 18 | @Test func testZeroStatus() async throws { 19 | let runner = Runner( 20 | for: Bundle.module.url(forResource: "zero-status", withExtension: "sh")! 21 | ) 22 | let session = runner.run() 23 | 24 | for try await l in await session.stdout.lines { #expect(l == "stdout") } 25 | 26 | for try await l in await session.stderr.lines { #expect(l == "stderr") } 27 | 28 | for await state: RunState in session.state { #expect(state == .succeeded) } 29 | } 30 | 31 | /// Test with a task that has a non-zero status. 32 | @Test func testNonZeroStatus() async throws { 33 | let runner = Runner( 34 | for: Bundle.module.url(forResource: "non-zero-status", withExtension: "sh")! 35 | ) 36 | let session = runner.run() 37 | 38 | for try await l in await session.stdout.lines { #expect(l == "stdout") } 39 | 40 | for try await l in await session.stderr.lines { #expect(l == "stderr") } 41 | 42 | for await state in session.state { #expect(state == .failed(123)) } 43 | } 44 | 45 | /// Test with a task that outputs more than one line 46 | /// and takes a while to complete. 47 | @Test func testLongRunningStatus() async throws { 48 | let runner = Runner( 49 | for: Bundle.module.url(forResource: "long-running", withExtension: "sh")! 50 | ) 51 | let session = runner.run() 52 | 53 | var expected = ["hello", "goodbye"] 54 | for try await l in await session.stdout.lines { 55 | #expect(l == expected.removeFirst()) 56 | } 57 | #expect(expected.isEmpty) 58 | 59 | for await state in session.state { #expect(state == .succeeded) } 60 | } 61 | 62 | /// Test tee mode where we both capture 63 | /// the process output and write it to stdout/stderr. 64 | @Test func testBothMode() async throws { 65 | let runner = Runner( 66 | for: Bundle.module.url(forResource: "zero-status", withExtension: "sh")! 67 | ) 68 | let session = runner.run(stdoutMode: .both, stderrMode: .both) 69 | 70 | for try await l in await session.stdout.lines { #expect(l == "stdout") } 71 | 72 | for try await l in await session.stderr.lines { #expect(l == "stderr") } 73 | 74 | for await state in session.state { #expect(state == .succeeded) } 75 | } 76 | 77 | /// Test pass-through mode where we don't capture 78 | /// the process output, but forward it to stdout/stderr. 79 | @Test func testPassthroughMode() async throws { 80 | let runner = Runner( 81 | for: Bundle.module.url(forResource: "zero-status", withExtension: "sh")! 82 | ) 83 | let session = runner.run(stdoutMode: .forward, stderrMode: .forward) 84 | 85 | for try await _ in await session.stdout.bytes { 86 | #expect(Bool(false), "shouldn't be any content") 87 | } 88 | 89 | for try await _ in await session.stderr.bytes { 90 | #expect(Bool(false), "shouldn't be any content") 91 | } 92 | 93 | for try await state in session.state { #expect(state == .succeeded) } 94 | } 95 | 96 | /// Test passing arguments. 97 | @Test func testArgs() async throws { 98 | let runner = Runner( 99 | for: Bundle.module.url(forResource: "args", withExtension: "sh")! 100 | ) 101 | let session = runner.run(["arg1", "arg2"]) 102 | 103 | for try await line in await session.stdout.lines { 104 | #expect(line == "args arg1 arg2") 105 | } 106 | } 107 | 108 | /// Regression test for xcodebuild which triggered a deadlock in an earlier implementation. 109 | @Test func testXcodeBuild() async throws { 110 | 111 | enum ArchiveError: Runner.Error { 112 | case archiveFailed 113 | 114 | func description(for session: Runner.Session) async -> String { 115 | async let stderr = session.stderr.string 116 | switch self { case .archiveFailed: 117 | return "Archiving failed.\n\n\(await stderr)" 118 | } 119 | } 120 | } 121 | 122 | let runner = Runner(command: "xcodebuild") 123 | let session = runner.run([], stdoutMode: .capture, stderrMode: .capture) 124 | 125 | do { 126 | try await session.throwIfFailed(ArchiveError.archiveFailed) 127 | } 128 | catch let e as Runner.WrappedError { 129 | #expect((e.error as? ArchiveError) == .archiveFailed) 130 | #expect(e.description.contains("Runner does not contain an Xcode project.")) 131 | #expect(await session.stderr.string.contains("Runner does not contain an Xcode project.")) 132 | } 133 | catch { 134 | throw error 135 | } 136 | 137 | #expect( 138 | await session.stdout.string.contains("Command line invocation:")) 139 | } 140 | 141 | #if TEST_REGRESSION 142 | @Test func testRegression() async throws { 143 | let xcode = Runner(command: "xcodebuild") 144 | xcode.cwd = URL(fileURLWithPath: "/Users/sam/Developer/Projects/Stack") 145 | let args = [ 146 | "-workspace", "Stack.xcworkspace", "-scheme", "Stack", "archive", 147 | "-archivePath", 148 | "/Users/sam/Developer/Projects/Stack/.build/macOS/archive.xcarchive", 149 | "-allowProvisioningUpdates", 150 | "INFOPLIST_PREFIX_HEADER=/Users/sam/Developer/Projects/Stack/.build/macOS/VersionInfo.h", 151 | "INFOPLIST_PREPROCESS=YES", "CURRENT_PROJECT_VERSION=25", 152 | ] 153 | 154 | let session = xcode.run(args, stdoutMode: .both, stderrMode: .both) 155 | try await session.throwIfFailed(ArchiveError.archiveFailed) 156 | } 157 | #endif 158 | --------------------------------------------------------------------------------