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 b6ab67d2..75b2b2d3 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -82,11 +82,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 @@ -95,92 +120,105 @@ 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() - - let (result, monitorError) = try await withThrowingTaskGroup( - of: SubprocessError?.self, - returning: (Swift.Result, 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() + + let (result, monitorError) = try await withThrowingTaskGroup( + of: SubprocessError?.self, + returning: (Swift.Result, 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() - let result: Swift.Result - do { - // Body runs in the same isolation. - let bodyResult = try await body(processIdentifier, inputIO, outputIO, errorIO) - taskFinishFlag.addOne() - result = .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) - result = .failure(error) + let result: Swift.Result + do { + // Body runs in the same isolation. + let bodyResult = try await body(processIdentifier, inputIO, outputIO, errorIO) + taskFinishFlag.addOne() + result = .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) + result = .failure(error) + } + + // Wait for the monitor child task to finish. + let monitorError = try await group.next() ?? nil + return (result, monitorError) } - // Wait for the monitor child task to finish. - let monitorError = try await group.next() ?? nil - return (result, monitorError) - } + // 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: result.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: result.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) } } } @@ -573,6 +611,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 { @@ -697,6 +769,7 @@ extension Configuration { /// via `SpawnResult` to perform actual reads internal struct SpawnResult: ~Copyable { let processIdentifier: ProcessIdentifier + let resolvedExecutable: FilePath? var _inputWriteEnd: IODescriptor? var _outputReadEnd: IODescriptor? var _errorReadEnd: IODescriptor? @@ -705,12 +778,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? { @@ -1346,3 +1421,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/Error.swift b/Sources/Subprocess/Error.swift index cd1d8e4d..8127186d 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 message private let context: [Code: Context] @@ -278,6 +284,7 @@ extension SubprocessError { return SubprocessError( code: .executableNotFound, underlyingError: underlyingError, + executionContext: nil, context: [.executableNotFound: .string(executable)] ) } @@ -286,6 +293,7 @@ extension SubprocessError { return SubprocessError( code: .failedToMonitorProcess, underlyingError: underlyingError, + executionContext: nil, context: [:] ) } @@ -294,6 +302,7 @@ extension SubprocessError { return SubprocessError( code: .processControlFailed, underlyingError: underlyingError, + executionContext: nil, context: [.processControlFailed: .processControlOperation(operation)] ) } @@ -302,6 +311,7 @@ extension SubprocessError { return SubprocessError( code: .spawnFailed, underlyingError: nil, + executionContext: nil, context: [:] ) } @@ -317,6 +327,7 @@ extension SubprocessError { return SubprocessError( code: .spawnFailed, underlyingError: underlyingError, + executionContext: nil, context: context ) } @@ -325,6 +336,7 @@ extension SubprocessError { return SubprocessError( code: .outputLimitExceeded, underlyingError: nil, + executionContext: nil, context: [ .outputLimitExceeded: .int(limit) ] @@ -338,6 +350,7 @@ extension SubprocessError { return SubprocessError( code: .asyncIOFailed, underlyingError: underlyingError, + executionContext: nil, context: [.asyncIOFailed: .string(reason)] ) } @@ -348,6 +361,7 @@ extension SubprocessError { return SubprocessError( code: .failedToReadFromSubprocess, underlyingError: underlyingError, + executionContext: nil, context: [:] ) } @@ -358,6 +372,7 @@ extension SubprocessError { return SubprocessError( code: .failedToWriteToSubprocess, underlyingError: underlyingError, + executionContext: nil, context: [:] ) } @@ -373,7 +388,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..b150e432 --- /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 parent's variables. +/// - The values the library resolved at spawn 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 spawned. +/// +/// `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 parent 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 child process was given, or `nil` if unavailable. + /// + /// This is `nil` for errors thrown before the process spawned, 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 child can locate its DLLs, even when the + /// caller did not supply one. + public let resolvedEnvironment: [Environment.Key: String]? + /// The child's working directory. + /// + /// When the caller passed `nil`, this is the parent's working directory + /// captured at spawn, 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 b950f44f..630afb47 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Darwin.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Darwin.swift @@ -479,7 +479,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 b123a0df..cba1c8d7 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Unix.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Unix.swift @@ -574,7 +574,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 e511652a..8a184091 100644 --- a/Sources/Subprocess/Platforms/Subprocess+Windows.swift +++ b/Sources/Subprocess/Platforms/Subprocess+Windows.swift @@ -282,11 +282,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 0f74d72a..f593e98d 100644 --- a/Tests/SubprocessTests/IntegrationTests.swift +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -79,18 +79,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")) } @Test func testExecutableAtPath() async throws { @@ -125,21 +125,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) @@ -732,10 +732,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( @@ -743,15 +740,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)) } } @@ -1146,7 +1144,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, @@ -1154,6 +1152,9 @@ extension SubprocessIntegrationTests { error: .discarded ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } #if SubprocessFoundation @@ -1205,7 +1206,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, @@ -1213,6 +1214,9 @@ extension SubprocessIntegrationTests { error: .discarded ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } @Test func testFileDescriptorOutput() async throws { @@ -1350,7 +1354,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, @@ -1358,6 +1362,9 @@ extension SubprocessIntegrationTests { error: .discarded ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } #endif @@ -1416,7 +1423,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, @@ -1424,6 +1431,9 @@ extension SubprocessIntegrationTests { error: .string(limit: 16) ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } #if SubprocessFoundation @@ -1474,7 +1484,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, @@ -1482,6 +1492,9 @@ extension SubprocessIntegrationTests { error: .bytes(limit: 16) ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } @Test func testFileDescriptorErrorOutput() async throws { @@ -1666,7 +1679,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, @@ -1674,6 +1687,9 @@ extension SubprocessIntegrationTests { error: .data(limit: 16) ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } #endif @@ -2639,6 +2655,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)))