diff --git a/Sources/Subprocess/CMakeLists.txt b/Sources/Subprocess/CMakeLists.txt index beb34714..b44aa0ff 100644 --- a/Sources/Subprocess/CMakeLists.txt +++ b/Sources/Subprocess/CMakeLists.txt @@ -11,6 +11,7 @@ add_library(Subprocess Execution.swift + ExecutionContext.swift Buffer.swift Error.swift Teardown.swift diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index 86aeae25..fc48b094 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -87,11 +87,36 @@ public struct Configuration: Sendable { ) async throws -> Result ) ) async throws -> ExecutionOutcome { - var spawnResults = try await self.spawn( - withInput: input, - outputPipe: output, - errorPipe: error - ) + // Built before spawning so it can be attached to spawn failures, where + // no resolved values exist yet. + let baseContext = ExecutionContext(self) + + var spawnResults: SpawnResult + #if os(Windows) + // `spawn` is `throws(SubprocessError)` on Windows, so `error` is + // already typed; an `as SubprocessError` pattern is a redundant cast + // that crashes the 6.2 toolchain's SIL ownership verifier. Elsewhere + // `spawn` is untyped `throws`, where the cast is meaningful. + do { + spawnResults = try await self.spawn( + withInput: input, + outputPipe: output, + errorPipe: error + ) + } catch { + throw error.withExecutionContext(baseContext) + } + #else + do { + spawnResults = try await self.spawn( + withInput: input, + outputPipe: output, + errorPipe: error + ) + } catch let error as SubprocessError { + throw error.withExecutionContext(baseContext) + } + #endif let processIdentifier = spawnResults.processIdentifier @@ -100,94 +125,107 @@ public struct Configuration: Sendable { processIdentifier.close() } - return try await withAsyncTaskCleanupHandler { () throws -> ExecutionOutcome in - // The counter coordinates a race between two finishers: the body - // closure and the process-termination monitor. Whichever side - // increments first observes a value of `1` and owns the response; - // the other side sees `2` and stays out of the way. - // - // If the monitor wins, the child has exited while the body is - // still reading or writing. The body might be blocked on a pipe - // that an inherited grandchild keeps open, so the monitor calls - // `cancelAsyncIO` to unblock it. If the body wins, the I/O - // already finished cleanly and no cancellation is needed. - let taskFinishFlag = AtomicCounter() - - // The body's result is moved out through this box rather than - // returned from the task group since a noncopyable `Result` can't be a - // `GroupResult`. The group returns only the (copyable) monitor error. - var resultBox: Swift.Result? = nil - let monitorError = try await withThrowingTaskGroup( - of: SubprocessError?.self, - returning: SubprocessError?.self - ) { group in - group.addTask { - do throws(SubprocessError) { - try await waitForProcessTermination(for: processIdentifier) - if taskFinishFlag.addOne() == 1 { - // The body closure hasn't finished but the child - // process has terminated. Cancel all active - // AsyncIO now. - try AsyncIO.shared.cancelAsyncIO(for: processIdentifier) + // Spawn succeeded, so resolved values are available. This context is + // attached to any error surfacing after spawn. + let executionContext = ExecutionContext( + self, + resolvedExecutable: spawnResults.resolvedExecutable, + resolvedEnvironment: self.environment.resolvedValues(), + resolvedWorkingDirectory: self.workingDirectory ?? currentWorkingDirectory() + ) + + do { + return try await withAsyncTaskCleanupHandler { () throws -> ExecutionOutcome in + // The counter coordinates a race between two finishers: the body + // closure and the process-termination monitor. Whichever side + // increments first observes a value of `1` and owns the response; + // the other side sees `2` and stays out of the way. + // + // If the monitor wins, the child has exited while the body is + // still reading or writing. The body might be blocked on a pipe + // that an inherited grandchild keeps open, so the monitor calls + // `cancelAsyncIO` to unblock it. If the body wins, the I/O + // already finished cleanly and no cancellation is needed. + let taskFinishFlag = AtomicCounter() + + // The body's result is moved out through this box rather than + // returned from the task group since a noncopyable `Result` can't be a + // `GroupResult`. The group returns only the (copyable) monitor error. + var resultBox: Swift.Result? = nil + let monitorError = try await withThrowingTaskGroup( + of: SubprocessError?.self, + returning: SubprocessError?.self + ) { group in + group.addTask { + do throws(SubprocessError) { + try await waitForProcessTermination(for: processIdentifier) + if taskFinishFlag.addOne() == 1 { + // The body closure hasn't finished but the child + // process has terminated. Cancel all active + // AsyncIO now. + try AsyncIO.shared.cancelAsyncIO(for: processIdentifier) + } + return nil + } catch { + try? AsyncIO.shared.cancelAsyncIO(for: processIdentifier) + return error } - return nil - } catch { - try? AsyncIO.shared.cancelAsyncIO(for: processIdentifier) - return error } - } - let inputIO = spawnResults.inputWriteEnd() - let outputIO = spawnResults.outputReadEnd() - let errorIO = spawnResults.errorReadEnd() + let inputIO = spawnResults.inputWriteEnd() + let outputIO = spawnResults.outputReadEnd() + let errorIO = spawnResults.errorReadEnd() - do { - // Body runs in the same isolation. - let bodyResult = try await body(processIdentifier, inputIO, outputIO, errorIO) - taskFinishFlag.addOne() - resultBox = .success(bodyResult) - } catch { - let execution = Execution( - processIdentifier: processIdentifier, - inputWriter: nil, - outputStream: nil, - errorStream: nil - ) - // Attempt to terminate the child process when the body throws - await execution.teardown(using: self.platformOptions.teardownSequence) - resultBox = .failure(error) + do { + // Body runs in the same isolation. + let bodyResult = try await body(processIdentifier, inputIO, outputIO, errorIO) + taskFinishFlag.addOne() + resultBox = .success(bodyResult) + } catch { + let execution = Execution( + processIdentifier: processIdentifier, + inputWriter: nil, + outputStream: nil, + errorStream: nil + ) + // Attempt to terminate the child process when the body throws + await execution.teardown(using: self.platformOptions.teardownSequence) + resultBox = .failure(error) + } + + // Wait for the monitor child task to finish. + return try await group.next() ?? nil } - // Wait for the monitor child task to finish. - return try await group.next() ?? nil - } + // Drop the cancellation marker before reaping the zombie. After + // `reapProcess` runs the kernel can immediately reuse this PID, + // so the marker must be gone first; otherwise a concurrent + // `run()` that happens to inherit the same PID would see the + // stale entry and reject its registrations. + AsyncIO.shared.cleanup(processIdentifier: processIdentifier) - // Drop the cancellation marker before reaping the zombie. After - // `reapProcess` runs the kernel can immediately reuse this PID, - // so the marker must be gone first; otherwise a concurrent - // `run()` that happens to inherit the same PID would see the - // stale entry and reject its registrations. - AsyncIO.shared.cleanup(processIdentifier: processIdentifier) + let terminationStatus = try reapProcess(with: processIdentifier) - let terminationStatus = try reapProcess(with: processIdentifier) + if let monitorError { + throw monitorError + } - if let monitorError { - throw monitorError + return try ExecutionOutcome( + terminationStatus: terminationStatus, + value: resultBox.take()!.get() + ) + } onCleanup: { + let execution = Execution( + processIdentifier: processIdentifier, + inputWriter: nil, + outputStream: nil, + errorStream: nil + ) + // Attempt to terminate the child process. + await execution.teardown(using: self.platformOptions.teardownSequence) } - - return try ExecutionOutcome( - terminationStatus: terminationStatus, - value: resultBox.take()!.get() - ) - } onCleanup: { - let execution = Execution( - processIdentifier: processIdentifier, - inputWriter: nil, - outputStream: nil, - errorStream: nil - ) - // Attempt to terminate the child process. - await execution.teardown(using: self.platformOptions.teardownSequence) + } catch let error as SubprocessError { + throw error.withExecutionContext(executionContext) } } } @@ -662,6 +700,40 @@ extension Environment: CustomStringConvertible, CustomDebugStringConvertible { return results } } + + /// The concrete environment variables passed to the child process, or + /// `nil` if they cannot be represented as a `[Key: String]` map. + /// + /// This mirrors the environment built by the platform spawn path: for + /// `.inherit`, the parent's current values with the configured overrides + /// applied (a `nil` override removes the key); for `.custom`, those values + /// directly; for raw-byte environments, `nil`. On Windows, `PATH` is + /// injected from the parent when absent, matching the spawn path's + /// DLL-resolution behavior. + internal func resolvedValues() -> [Environment.Key: String]? { + var values: [Environment.Key: String] + switch self.config { + case .custom(let customValues): + values = customValues + case .inherit(let overrides): + values = Self.currentEnvironmentValues() + for (key, override) in overrides { + values[key] = override + } + #if !os(Windows) + case .rawBytes: + return nil + #endif + } + #if os(Windows) + if values[.path] == nil, + let parentPath = Self.currentEnvironmentValues()[.path] + { + values[.path] = parentPath + } + #endif + return values + } } extension Environment.Key { @@ -810,6 +882,7 @@ extension Configuration { /// are returned in this value to perform actual reads. internal struct SpawnResult: ~Copyable { let processIdentifier: ProcessIdentifier + let resolvedExecutable: FilePath? var _inputWriteEnd: IODescriptor? var _outputReadEnd: IODescriptor? var _errorReadEnd: IODescriptor? @@ -818,12 +891,14 @@ extension Configuration { processIdentifier: ProcessIdentifier, inputWriteEnd: consuming IODescriptor?, outputReadEnd: consuming IODescriptor?, - errorReadEnd: consuming IODescriptor? + errorReadEnd: consuming IODescriptor?, + resolvedExecutable: FilePath? = nil ) { self.processIdentifier = processIdentifier self._inputWriteEnd = consume inputWriteEnd self._outputReadEnd = consume outputReadEnd self._errorReadEnd = consume errorReadEnd + self.resolvedExecutable = resolvedExecutable } mutating func inputWriteEnd() -> IODescriptor? { @@ -1534,3 +1609,28 @@ extension StringProtocol { } } } + +/// Best-effort lookup of the parent process's current working directory. +/// +/// Returns `nil` on failure; never throws, since it only feeds diagnostics. +internal func currentWorkingDirectory() -> FilePath? { + #if os(Windows) + let length = GetCurrentDirectoryW(0, nil) + guard length > 0 else { return nil } + let path = try? fillNullTerminatedWideStringBuffer( + initialSize: length, + maxSize: DWORD(Int16.max) + ) { + GetCurrentDirectoryW(DWORD($0.count), $0.baseAddress) + } + guard let path else { return nil } + return FilePath(path) + #else + return withUnsafeTemporaryAllocation(of: CChar.self, capacity: Int(PATH_MAX)) { buffer -> FilePath? in + guard getcwd(buffer.baseAddress, buffer.count) != nil else { + return nil + } + return FilePath(platformString: buffer.baseAddress!) + } + #endif +} diff --git a/Sources/Subprocess/Documentation.docc/GettingStarted.md b/Sources/Subprocess/Documentation.docc/GettingStarted.md index 9830af06..8090ce10 100644 --- a/Sources/Subprocess/Documentation.docc/GettingStarted.md +++ b/Sources/Subprocess/Documentation.docc/GettingStarted.md @@ -171,6 +171,8 @@ The `signaled` case exists only on platforms that report signals; on Windows, ev - It throws a ``SubprocessError`` for a problem with the setup or environment — for example, when ``Executable/name(_:)`` finds nothing in `PATH`, or when the collected output exceeds the `limit` you specified. - It rethrows any error your own body closure throws, in the streaming form described in . +When `run` throws a ``SubprocessError``, ``SubprocessError/executionContext`` holds a snapshot of what was being run: the executable, arguments, environment, and working directory you configured, alongside the values Subprocess resolved when it launched the process. Use it to report which command failed without threading that information through your own error handling. + Because a command failure isn't a Swift error, you handle the two concerns in different places — `do`/`catch` for a process that couldn't run, and ``TerminationStatus/isSuccess`` for one that ran and failed: ```swift diff --git a/Sources/Subprocess/Documentation.docc/Subprocess.md b/Sources/Subprocess/Documentation.docc/Subprocess.md index 76894d67..6285b3a5 100644 --- a/Sources/Subprocess/Documentation.docc/Subprocess.md +++ b/Sources/Subprocess/Documentation.docc/Subprocess.md @@ -114,3 +114,4 @@ Subprocess also handles the concerns that surround a running process: ### Handling errors - ``SubprocessError`` +- ``ExecutionContext`` diff --git a/Sources/Subprocess/Documentation.docc/reference/ExecutionContext.md b/Sources/Subprocess/Documentation.docc/reference/ExecutionContext.md new file mode 100644 index 00000000..31e5eefc --- /dev/null +++ b/Sources/Subprocess/Documentation.docc/reference/ExecutionContext.md @@ -0,0 +1,16 @@ +# ``ExecutionContext`` + +## Topics + +### Accessing the configured values + +- ``executable`` +- ``arguments`` +- ``environment`` +- ``workingDirectory`` + +### Accessing the resolved values + +- ``resolvedExecutable`` +- ``resolvedEnvironment`` +- ``resolvedWorkingDirectory`` diff --git a/Sources/Subprocess/Documentation.docc/reference/SubprocessError.md b/Sources/Subprocess/Documentation.docc/reference/SubprocessError.md index e523acb0..d9c6fe1b 100644 --- a/Sources/Subprocess/Documentation.docc/reference/SubprocessError.md +++ b/Sources/Subprocess/Documentation.docc/reference/SubprocessError.md @@ -6,6 +6,7 @@ - ``code`` - ``underlyingError`` +- ``executionContext`` ### Accessing error codes diff --git a/Sources/Subprocess/Error.swift b/Sources/Subprocess/Error.swift index 0683a18f..90783dfb 100644 --- a/Sources/Subprocess/Error.swift +++ b/Sources/Subprocess/Error.swift @@ -44,6 +44,12 @@ public struct SubprocessError: Swift.Error, Sendable, Hashable { public let code: SubprocessError.Code /// The underlying error that caused this error. public let underlyingError: UnderlyingError? + /// A snapshot of the inputs and resolved values for the subprocess that + /// produced this error. + /// + /// This is populated for errors that propagate out of a `run()` call, and + /// is `nil` for errors observed outside of a `run()` call. + public let executionContext: ExecutionContext? /// Context associated with this error for better error messages. private let context: [Code: Context] @@ -287,6 +293,7 @@ extension SubprocessError { return SubprocessError( code: .executableNotFound, underlyingError: underlyingError, + executionContext: nil, context: [.executableNotFound: .string(executable)] ) } @@ -295,6 +302,7 @@ extension SubprocessError { return SubprocessError( code: .failedToMonitorProcess, underlyingError: underlyingError, + executionContext: nil, context: [:] ) } @@ -303,6 +311,7 @@ extension SubprocessError { return SubprocessError( code: .processControlFailed, underlyingError: underlyingError, + executionContext: nil, context: [.processControlFailed: .processControlOperation(operation)] ) } @@ -311,6 +320,7 @@ extension SubprocessError { return SubprocessError( code: .spawnFailed, underlyingError: nil, + executionContext: nil, context: [:] ) } @@ -326,6 +336,7 @@ extension SubprocessError { return SubprocessError( code: .spawnFailed, underlyingError: underlyingError, + executionContext: nil, context: context ) } @@ -334,6 +345,7 @@ extension SubprocessError { return SubprocessError( code: .outputLimitExceeded, underlyingError: nil, + executionContext: nil, context: [ .outputLimitExceeded: .int(limit) ] @@ -347,6 +359,7 @@ extension SubprocessError { return SubprocessError( code: .asyncIOFailed, underlyingError: underlyingError, + executionContext: nil, context: [.asyncIOFailed: .string(reason)] ) } @@ -357,6 +370,7 @@ extension SubprocessError { return SubprocessError( code: .failedToReadFromSubprocess, underlyingError: underlyingError, + executionContext: nil, context: [:] ) } @@ -372,6 +386,7 @@ extension SubprocessError { return SubprocessError( code: .failedToWriteToSubprocess, underlyingError: underlyingError, + executionContext: nil, context: context ) } @@ -403,7 +418,30 @@ extension SubprocessError { return SubprocessError( code: .failedToChangeWorkingDirectory, underlyingError: underlyingError, + executionContext: nil, context: context ) } } + +// MARK: - Execution Context +extension SubprocessError { + /// Returns a copy of this error with `executionContext` attached. + /// + /// Attachment is idempotent: if the error already carries an + /// `ExecutionContext`, or if `executionContext` is `nil`, the error is + /// returned unchanged. + internal func withExecutionContext( + _ executionContext: ExecutionContext? + ) -> SubprocessError { + guard self.executionContext == nil, let executionContext else { + return self + } + return SubprocessError( + code: self.code, + underlyingError: self.underlyingError, + executionContext: executionContext, + context: self.context + ) + } +} diff --git a/Sources/Subprocess/ExecutionContext.swift b/Sources/Subprocess/ExecutionContext.swift new file mode 100644 index 00000000..bc7a3ee7 --- /dev/null +++ b/Sources/Subprocess/ExecutionContext.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if canImport(System) +public import System +#else +public import SystemPackage +#endif + +/// A snapshot of the inputs and resolved values associated with a +/// `SubprocessError`. +/// +/// `ExecutionContext` records two families of values: +/// +/// - The values configured by the caller: ``executable``, ``arguments``, +/// ``environment``, and ``workingDirectory``, reflecting the inputs exactly +/// as supplied. For example, if ``Environment/inherit`` was used, +/// ``environment`` is `.inherit`, not the calling process's variables. +/// - The values the library resolved at launch time: ``resolvedExecutable``, +/// ``resolvedEnvironment``, and ``resolvedWorkingDirectory``. Each is +/// best-effort and may be `nil` when the value was unavailable, such as for +/// an error thrown before the process launched. +/// +/// `ExecutionContext` is attached to every ``SubprocessError`` that propagates +/// out of a `run()` call, including errors originating inside a custom `body` +/// closure (from ``StandardInputWriter``, ``SubprocessOutputSequence``, or a +/// collected output type). ``SubprocessError/executionContext`` is `nil` for +/// errors observed outside of a `run()` call. +public struct ExecutionContext: Sendable, Hashable { + /// The executable that was configured to run. + public let executable: Executable + /// The arguments that were configured to be passed to the executable. + public let arguments: Arguments + /// The environment that was configured for the executable. + public let environment: Environment + /// The working directory that was configured for the executable, or `nil` + /// if the subprocess was configured to inherit the calling process's + /// working directory. + public let workingDirectory: FilePath? + /// The path used to launch the executable, or `nil` if undetermined. + /// + /// For ``Executable/path(_:)`` this is the caller-provided path. For + /// ``Executable/name(_:)`` this is the candidate that launched successfully + /// after searching `PATH`. This is normally absolute; it may not be + /// absolute when it is found relative to the working directory. On Windows + /// it may be `nil` for a name-based executable, because `CreateProcessW()` + /// searches internally without reporting the path it used. + public let resolvedExecutable: FilePath? + /// The environment the subprocess was given, or `nil` if unavailable. + /// + /// This is `nil` for errors thrown before the process launched, and for + /// environments configured with raw bytes, which have no faithful `String` + /// representation. On Windows, this may include a `PATH` entry that + /// `Subprocess` added so the subprocess can locate its DLLs, even when the + /// caller did not supply one. + public let resolvedEnvironment: [Environment.Key: String]? + /// The subprocess's working directory. + /// + /// When the caller passed `nil`, this is the calling process's working + /// directory captured at launch, or `nil` if undetermined. + public let resolvedWorkingDirectory: FilePath? + + internal init( + _ configuration: Configuration, + resolvedExecutable: FilePath? = nil, + resolvedEnvironment: [Environment.Key: String]? = nil, + resolvedWorkingDirectory: FilePath? = nil + ) { + self.executable = configuration.executable + self.arguments = configuration.arguments + self.environment = configuration.environment + self.workingDirectory = configuration.workingDirectory + self.resolvedExecutable = resolvedExecutable + self.resolvedEnvironment = resolvedEnvironment + self.resolvedWorkingDirectory = resolvedWorkingDirectory + } +} + +extension ExecutionContext: CustomStringConvertible, CustomDebugStringConvertible { + /// A textual representation of this execution context. + public var description: String { + return """ + ExecutionContext( + executable: \(self.executable.description), + arguments: \(self.arguments.description), + environment: \(self.environment.description), + workingDirectory: \(self.workingDirectory?.string ?? ""), + resolvedExecutable: \(self.resolvedExecutable?.string ?? "nil"), + resolvedWorkingDirectory: \(self.resolvedWorkingDirectory?.string ?? "nil"), + resolvedEnvironment: \(self.resolvedEnvironment.map { "\($0.count) variable\($0.count > 1 ? "s" : "")" } ?? "nil") + ) + """ + } + + /// A debug-oriented textual representation of this execution context. + public var debugDescription: String { + return description + } +} diff --git a/Sources/Subprocess/Platforms/Subprocess+Darwin.swift b/Sources/Subprocess/Platforms/Subprocess+Darwin.swift index 08a20fa8..b752d9a7 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Darwin.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Darwin.swift @@ -484,7 +484,8 @@ extension Configuration { processIdentifier: .init(value: pid), inputWriteEnd: inputWriteFileDescriptor, outputReadEnd: outputReadFileDescriptor, - errorReadEnd: errorReadFileDescriptor + errorReadEnd: errorReadFileDescriptor, + resolvedExecutable: FilePath(possibleExecutablePath) ) } diff --git a/Sources/Subprocess/Platforms/Subprocess+Unix.swift b/Sources/Subprocess/Platforms/Subprocess+Unix.swift index 792840c0..23a90b54 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -715,7 +715,8 @@ extension Configuration { processIdentifier: processIdentifier, inputWriteEnd: inputWriteFileDescriptor, outputReadEnd: outputReadFileDescriptor, - errorReadEnd: errorReadFileDescriptor + errorReadEnd: errorReadFileDescriptor, + resolvedExecutable: FilePath(possibleExecutablePath) ) } diff --git a/Sources/Subprocess/Platforms/Subprocess+Windows.swift b/Sources/Subprocess/Platforms/Subprocess+Windows.swift index ae003be5..6793762a 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -329,11 +329,23 @@ extension Configuration { throw error } + let resolvedExecutable: FilePath? + switch self.executable.storage { + case .path(let path): + resolvedExecutable = path + case .executable: + // `nil` on the fast path, where `applicationName` is `nil` and + // `CreateProcessW` searched via `lpCommandLine`; the matched + // candidate on the arg0-override path. + resolvedExecutable = applicationName.map { FilePath($0) } + } + return SpawnResult( processIdentifier: pid, inputWriteEnd: inputWriteFileDescriptor, outputReadEnd: outputReadFileDescriptor, - errorReadEnd: errorReadFileDescriptor + errorReadEnd: errorReadFileDescriptor, + resolvedExecutable: resolvedExecutable ) } diff --git a/Tests/SubprocessTests/ExecutionContextTests.swift b/Tests/SubprocessTests/ExecutionContextTests.swift new file mode 100644 index 00000000..5280eb34 --- /dev/null +++ b/Tests/SubprocessTests/ExecutionContextTests.swift @@ -0,0 +1,175 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// +//===----------------------------------------------------------------------===// + +#if canImport(System) +import System +#else +import SystemPackage +#endif + +import Testing +@testable import Subprocess + +@Suite("ExecutionContext Unit Tests", .serialized) +struct ExecutionContextTests {} + +// MARK: - General +extension ExecutionContextTests { + /// Once an error has an `ExecutionContext` attached, a subsequent attempt + /// to attach a different context must be a no-op. This guarantees that the + /// inner I/O wraps (which have the most specific information) win over the + /// outer wrap in `Configuration.run(input:output:error:_:)`. + @Test func testWithExecutionContextIsIdempotent() { + let firstConfig = Configuration( + executable: .name("first"), + arguments: ["a"], + environment: .custom(["K": "1"]), + workingDirectory: FilePath("/tmp/first") + ) + let secondConfig = Configuration( + executable: .name("second"), + arguments: ["b"], + environment: .custom(["K": "2"]), + workingDirectory: FilePath("/tmp/second") + ) + + let firstContext = ExecutionContext(firstConfig) + let secondContext = ExecutionContext(secondConfig) + + let baseError: SubprocessError = .spawnFailed + #expect(baseError.executionContext == nil) + + let firstAttach = baseError.withExecutionContext(firstContext) + #expect(firstAttach.executionContext == firstContext) + + let secondAttach = firstAttach.withExecutionContext(secondContext) + #expect(secondAttach.executionContext == firstContext) + } + + /// Attaching `nil` must never overwrite an existing context, and + /// must leave a context-less error context-less. + @Test func testWithExecutionContextNilIsNoOp() { + let config = Configuration(executable: .name("test")) + let context = ExecutionContext(config) + + let baseError: SubprocessError = .spawnFailed + let attached = baseError.withExecutionContext(context) + + #expect(baseError.withExecutionContext(nil).executionContext == nil) + #expect(attached.withExecutionContext(nil).executionContext == context) + } + + @Test func testExecutionContextRoundTripsConfigurationFields() { + let config = Configuration( + executable: .path("/usr/bin/example"), + arguments: ["--flag", "value"], + environment: .custom(["FOO": "bar"]), + workingDirectory: FilePath("/var/tmp") + ) + + let context = ExecutionContext(config) + + #expect(context.executable == config.executable) + #expect(context.arguments == config.arguments) + #expect(context.environment == config.environment) + #expect(context.workingDirectory == config.workingDirectory) + } + + @Test func testExecutionContextResolvedValuesDefaultToNil() { + let context = ExecutionContext(Configuration(executable: .name("x"))) + #expect(context.resolvedExecutable == nil) + #expect(context.resolvedEnvironment == nil) + #expect(context.resolvedWorkingDirectory == nil) + } + + @Test func testExecutionContextStoresResolvedValues() { + let config = Configuration( + executable: .path("/usr/bin/example"), + arguments: ["--flag"], + environment: .custom(["FOO": "bar"]), + workingDirectory: FilePath("/var/tmp") + ) + let context = ExecutionContext( + config, + resolvedExecutable: FilePath("/usr/bin/example"), + resolvedEnvironment: ["FOO": "bar"], + resolvedWorkingDirectory: FilePath("/var/tmp") + ) + #expect(context.resolvedExecutable == FilePath("/usr/bin/example")) + #expect(context.resolvedEnvironment == ["FOO": "bar"]) + #expect(context.resolvedWorkingDirectory == FilePath("/var/tmp")) + } + + @Test func testExecutionContextEqualityIncludesResolvedValues() { + let config = Configuration(executable: .name("x")) + let a = ExecutionContext(config, resolvedExecutable: FilePath("/a")) + let b = ExecutionContext(config, resolvedExecutable: FilePath("/b")) + #expect(a != b) + } +} + +// MARK: - resolvedValues() +extension ExecutionContextTests { + @Test func testResolvedValuesForCustomEnvironment() { + // `PATH` is included so the Windows injection is a no-op and the + // result is identical across platforms. + let values: [Environment.Key: String] = ["A": "1", "PATH": "/custom"] + #expect(Environment.custom(values).resolvedValues() == values) + } + + @Test func testResolvedValuesForInheritedEnvironment() { + #expect(Environment.inherit.resolvedValues() == Environment.currentEnvironmentValues()) + } + + @Test func testResolvedValuesAppliesInheritOverrides() throws { + let current = Environment.currentEnvironmentValues() + let addedKey: Environment.Key = "SUBPROCESS_RESOLVED_VALUES_ADDED" + try #require(current[addedKey] == nil) + // Avoid `PATH` so the Windows injection doesn't interfere with removal. + let existingKey = try #require(current.keys.first { $0 != .path }) + + let updated = Environment.inherit.updating([ + addedKey: "added", + existingKey: "overridden", + ]) + let resolved = try #require(updated.resolvedValues()) + #expect(resolved[addedKey] == "added") + #expect(resolved[existingKey] == "overridden") + + let removed = Environment.inherit.updating([existingKey: nil]) + let resolvedAfterRemoval = try #require(removed.resolvedValues()) + #expect(resolvedAfterRemoval[existingKey] == nil) + #expect(resolvedAfterRemoval.count == current.count - 1) + } + + #if !os(Windows) + @Test func testResolvedValuesForRawBytesEnvironmentIsNil() { + #expect(Environment.custom([Array("A=1".utf8)]).resolvedValues() == nil) + } + #endif + + #if os(Windows) + @Test func testResolvedValuesInjectsPathOnWindows() throws { + let resolved = try #require(Environment.custom(["A": "1"]).resolvedValues()) + #expect(resolved["A"] == "1") + let parentPath = try #require(Environment.currentEnvironmentValues()[.path]) + #expect(resolved[.path] == parentPath) + } + #endif +} + +// MARK: - currentWorkingDirectory() +extension ExecutionContextTests { + @Test func testCurrentWorkingDirectoryIsAbsolute() throws { + let cwd = try #require(currentWorkingDirectory()) + #expect(cwd.isAbsolute) + } +} diff --git a/Tests/SubprocessTests/IntegrationTests.swift b/Tests/SubprocessTests/IntegrationTests.swift index a4ed5a68..7276b608 100644 --- a/Tests/SubprocessTests/IntegrationTests.swift +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -78,18 +78,18 @@ extension SubprocessIntegrationTests { @Test func testExecutableNamedCannotResolve() async throws { #if os(Windows) - let underlying = SubprocessError.WindowsError(win32Error: DWORD(ERROR_FILE_NOT_FOUND)) + let expectedUnderlying = SubprocessError.WindowsError(win32Error: DWORD(ERROR_FILE_NOT_FOUND)) #else - let underlying = Errno(rawValue: ENOENT) + let expectedUnderlying = Errno(rawValue: ENOENT) #endif - let expectedError: SubprocessError = .executableNotFound( - "do-not-exist", underlyingError: underlying - ) - - await #expect(throws: expectedError) { + let error = try await #require(throws: SubprocessError.self) { _ = try await Subprocess.run(.name("do-not-exist"), output: .discarded) } + + #expect(error.code == .executableNotFound) + #expect(error.underlyingError == expectedUnderlying) + #expect(error.description.contains("do-not-exist")) } /// `name(_:)` takes a name to look up on `PATH`, so a value that names a @@ -155,21 +155,21 @@ extension SubprocessIntegrationTests { @Test func testExecutableAtPathCannotResolve() async throws { #if os(Windows) let fakePath = FilePath("D:\\does\\not\\exist") - let underlying = SubprocessError.WindowsError( + let expectedUnderlying = SubprocessError.WindowsError( win32Error: DWORD(ERROR_FILE_NOT_FOUND) ) #else let fakePath = FilePath("/usr/bin/do-not-exist") - let underlying = Errno(rawValue: ENOENT) + let expectedUnderlying = Errno(rawValue: ENOENT) #endif - let expectedError: SubprocessError = .executableNotFound( - fakePath.string, underlyingError: underlying - ) - - await #expect(throws: expectedError) { + let error = try await #require(throws: SubprocessError.self) { _ = try await Subprocess.run(.path(fakePath), output: .discarded) } + + #expect(error.code == .executableNotFound) + #expect(error.underlyingError == expectedUnderlying) + #expect(error.description.contains(fakePath.string)) } #if !os(Windows) @@ -752,10 +752,7 @@ extension SubprocessIntegrationTests { arguments: ["/c", "cd"], workingDirectory: invalidPath ) - let underlying = SubprocessError.WindowsError(win32Error: DWORD(ERROR_DIRECTORY)) - let expectedError: SubprocessError = .failedToChangeWorkingDirectory( - #"X:\Does\Not\Exist"#, underlyingError: underlying - ) + let expectedUnderlying = SubprocessError.WindowsError(win32Error: DWORD(ERROR_DIRECTORY)) #else let invalidPath: FilePath = FilePath("/does/not/exist") let setup = TestSetup( @@ -763,15 +760,16 @@ extension SubprocessIntegrationTests { arguments: [], workingDirectory: invalidPath ) - let underlying = Errno(rawValue: ENOENT) - let expectedError: SubprocessError = .failedToChangeWorkingDirectory( - "/does/not/exist", underlyingError: underlying - ) + let expectedUnderlying = Errno(rawValue: ENOENT) #endif - await #expect(throws: expectedError) { + let error = try await #require(throws: SubprocessError.self) { _ = try await _run(setup, input: .none, output: .string(limit: .max), error: .discarded) } + + #expect(error.code == .failedToChangeWorkingDirectory) + #expect(error.underlyingError == expectedUnderlying) + #expect(error.description.contains(invalidPath.string)) } } @@ -1203,7 +1201,7 @@ extension SubprocessIntegrationTests { ) #endif - await #expect(throws: SubprocessError.outputLimitExceeded(limit: 16)) { + let error = try await #require(throws: SubprocessError.self) { _ = try await _run( setup, input: .none, @@ -1211,6 +1209,9 @@ extension SubprocessIntegrationTests { error: .discarded ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "The process's output exceeded the limit of 16 bytes.") } #if SubprocessFoundation @@ -1262,7 +1263,7 @@ extension SubprocessIntegrationTests { ) #endif - await #expect(throws: SubprocessError.outputLimitExceeded(limit: 16)) { + let error = try await #require(throws: SubprocessError.self) { _ = try await _run( setup, input: .none, @@ -1270,6 +1271,9 @@ extension SubprocessIntegrationTests { error: .discarded ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "The process's output exceeded the limit of 16 bytes.") } @Test func testFileDescriptorOutput() async throws { @@ -1407,7 +1411,7 @@ extension SubprocessIntegrationTests { ) #endif - await #expect(throws: SubprocessError.outputLimitExceeded(limit: 16)) { + let error = try await #require(throws: SubprocessError.self) { _ = try await _run( setup, input: .none, @@ -1415,6 +1419,9 @@ extension SubprocessIntegrationTests { error: .discarded ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "The process's output exceeded the limit of 16 bytes.") } #endif @@ -1471,7 +1478,7 @@ extension SubprocessIntegrationTests { ) #endif - await #expect(throws: SubprocessError.outputLimitExceeded(limit: 16)) { + let error = try await #require(throws: SubprocessError.self) { _ = try await _run( setup, input: .none, @@ -1479,6 +1486,9 @@ extension SubprocessIntegrationTests { error: .string(limit: 16) ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "The process's output exceeded the limit of 16 bytes.") } #if SubprocessFoundation @@ -1529,7 +1539,7 @@ extension SubprocessIntegrationTests { ) #endif - await #expect(throws: SubprocessError.outputLimitExceeded(limit: 16)) { + let error = try await #require(throws: SubprocessError.self) { _ = try await _run( setup, input: .none, @@ -1537,6 +1547,9 @@ extension SubprocessIntegrationTests { error: .bytes(limit: 16) ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "The process's output exceeded the limit of 16 bytes.") } @Test func testFileDescriptorErrorOutput() async throws { @@ -1721,7 +1734,7 @@ extension SubprocessIntegrationTests { ) #endif - await #expect(throws: SubprocessError.outputLimitExceeded(limit: 16)) { + let error = try await #require(throws: SubprocessError.self) { _ = try await _run( setup, input: .none, @@ -1729,6 +1742,9 @@ extension SubprocessIntegrationTests { error: .data(limit: 16) ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "The process's output exceeded the limit of 16 bytes.") } #endif @@ -2807,6 +2823,209 @@ extension SubprocessIntegrationTests { } #endif // !os(Windows) +// MARK: - ExecutionContext Tests +extension SubprocessIntegrationTests { + @Test func testExecutionContextAttachedOnExecutableNotFound() async throws { + let setup = TestSetup( + executable: .name("do-not-exist"), + arguments: ["arg1", "arg2"], + environment: .custom(["TEST_KEY": "test_value"]), + workingDirectory: FilePath( + FileManager.default.temporaryDirectory._fileSystemPath + ) + ) + + let error = try await #require(throws: SubprocessError.self) { + _ = try await _run( + setup, + input: .none, + output: .discarded, + error: .discarded + ) + } + + #expect(error.code == .executableNotFound) + + let context = try #require(error.executionContext) + #expect(context.executable == setup.executable) + #expect(context.arguments == setup.arguments) + #expect(context.environment == setup.environment) + #expect(context.workingDirectory == setup.workingDirectory) + #expect(context.resolvedExecutable == nil) + #expect(context.resolvedEnvironment == nil) + #expect(context.resolvedWorkingDirectory == nil) + } + + @Test func testExecutionContextAttachedOnInvalidWorkingDirectory() async throws { + #if os(Windows) + let invalidPath: FilePath = FilePath(#"X:\Does\Not\Exist"#) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: ["/c", "cd"], + workingDirectory: invalidPath + ) + #else + let invalidPath: FilePath = FilePath("/does/not/exist") + let setup = TestSetup( + executable: .path("/bin/pwd"), + arguments: [], + workingDirectory: invalidPath + ) + #endif + + let error = try await #require(throws: SubprocessError.self) { + _ = try await _run( + setup, + input: .none, + output: .discarded, + error: .discarded + ) + } + + #expect(error.code == .failedToChangeWorkingDirectory) + + let context = try #require(error.executionContext) + #expect(context.executable == setup.executable) + #expect(context.arguments == setup.arguments) + #expect(context.environment == setup.environment) + #expect(context.workingDirectory == setup.workingDirectory) + #expect(context.resolvedExecutable == nil) + #expect(context.resolvedEnvironment == nil) + #expect(context.resolvedWorkingDirectory == nil) + } + + @Test func testExecutionContextAttachedOnOutputLimitExceeded() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: [ + "/c", + "findstr x*", + theMysteriousIsland.string, + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [theMysteriousIsland.string] + ) + #endif + + let error = try await #require(throws: SubprocessError.self) { + _ = try await _run( + setup, + input: .none, + output: .string(limit: 16), + error: .discarded + ) + } + + #expect(error.code == .outputLimitExceeded) + + let context = try #require(error.executionContext) + #expect(context.executable == setup.executable) + #expect(context.arguments == setup.arguments) + #expect(context.environment == setup.environment) + #expect(context.workingDirectory == setup.workingDirectory) + } + + @Test func testResolvedValuesAttachedOnOutputLimitExceeded() async throws { + let key: Environment.Key = "SUBPROCESS_RESOLVED_ENV_TEST" + let environment: Environment = .inherit.updating([key: "present"]) + let workingDirectory = FilePath(FileManager.default.temporaryDirectory._fileSystemPath) + + #if os(Windows) + let comspec = try #require(Environment.currentEnvironmentValues()["ComSpec"]) + let setup = TestSetup( + executable: .path(FilePath(comspec)), + arguments: ["/c", "findstr x*", theMysteriousIsland.string], + environment: environment, + workingDirectory: workingDirectory + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [theMysteriousIsland.string], + environment: environment, + workingDirectory: workingDirectory + ) + #endif + + let error = try await #require(throws: SubprocessError.self) { + _ = try await _run(setup, input: .none, output: .string(limit: 16), error: .discarded) + } + #expect(error.code == .outputLimitExceeded) + + let context = try #require(error.executionContext) + + // `.path` echoes the configured path verbatim on both platforms. + #if os(Windows) + #expect(context.resolvedExecutable == FilePath(comspec)) + #else + #expect(context.resolvedExecutable == FilePath("/bin/cat")) + #endif + + #expect(context.resolvedWorkingDirectory == workingDirectory) + + let resolvedEnvironment = try #require(context.resolvedEnvironment) + #expect(resolvedEnvironment[key] == "present") + #expect(resolvedEnvironment[.path] != nil) + } + + @Test func testResolvedExecutableForNameBasedExecutable() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: ["/c", "findstr x*", theMysteriousIsland.string] + ) + #else + let setup = TestSetup( + executable: .name("cat"), + arguments: [theMysteriousIsland.string] + ) + #endif + + let error = try await #require(throws: SubprocessError.self) { + _ = try await _run(setup, input: .none, output: .string(limit: 16), error: .discarded) + } + #expect(error.code == .outputLimitExceeded) + + let context = try #require(error.executionContext) + + #if os(Windows) + // `CreateProcessW()` searches internally, so the path is unavailable. + #expect(context.resolvedExecutable == nil) + #else + let resolved = try #require(context.resolvedExecutable) + #expect(resolved.isAbsolute) + #expect(resolved.lastComponent?.string == "cat") + #endif + } + + @Test func testResolvedWorkingDirectoryWhenInherited() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("cmd.exe"), + arguments: ["/c", "findstr x*", theMysteriousIsland.string] + ) + #else + let setup = TestSetup( + executable: .path("/bin/cat"), + arguments: [theMysteriousIsland.string] + ) + #endif + + let error = try await #require(throws: SubprocessError.self) { + _ = try await _run(setup, input: .none, output: .string(limit: 16), error: .discarded) + } + #expect(error.code == .outputLimitExceeded) + + let context = try #require(error.executionContext) + let resolved = try #require(context.resolvedWorkingDirectory) + #expect(resolved == currentWorkingDirectory()) + } +} + // MARK: - Other Tests extension SubprocessIntegrationTests { @Test func testTerminateProcess() async throws { diff --git a/Tests/SubprocessTests/ProcessMonitoringTests.swift b/Tests/SubprocessTests/ProcessMonitoringTests.swift index 5d6162d9..d0a64d16 100644 --- a/Tests/SubprocessTests/ProcessMonitoringTests.swift +++ b/Tests/SubprocessTests/ProcessMonitoringTests.swift @@ -224,9 +224,12 @@ extension SubprocessProcessMonitoringTests { let expectedError: SubprocessError = .failedToMonitor(withUnderlyingError: underlying) - await #expect(throws: expectedError) { + let error = try await #require(throws: SubprocessError.self) { _ = try await monitorProcessTermination(for: processIdentifier) } + + #expect(error == expectedError) + #expect(error.executionContext == nil) } @Test(.timeLimit(.minutes(1))) diff --git a/Tests/SubprocessTests/UnixTests.swift b/Tests/SubprocessTests/UnixTests.swift index 60bfb671..e974b778 100644 --- a/Tests/SubprocessTests/UnixTests.swift +++ b/Tests/SubprocessTests/UnixTests.swift @@ -1210,54 +1210,53 @@ extension SubprocessUnixTests { } @Test func testRejectInvalidEnvironment() async throws { - func _runTest(withEnvironment environment: Environment, errorReason: String) async { - let expectedError: SubprocessError = .spawnFailed( - withUnderlyingError: nil, - reason: errorReason - ) - - await #expect(throws: expectedError) { + func _runTest(withEnvironment environment: Environment, errorReason: String) async throws { + let error: SubprocessError = try await #require(throws: SubprocessError.self) { _ = try await Subprocess.run( .path("/bin/echo"), environment: environment, output: .discarded ) } + + #expect(error.code == .spawnFailed) + #expect(error.underlyingError == nil) + #expect(error.description.contains(errorReason)) } - await _runTest( + try await _runTest( withEnvironment: .inherit.updating(["key=": "value"]), errorReason: "Environment key 'key=' must not contain '=' or null bytes." ) - await _runTest( + try await _runTest( withEnvironment: .inherit.updating(["key\0": "value"]), errorReason: "Environment key 'key\0' must not contain '=' or null bytes." ) - await _runTest( + try await _runTest( withEnvironment: .inherit.updating(["0key": "value"]), errorReason: "Environment key '0key' must not begin with a digit." ) - await _runTest( + try await _runTest( withEnvironment: .inherit.updating(["key": "value\0"]), errorReason: "Environment value 'value\0' must not contain null bytes." ) // Raw bytes: a trailing null terminator is allowed, but an embedded // null byte must be rejected since `strdup` would silently truncate it. - await _runTest( + try await _runTest( withEnvironment: .custom([Array("key=va\0lue".utf8)]), errorReason: "Environment entry 'key=va\0lue' must not contain null bytes." ) - await _runTest( + try await _runTest( withEnvironment: .custom([Array("keyvalue\0".utf8)]), errorReason: "Environment entry 'keyvalue' must contain '='." ) - await _runTest( + try await _runTest( withEnvironment: .custom([Array("0key=value\0".utf8)]), errorReason: "Environment key '0key' must not begin with a digit." ) diff --git a/Tests/SubprocessTests/WindowsTests.swift b/Tests/SubprocessTests/WindowsTests.swift index bf65aad4..7eedcea9 100644 --- a/Tests/SubprocessTests/WindowsTests.swift +++ b/Tests/SubprocessTests/WindowsTests.swift @@ -588,39 +588,38 @@ extension SubprocessWindowsTests { } @Test func testRejectInvalidEnvironment() async throws { - func _runTest(withEnvironment environment: Environment, errorReason: String) async { - let expectedError: SubprocessError = .spawnFailed( - withUnderlyingError: nil, - reason: errorReason - ) - - await #expect(throws: expectedError) { + func _runTest(withEnvironment environment: Environment, errorReason: String) async throws { + let error = try await #require(throws: SubprocessError.self) { _ = try await Subprocess.run( self.cmdExe, environment: environment, output: .discarded ) } + + #expect(error.code == .spawnFailed) + #expect(error.underlyingError == nil) + #expect(error.description.contains(errorReason)) } - await _runTest( + try await _runTest( withEnvironment: .inherit.updating(["key=": "value"]), errorReason: "Environment key 'key=' must not contain '='." ) - await _runTest( + try await _runTest( withEnvironment: .inherit.updating(["key\0": "value"]), errorReason: "Environment key 'key\0' must not contain null bytes." ) - await _runTest( + try await _runTest( withEnvironment: .inherit.updating(["key": "value\0"]), errorReason: "Environment value 'value\0' must not contain null bytes." ) let longKey = randomString(length: 32767 / 2 + 1) let longValue = randomString(length: 32767 / 2 + 1) - await _runTest( + try await _runTest( withEnvironment: .inherit.updating([.init(longKey): longValue]), errorReason: "Environment key and value pair must not exceed 32,767 characters." )