├── .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 |
--------------------------------------------------------------------------------