diff --git a/Sources/Subprocess/API.swift b/Sources/Subprocess/API.swift index 74b8e26..802f916 100644 --- a/Sources/Subprocess/API.swift +++ b/Sources/Subprocess/API.swift @@ -442,6 +442,7 @@ public func run< standardError: Error.OutputType ) + let executionContext = ExecutionContext(configuration) let customInput = CustomWriteInput() let result = try await configuration.run( @@ -454,11 +455,20 @@ public func run< var errorIOBox: IODescriptor? = consume errorIO // Write input, capture output and error in parallel - async let stdout = try output.captureOutput(from: outputIOBox.take()) - async let stderr = try error.captureOutput(from: errorIOBox.take()) + async let stdout = try output.captureOutput( + from: outputIOBox.take(), + executionContext: executionContext + ) + async let stderr = try error.captureOutput( + from: errorIOBox.take(), + executionContext: executionContext + ) // Write span at the same isolation if let writeFd = inputIOBox.take() { - let writer = StandardInputWriter(diskIO: writeFd) + let writer = StandardInputWriter( + diskIO: writeFd, + executionContext: executionContext + ) _ = try await writer.write(input._bytes) try await writer.finish() } @@ -501,6 +511,7 @@ public func run< standardOutput: Output.OutputType, standardError: Error.OutputType ) + let executionContext = ExecutionContext(configuration) let inputPipe = try input.createPipe() let outputPipe = try output.createPipe() let errorPipe = try error.createPipe(from: outputPipe) @@ -523,7 +534,10 @@ public func run< var errorIOContainer: IODescriptor? = errorIOBox.take() group.addTask { if let writeFd = inputIOContainer.take() { - let writer = StandardInputWriter(diskIO: writeFd) + let writer = StandardInputWriter( + diskIO: writeFd, + executionContext: executionContext + ) try await input.write(with: writer) try await writer.finish() } @@ -531,13 +545,15 @@ public func run< } group.addTask { let stdout = try await output.captureOutput( - from: outputIOContainer.take() + from: outputIOContainer.take(), + executionContext: executionContext ) return .standardOutputCaptured(stdout) } group.addTask { let stderr = try await error.captureOutput( - from: errorIOContainer.take() + from: errorIOContainer.take(), + executionContext: executionContext ) return .standardErrorCaptured(stderr) } @@ -561,14 +577,22 @@ public func run< standardOutput: stdout, standardError: stderror ) - } catch { - if let underlying = error as? SubprocessError.UnderlyingError { - throw SubprocessError.asyncIOFailed( - reason: "Failed to capture output", - underlyingError: underlying - ) - } + } catch let error as SubprocessError { + // Inner I/O layers (`StandardInputWriter`, `OutputProtocol.captureOutput`) + // already attached `executionContext` at the throw site. + // Rethrow as-is. The outer wrap in `Configuration.run()` is + // a no-op due to the idempotency check in + // `SubprocessError.withExecutionContext(_:)`. throw error + } catch { + // Should be unreachable. Every child task throws `SubprocessError`. + // If a future change causes a non-`SubprocessError` to escape, + // fall back to wrapping it as `asyncIOFailed`, and the outer + // wrap will populate context. + throw SubprocessError.asyncIOFailed( + reason: "Failed to capture output", + underlyingError: error as? SubprocessError.UnderlyingError + ) } } } @@ -605,6 +629,7 @@ public func run< error: Error = .discarded, body: (_ execution: Execution) async throws -> Result ) async throws -> ExecutionOutcome where Error.OutputType == Void { + let executionContext = ExecutionContext(configuration) let inputPipe = try input.createPipe() let outputPipe = try output.createPipe() let errorPipe = try error.createPipe(from: outputPipe) @@ -627,7 +652,10 @@ public func run< var inputIOContainer: IODescriptor? = inputIOBox.take() group.addTask { if let inputIO = inputIOContainer.take() { - let writer = StandardInputWriter(diskIO: inputIO) + let writer = StandardInputWriter( + diskIO: inputIO, + executionContext: executionContext + ) try await input.write(with: writer) try await writer.finish() } @@ -667,6 +695,7 @@ public func run< _ outputSequence: AsyncBufferSequence ) async throws -> Result ) async throws -> ExecutionOutcome where Error.OutputType == Void { + let executionContext = ExecutionContext(configuration) let output = SequenceOutput() let inputPipe = try input.createPipe() let outputPipe = try output.createPipe() @@ -689,7 +718,10 @@ public func run< var inputIOContainer: IODescriptor? = inputIOBox.take() group.addTask { if let inputIO = inputIOContainer.take() { - let writer = StandardInputWriter(diskIO: inputIO) + let writer = StandardInputWriter( + diskIO: inputIO, + executionContext: executionContext + ) try await input.write(with: writer) try await writer.finish() } @@ -697,7 +729,8 @@ public func run< // Body runs in the same isolation let outputSequence = AsyncBufferSequence( - diskIO: outputIOBox!.consumeDescriptor() + diskIO: outputIOBox!.consumeDescriptor(), + executionContext: executionContext ) let result = try await body(execution, outputSequence) @@ -728,6 +761,7 @@ public func run( _ errorSequence: AsyncBufferSequence ) async throws -> Result ) async throws -> ExecutionOutcome where Output.OutputType == Void { + let executionContext = ExecutionContext(configuration) let error = SequenceOutput() return try await configuration.run( @@ -747,13 +781,17 @@ public func run( var inputIOContainer: IODescriptor? = inputIOBox.take() group.addTask { if let inputIO = inputIOContainer.take() { - let writer = StandardInputWriter(diskIO: inputIO) + let writer = StandardInputWriter( + diskIO: inputIO, + executionContext: executionContext + ) try await input.write(with: writer) try await writer.finish() } } let errorSequence = AsyncBufferSequence( - diskIO: errorIOBox!.consumeDescriptor() + diskIO: errorIOBox!.consumeDescriptor(), + executionContext: executionContext ) // Body runs in the same isolation let result = try await body(execution, errorSequence) @@ -793,6 +831,7 @@ public func run( _ errorSequence: AsyncBufferSequence ) async throws -> Result ) async throws -> ExecutionOutcome { + let executionContext = ExecutionContext(configuration) let output = SequenceOutput() let error = SequenceOutput() @@ -812,7 +851,10 @@ public func run( var inputIOContainer: IODescriptor? = inputIOBox.take() group.addTask { if let inputIO = inputIOContainer.take() { - let writer = StandardInputWriter(diskIO: inputIO) + let writer = StandardInputWriter( + diskIO: inputIO, + executionContext: executionContext + ) try await input.write(with: writer) try await writer.finish() } @@ -820,11 +862,13 @@ public func run( // Body runs in the same isolation let outputSequence = AsyncBufferSequence( - diskIO: outputIOBox!.consumeDescriptor() + diskIO: outputIOBox!.consumeDescriptor(), + executionContext: executionContext ) let errorSequence = AsyncBufferSequence( - diskIO: errorIOBox!.consumeDescriptor() + diskIO: errorIOBox!.consumeDescriptor(), + executionContext: executionContext ) let result = try await body(execution, outputSequence, errorSequence) @@ -856,6 +900,7 @@ public func run( _ outputSequence: AsyncBufferSequence ) async throws -> Result ) async throws -> ExecutionOutcome where Error.OutputType == Void { + let executionContext = ExecutionContext(configuration) let input = CustomWriteInput() let output = SequenceOutput() let inputPipe = try input.createPipe() @@ -871,9 +916,13 @@ public func run( var errorIOBox = consume errorIO try errorIOBox?.safelyClose() - let writer = StandardInputWriter(diskIO: inputIO!) + let writer = StandardInputWriter( + diskIO: inputIO!, + executionContext: executionContext + ) let outputSequence = AsyncBufferSequence( - diskIO: outputIOBox!.consumeDescriptor() + diskIO: outputIOBox!.consumeDescriptor(), + executionContext: executionContext ) let result = try await body(execution, writer, outputSequence) @@ -904,6 +953,7 @@ public func run( _ errorSequence: AsyncBufferSequence ) async throws -> Result ) async throws -> ExecutionOutcome where Output.OutputType == Void { + let executionContext = ExecutionContext(configuration) let input = CustomWriteInput() let error = SequenceOutput() @@ -916,9 +966,13 @@ public func run( var errorIOBox = consume errorIO try outputIOBox?.safelyClose() - let writer = StandardInputWriter(diskIO: inputIO!) + let writer = StandardInputWriter( + diskIO: inputIO!, + executionContext: executionContext + ) let errorSequence = AsyncBufferSequence( - diskIO: errorIOBox!.consumeDescriptor() + diskIO: errorIOBox!.consumeDescriptor(), + executionContext: executionContext ) let bodyResult = try await body(execution, writer, errorSequence) try await writer.finish() @@ -948,6 +1002,7 @@ public func run( _ errorSequence: AsyncBufferSequence ) async throws -> Result ) async throws -> ExecutionOutcome { + let executionContext = ExecutionContext(configuration) let input = CustomWriteInput() let output = SequenceOutput() let error = SequenceOutput() @@ -960,12 +1015,17 @@ public func run( var outputIOBox = consume outputIO var errorIOBox = consume errorIO - let writer = StandardInputWriter(diskIO: inputIO!) + let writer = StandardInputWriter( + diskIO: inputIO!, + executionContext: executionContext + ) let outputSequence = AsyncBufferSequence( - diskIO: outputIOBox!.consumeDescriptor() + diskIO: outputIOBox!.consumeDescriptor(), + executionContext: executionContext ) let errorSequence = AsyncBufferSequence( - diskIO: errorIOBox!.consumeDescriptor() + diskIO: errorIOBox!.consumeDescriptor(), + executionContext: executionContext ) let result = try await body(execution, writer, outputSequence, errorSequence) try await writer.finish() diff --git a/Sources/Subprocess/AsyncBufferSequence.swift b/Sources/Subprocess/AsyncBufferSequence.swift index 958dee7..2afa0ea 100644 --- a/Sources/Subprocess/AsyncBufferSequence.swift +++ b/Sources/Subprocess/AsyncBufferSequence.swift @@ -52,35 +52,41 @@ public struct AsyncBufferSequence: AsyncSequence, @unchecked Sendable { private let diskIO: DiskIO private let preferredBufferSize: Int private var buffer: [Buffer] + private let executionContext: ExecutionContext? - internal init(diskIO: DiskIO) { + internal init(diskIO: DiskIO, executionContext: ExecutionContext?) { self.diskIO = diskIO self.buffer = [] // Only need to query it once at beginning of stream self.preferredBufferSize = AsyncIO.queryPipeBufferSize(for: diskIO) + self.executionContext = executionContext } /// Retrieves the next buffer in the sequence, or `nil` if the sequence ended. public mutating func next(isolation actor: isolated (any Actor)?) async throws -> Buffer? { - // If we have more left in buffer, use that - guard self.buffer.isEmpty else { - return self.buffer.removeFirst() - } - // Read more data - let data = try await AsyncIO.shared.read( - from: self.diskIO, - upTo: self.preferredBufferSize - ) - guard let data else { - // We finished reading. Close the file descriptor now - #if canImport(WinSDK) - try _safelyClose(.handle(self.diskIO)) - #else - try _safelyClose(.fileDescriptor(self.diskIO)) - #endif - return nil + do { + // If we have more left in buffer, use that + guard self.buffer.isEmpty else { + return self.buffer.removeFirst() + } + // Read more data + let data = try await AsyncIO.shared.read( + from: self.diskIO, + upTo: self.preferredBufferSize + ) + guard let data else { + // We finished reading. Close the file descriptor now + #if canImport(WinSDK) + try _safelyClose(.handle(self.diskIO)) + #else + try _safelyClose(.fileDescriptor(self.diskIO)) + #endif + return nil + } + return Buffer(data: data) + } catch { + throw error.withExecutionContext(self.executionContext) } - return Buffer(data: data) } /// Retrieves the next buffer in the sequence, or `nil` if the sequence ended. @@ -90,14 +96,16 @@ public struct AsyncBufferSequence: AsyncSequence, @unchecked Sendable { } private let diskIO: DiskIO + private let executionContext: ExecutionContext? - internal init(diskIO: DiskIO) { + internal init(diskIO: DiskIO, executionContext: ExecutionContext?) { self.diskIO = diskIO + self.executionContext = executionContext } /// Creates an iterator for this asynchronous sequence. public func makeAsyncIterator() -> Iterator { - return Iterator(diskIO: self.diskIO) + return Iterator(diskIO: self.diskIO, executionContext: self.executionContext) } /// Splits the buffer into strings using the specified separator. @@ -118,7 +126,8 @@ public struct AsyncBufferSequence: AsyncSequence, @unchecked Sendable { underlying: self, encoding: UTF8.self, bufferingPolicy: bufferingPolicy, - separator: separator + separator: separator, + executionContext: self.executionContext ) } @@ -143,7 +152,8 @@ public struct AsyncBufferSequence: AsyncSequence, @unchecked Sendable { underlying: self, encoding: encoding, bufferingPolicy: bufferingPolicy, - separator: separator + separator: separator, + executionContext: self.executionContext ) } } @@ -187,6 +197,7 @@ extension AsyncBufferSequence { private let base: AsyncBufferSequence private let bufferingPolicy: BufferingPolicy private let separator: Separator + private let executionContext: ExecutionContext? /// An iterator for ``StringSequence``. public struct AsyncIterator: AsyncIteratorProtocol { @@ -202,11 +213,13 @@ extension AsyncBufferSequence { private let bufferingPolicy: BufferingPolicy private let separator: Separator private let separatorCodeUnits: [Encoding.CodeUnit] + private let executionContext: ExecutionContext? internal init( underlyingIterator: AsyncBufferSequence.AsyncIterator, bufferingPolicy: BufferingPolicy, - separator: Separator + separator: Separator, + executionContext: ExecutionContext? ) { self.source = underlyingIterator self.buffer = [] @@ -216,6 +229,7 @@ extension AsyncBufferSequence { self.eofReached = false self.bufferingPolicy = bufferingPolicy self.separator = separator + self.executionContext = executionContext // Pre-compute separator code unit sequences for // .unicodeScalarSequence cases. // Both use the same buffer-tail matching algorithm: @@ -242,186 +256,189 @@ extension AsyncBufferSequence { /// Retrieves the next line, or `nil` if the sequence ended. public mutating func next(isolation actor: isolated (any Actor)?) async throws -> String? { + do { + func loadBuffer() async throws -> [Encoding.CodeUnit]? { + guard !self.eofReached else { + return nil + } - func loadBuffer() async throws -> [Encoding.CodeUnit]? { - guard !self.eofReached else { - return nil + guard let buffer = try await self.source.next(isolation: actor) else { + self.eofReached = true + return nil + } + // Cast data to CodeUnit type + let result = buffer.withUnsafeBytes { ptr in + return ptr.withMemoryRebound(to: Encoding.CodeUnit.self) { codeUnitPtr in + return Array(codeUnitPtr) + } + } + return result.isEmpty ? nil : result } - guard let buffer = try await self.source.next(isolation: actor) else { - self.eofReached = true - return nil - } - // Cast data to CodeUnit type - let result = buffer.withUnsafeBytes { ptr in - return ptr.withMemoryRebound(to: Encoding.CodeUnit.self) { codeUnitPtr in - return Array(codeUnitPtr) + func yield() -> String? { + defer { + self.buffer.removeAll(keepingCapacity: true) + } + if self.buffer.isEmpty { + return "" } + return String(decoding: self.buffer, as: Encoding.self) } - return result.isEmpty ? nil : result - } - func yield() -> String? { - defer { - self.buffer.removeAll(keepingCapacity: true) - } - if self.buffer.isEmpty { - return "" + func nextFromSource() async throws -> Encoding.CodeUnit? { + if underlyingBufferIndex >= underlyingBuffer.count { + guard let buf = try await loadBuffer() else { + return nil + } + underlyingBuffer = buf + underlyingBufferIndex = buf.startIndex + } + let result = underlyingBuffer[underlyingBufferIndex] + underlyingBufferIndex = underlyingBufferIndex.advanced(by: 1) + return result } - return String(decoding: self.buffer, as: Encoding.self) - } - func nextFromSource() async throws -> Encoding.CodeUnit? { - if underlyingBufferIndex >= underlyingBuffer.count { - guard let buf = try await loadBuffer() else { - return nil + func nextCodeUnit() async throws -> Encoding.CodeUnit? { + defer { leftover = nil } + if let leftover = leftover { + return leftover } - underlyingBuffer = buf - underlyingBufferIndex = buf.startIndex + return try await nextFromSource() } - let result = underlyingBuffer[underlyingBufferIndex] - underlyingBufferIndex = underlyingBufferIndex.advanced(by: 1) - return result - } - func nextCodeUnit() async throws -> Encoding.CodeUnit? { - defer { leftover = nil } - if let leftover = leftover { - return leftover + // https://en.wikipedia.org/wiki/Newline#Unicode + let lineFeed = Encoding.CodeUnit(0x0A) + /// let verticalTab = Encoding.CodeUnit(0x0B) + /// let formFeed = Encoding.CodeUnit(0x0C) + let carriageReturn = Encoding.CodeUnit(0x0D) + // carriageReturn + lineFeed + let newLine1: Encoding.CodeUnit + let newLine2: Encoding.CodeUnit + let lineSeparator1: Encoding.CodeUnit + let lineSeparator2: Encoding.CodeUnit + let lineSeparator3: Encoding.CodeUnit + let paragraphSeparator1: Encoding.CodeUnit + let paragraphSeparator2: Encoding.CodeUnit + let paragraphSeparator3: Encoding.CodeUnit + switch Encoding.CodeUnit.self { + case is UInt8.Type: + newLine1 = Encoding.CodeUnit(0xC2) + newLine2 = Encoding.CodeUnit(0x85) + + lineSeparator1 = Encoding.CodeUnit(0xE2) + lineSeparator2 = Encoding.CodeUnit(0x80) + lineSeparator3 = Encoding.CodeUnit(0xA8) + + paragraphSeparator1 = Encoding.CodeUnit(0xE2) + paragraphSeparator2 = Encoding.CodeUnit(0x80) + paragraphSeparator3 = Encoding.CodeUnit(0xA9) + case is UInt16.Type, is UInt32.Type: + // UTF16 and UTF32 use one byte for all + newLine1 = Encoding.CodeUnit(0x0085) + newLine2 = Encoding.CodeUnit(0x0085) + + lineSeparator1 = Encoding.CodeUnit(0x2028) + lineSeparator2 = Encoding.CodeUnit(0x2028) + lineSeparator3 = Encoding.CodeUnit(0x2028) + + paragraphSeparator1 = Encoding.CodeUnit(0x2029) + paragraphSeparator2 = Encoding.CodeUnit(0x2029) + paragraphSeparator3 = Encoding.CodeUnit(0x2029) + default: + fatalError("Unknown encoding type \(Encoding.self)") } - return try await nextFromSource() - } - // https://en.wikipedia.org/wiki/Newline#Unicode - let lineFeed = Encoding.CodeUnit(0x0A) - /// let verticalTab = Encoding.CodeUnit(0x0B) - /// let formFeed = Encoding.CodeUnit(0x0C) - let carriageReturn = Encoding.CodeUnit(0x0D) - // carriageReturn + lineFeed - let newLine1: Encoding.CodeUnit - let newLine2: Encoding.CodeUnit - let lineSeparator1: Encoding.CodeUnit - let lineSeparator2: Encoding.CodeUnit - let lineSeparator3: Encoding.CodeUnit - let paragraphSeparator1: Encoding.CodeUnit - let paragraphSeparator2: Encoding.CodeUnit - let paragraphSeparator3: Encoding.CodeUnit - switch Encoding.CodeUnit.self { - case is UInt8.Type: - newLine1 = Encoding.CodeUnit(0xC2) - newLine2 = Encoding.CodeUnit(0x85) - - lineSeparator1 = Encoding.CodeUnit(0xE2) - lineSeparator2 = Encoding.CodeUnit(0x80) - lineSeparator3 = Encoding.CodeUnit(0xA8) - - paragraphSeparator1 = Encoding.CodeUnit(0xE2) - paragraphSeparator2 = Encoding.CodeUnit(0x80) - paragraphSeparator3 = Encoding.CodeUnit(0xA9) - case is UInt16.Type, is UInt32.Type: - // UTF16 and UTF32 use one byte for all - newLine1 = Encoding.CodeUnit(0x0085) - newLine2 = Encoding.CodeUnit(0x0085) - - lineSeparator1 = Encoding.CodeUnit(0x2028) - lineSeparator2 = Encoding.CodeUnit(0x2028) - lineSeparator3 = Encoding.CodeUnit(0x2028) - - paragraphSeparator1 = Encoding.CodeUnit(0x2029) - paragraphSeparator2 = Encoding.CodeUnit(0x2029) - paragraphSeparator3 = Encoding.CodeUnit(0x2029) - default: - fatalError("Unknown encoding type \(Encoding.self)") - } - - while let first = try await nextCodeUnit() { - // Throw if we exceed max line length - if case .maxLineLength(let maxLength) = self.bufferingPolicy, buffer.count >= maxLength { - throw SubprocessError.outputLimitExceeded(limit: maxLength) - } + while let first = try await nextCodeUnit() { + // Throw if we exceed max line length + if case .maxLineLength(let maxLength) = self.bufferingPolicy, buffer.count >= maxLength { + throw SubprocessError.outputLimitExceeded(limit: maxLength) + } - switch self.separator.storage { - case .lineBreaks: - switch first { - case carriageReturn: - // Swallow up any subsequent LF - guard let next = try await nextFromSource() else { - return yield() // if we ran out of bytes, the last byte was a CR - } - guard next == lineFeed else { - // if the next character was not an LF, save it for the next iteration and still return a line - leftover = next + switch self.separator.storage { + case .lineBreaks: + switch first { + case carriageReturn: + // Swallow up any subsequent LF + guard let next = try await nextFromSource() else { + return yield() // if we ran out of bytes, the last byte was a CR + } + guard next == lineFeed else { + // if the next character was not an LF, save it for the next iteration and still return a line + leftover = next + return yield() + } return yield() - } - return yield() - case newLine1 where Encoding.CodeUnit.self is UInt8.Type: // this may be used to compose other UTF8 characters - guard let next = try await nextFromSource() else { - // technically invalid UTF8 but it should be repaired to "\u{FFFD}" - buffer.append(first) + case newLine1 where Encoding.CodeUnit.self is UInt8.Type: // this may be used to compose other UTF8 characters + guard let next = try await nextFromSource() else { + // technically invalid UTF8 but it should be repaired to "\u{FFFD}" + buffer.append(first) + return yield() + } + guard next == newLine2 else { + // This character is not a valid newLine. Treat it as normal character + buffer.append(first) + buffer.append(next) + continue + } return yield() - } - guard next == newLine2 else { - // This character is not a valid newLine. Treat it as normal character + case lineSeparator1 where Encoding.CodeUnit.self is UInt8.Type, + paragraphSeparator1 where Encoding.CodeUnit.self is UInt8.Type: + // Try to read: 80 [A8 | A9]. + // If we can't, then we put the byte in the buffer for error correction + guard let next = try await nextFromSource() else { + buffer.append(first) + return yield() + } + guard next == lineSeparator2 || next == paragraphSeparator2 else { + // Invalid lineSeparator. Treat it as normal charcter. + buffer.append(first) + buffer.append(next) + continue + } + guard let fin = try await nextFromSource() else { + // Invalid lineSeparator. Treat it as normal charcter. + buffer.append(first) + buffer.append(next) + return yield() + } + guard fin == lineSeparator3 || fin == paragraphSeparator3 else { + // Invalid lineSeparator. Treat it as normal charcter. + buffer.append(first) + buffer.append(next) + buffer.append(fin) + continue + } + return yield() + case lineFeed..= self.separatorCodeUnits.count else { continue } - guard let fin = try await nextFromSource() else { - // Invalid lineSeparator. Treat it as normal charcter. - buffer.append(first) - buffer.append(next) + if buffer.suffix( + self.separatorCodeUnits.count + ).elementsEqual(self.separatorCodeUnits) { + buffer.removeLast(self.separatorCodeUnits.count) return yield() } - guard fin == lineSeparator3 || fin == paragraphSeparator3 else { - // Invalid lineSeparator. Treat it as normal charcter. - buffer.append(first) - buffer.append(next) - buffer.append(fin) - continue - } - return yield() - case lineFeed..= self.separatorCodeUnits.count else { - continue - } - if buffer.suffix( - self.separatorCodeUnits.count - ).elementsEqual(self.separatorCodeUnits) { - buffer.removeLast(self.separatorCodeUnits.count) - return yield() - } - continue } - } - // Don't emit an empty newline when there is no more content (e.g. end of file) - if !buffer.isEmpty { - return yield() + // Don't emit an empty newline when there is no more content (e.g. end of file) + if !buffer.isEmpty { + return yield() + } + return nil + } catch let error as SubprocessError { + throw error.withExecutionContext(self.executionContext) } - return nil } /// Retrieves the next line, or `nil` if the sequence ended. @@ -435,7 +452,8 @@ extension AsyncBufferSequence { return AsyncIterator( underlyingIterator: self.base.makeAsyncIterator(), bufferingPolicy: self.bufferingPolicy, - separator: self.separator + separator: self.separator, + executionContext: self.executionContext ) } @@ -443,11 +461,13 @@ extension AsyncBufferSequence { underlying: AsyncBufferSequence, encoding: Encoding.Type, bufferingPolicy: BufferingPolicy, - separator: Separator + separator: Separator, + executionContext: ExecutionContext? ) { self.base = underlying self.bufferingPolicy = bufferingPolicy self.separator = separator + self.executionContext = executionContext } } } diff --git a/Sources/Subprocess/Configuration.swift b/Sources/Subprocess/Configuration.swift index 1a87c0e..8c1e84b 100644 --- a/Sources/Subprocess/Configuration.swift +++ b/Sources/Subprocess/Configuration.swift @@ -95,51 +95,61 @@ public struct Configuration: Sendable { ) async throws -> Result ) ) async throws -> ExecutionOutcome { - var spawnResults = try await self.spawn( - withInput: input, - outputPipe: output, - errorPipe: error - ) + do { + var spawnResults = try await self.spawn( + withInput: input, + outputPipe: output, + errorPipe: error + ) - let execution = spawnResults.execution - defer { - // Close process file descriptor now we finished monitoring - execution.processIdentifier.close() - } + let execution = spawnResults.execution + defer { + // Close process file descriptor now we finished monitoring + execution.processIdentifier.close() + } - return try await withAsyncTaskCleanupHandler { () throws -> ExecutionOutcome in - let inputIO = spawnResults.inputWriteEnd() - let outputIO = spawnResults.outputReadEnd() - let errorIO = spawnResults.errorReadEnd() + return try await withAsyncTaskCleanupHandler { () throws -> ExecutionOutcome in + 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(execution, inputIO, outputIO, errorIO) + let result: Swift.Result + do { + // Body runs in the same isolation + let bodyResult = try await body(execution, inputIO, outputIO, errorIO) - result = .success(bodyResult) - } catch { - result = .failure(error) - } + result = .success(bodyResult) + } catch { + result = .failure(error) + } - // Ensure that we begin monitoring process termination after `body` runs - // and regardless of whether `body` throws, so that the pid gets reaped - // even if `body` throws, and we are not leaving zombie processes in the - // process table which will cause the process termination monitoring thread - // to effectively hang due to the pid never being awaited - let terminationStatus = try await monitorProcessTermination( - for: execution.processIdentifier - ) + // Ensure that we begin monitoring process termination after `body` runs + // and regardless of whether `body` throws, so that the pid gets reaped + // even if `body` throws, and we are not leaving zombie processes in the + // process table which will cause the process termination monitoring thread + // to effectively hang due to the pid never being awaited + let terminationStatus = try await monitorProcessTermination( + for: execution.processIdentifier + ) - return ExecutionOutcome( - terminationStatus: terminationStatus, - value: try result.get() - ) - } onCleanup: { - // Attempt to terminate the child process - await execution.runTeardownSequence( - self.platformOptions.teardownSequence - ) + return ExecutionOutcome( + terminationStatus: terminationStatus, + value: try result.get() + ) + } onCleanup: { + // Attempt to terminate the child process + await execution.runTeardownSequence( + self.platformOptions.teardownSequence + ) + } + } catch let error as SubprocessError { + // Attach `ExecutionContext` to every thrown `SubprocessError`. + // The idempotency guard in `withExecutionContext(_:)` ensures + // inner I/O layers that already attached context are not + // overwritten here. Non-`Subprocess` errors (e.g., anything thrown + // from the `body` closure) propagate unchanged, since the + // execution context is only meaningful for Subprocess's own errors. + throw error.withExecutionContext(.init(self)) } } } diff --git a/Sources/Subprocess/Error.swift b/Sources/Subprocess/Error.swift index 264b84b..44ddb5b 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 configured inputs (executable, arguments, environment, + /// working directory) at the time this error was thrown. + /// + /// This is populated for every error that propagates out of a `run()` call. + /// This may be `nil` for errors observed from outside of a `run()` call. + public let executionContext: ExecutionContext? /// Context associated with this error for better error message private let context: [Code: Context] @@ -243,6 +249,7 @@ extension SubprocessError { return SubprocessError( code: .executableNotFound, underlyingError: underlyingError, + executionContext: nil, context: [.executableNotFound: .string(executable)] ) } @@ -251,6 +258,7 @@ extension SubprocessError { return SubprocessError( code: .failedToMonitorProcess, underlyingError: underlyingError, + executionContext: nil, context: [:] ) } @@ -259,6 +267,7 @@ extension SubprocessError { return SubprocessError( code: .processControlFailed, underlyingError: underlyingError, + executionContext: nil, context: [.processControlFailed: .processControlOperation(operation)] ) } @@ -267,6 +276,7 @@ extension SubprocessError { return SubprocessError( code: .spawnFailed, underlyingError: nil, + executionContext: nil, context: [:] ) } @@ -282,6 +292,7 @@ extension SubprocessError { return SubprocessError( code: .spawnFailed, underlyingError: underlyingError, + executionContext: nil, context: context ) } @@ -290,6 +301,7 @@ extension SubprocessError { return SubprocessError( code: .outputLimitExceeded, underlyingError: nil, + executionContext: nil, context: [ .outputLimitExceeded: .int(limit) ] @@ -303,6 +315,7 @@ extension SubprocessError { return SubprocessError( code: .asyncIOFailed, underlyingError: underlyingError, + executionContext: nil, context: [.asyncIOFailed: .string(reason)] ) } @@ -313,6 +326,7 @@ extension SubprocessError { return SubprocessError( code: .failedToReadFromSubprocess, underlyingError: underlyingError, + executionContext: nil, context: [:] ) } @@ -323,6 +337,7 @@ extension SubprocessError { return SubprocessError( code: .failedToWriteToSubprocess, underlyingError: underlyingError, + executionContext: nil, context: [:] ) } @@ -338,7 +353,32 @@ extension SubprocessError { return SubprocessError( code: .failedToChangeWorkingDirectory, underlyingError: underlyingError, + executionContext: nil, context: context ) } } + +// MARK: - withExecutionContext +extension SubprocessError { + /// Returns a copy of this error with the given context attached, unless + /// this error already carries an `executionContext` or the given context + /// is `nil`. + /// + /// This guarantee allows attachment to happen safely at multiple layers. + /// The inner I/O layers attach context first (with the most specific + /// information they have), and the outer wrap in + /// `Configuration.run(input:output:error:_:)` is a no-op for + /// already-enriched errors. + internal func withExecutionContext(_ executionContext: ExecutionContext?) -> Self { + guard let executionContext, self.executionContext == nil 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 0000000..2d459b3 --- /dev/null +++ b/Sources/Subprocess/ExecutionContext.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// +// 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 that produced a `SubprocessError`. +/// +/// `ExecutionContext` records the ``Executable``, ``Arguments``, ``Environment``, +/// and working directory that were configured for a subprocess at the time +/// an error was thrown. The values are inputs supplied by the caller. For +/// example, if ``Environment/inherit`` was used, then ``environment`` +/// reflects `.inherit`, not a snapshot of the parent process's environment +/// variables. +/// +/// `ExecutionContext` is populated on every ``SubprocessError`` that propagates +/// out of a `run()` call, including errors thrown from within a custom `body` +/// closure that uses ``StandardInputWriter``, ``AsyncBufferSequence``, or a +/// collected output type. This may be `nil` for errors observed from 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? + + internal init(_ configuration: Configuration) { + self.executable = configuration.executable + self.arguments = configuration.arguments + self.environment = configuration.environment + self.workingDirectory = configuration.workingDirectory + } +} + +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 ?? "") + ) + """ + } + + /// A debug-oriented textual representation of this execution context. + public var debugDescription: String { + return """ + ExecutionContext( + executable: \(self.executable.debugDescription), + arguments: \(self.arguments.debugDescription), + environment: \(self.environment.debugDescription), + workingDirectory: \(self.workingDirectory?.string ?? "") + ) + """ + } +} diff --git a/Sources/Subprocess/IO/Input.swift b/Sources/Subprocess/IO/Input.swift index 9d24994..961ad7c 100644 --- a/Sources/Subprocess/IO/Input.swift +++ b/Sources/Subprocess/IO/Input.swift @@ -238,9 +238,11 @@ extension InputProtocol { public final actor StandardInputWriter: Sendable { internal var diskIO: IODescriptor + internal let executionContext: ExecutionContext? - init(diskIO: consuming IODescriptor) { + init(diskIO: consuming IODescriptor, executionContext: ExecutionContext?) { self.diskIO = diskIO + self.executionContext = executionContext } /// Writes an array of bytes to the subprocess's standard input. @@ -251,7 +253,11 @@ public final actor StandardInputWriter: Sendable { public func write( _ array: [UInt8] ) async throws(SubprocessError) -> Int { - return try await AsyncIO.shared.write(array, to: self.diskIO) + do { + return try await AsyncIO.shared.write(array, to: self.diskIO) + } catch { + throw error.withExecutionContext(self.executionContext) + } } /// Writes a raw span to the subprocess's standard input. @@ -261,7 +267,11 @@ public final actor StandardInputWriter: Sendable { /// See ``underlyingError`` for more details. /// - Returns: The number of bytes written. public func write(_ span: borrowing RawSpan) async throws(SubprocessError) -> Int { - return try await AsyncIO.shared.write(span, to: self.diskIO) + do { + return try await AsyncIO.shared.write(span, to: self.diskIO) + } catch { + throw error.withExecutionContext(self.executionContext) + } } /// Writes a string to the subprocess's standard input. @@ -285,7 +295,11 @@ public final actor StandardInputWriter: Sendable { /// - Throws: `SubprocessError` with error code `.asyncIOFailed`. /// See ``underlyingError`` for more details. public func finish() async throws(SubprocessError) { - try self.diskIO.safelyClose() + do { + try self.diskIO.safelyClose() + } catch { + throw error.withExecutionContext(self.executionContext) + } } } diff --git a/Sources/Subprocess/IO/Output.swift b/Sources/Subprocess/IO/Output.swift index 602bc3b..9d24b89 100644 --- a/Sources/Subprocess/IO/Output.swift +++ b/Sources/Subprocess/IO/Output.swift @@ -125,7 +125,8 @@ public struct BytesOutput: OutputProtocol, ErrorOutputProtocol { public let maxSize: Int internal func captureOutput( - from diskIO: consuming IODescriptor + from diskIO: consuming IODescriptor, + executionContext: ExecutionContext? = nil ) async throws(SubprocessError) -> [UInt8] { var result: [UInt8] = [] do { @@ -153,12 +154,13 @@ public struct BytesOutput: OutputProtocol, ErrorOutputProtocol { } } catch { try diskIO.safelyClose() - throw error + throw error.withExecutionContext(executionContext) } try diskIO.safelyClose() if result.count > self.maxSize { - throw .outputLimitExceeded(limit: self.maxSize) + throw SubprocessError.outputLimitExceeded(limit: self.maxSize) + .withExecutionContext(executionContext) } return result } @@ -322,7 +324,8 @@ extension OutputProtocol { /// Capture the output from the subprocess up to maxSize @_disfavoredOverload internal func captureOutput( - from diskIO: consuming IODescriptor? + from diskIO: consuming IODescriptor?, + executionContext: ExecutionContext? = nil ) async throws -> OutputType { if OutputType.self == Void.self { try diskIO?.safelyClose() @@ -339,7 +342,10 @@ extension OutputProtocol { } if let bytesOutput = self as? BytesOutput { - return try await bytesOutput.captureOutput(from: diskIO) as! Self.OutputType + return try await bytesOutput.captureOutput( + from: diskIO, + executionContext: executionContext + ) as! Self.OutputType } var result: [UInt8] = [] @@ -367,12 +373,13 @@ extension OutputProtocol { } } catch { try diskIO.safelyClose() - throw error + throw error.withExecutionContext(executionContext) } try diskIO.safelyClose() if result.count > self.maxSize { throw SubprocessError.outputLimitExceeded(limit: self.maxSize) + .withExecutionContext(executionContext) } return try self.output(from: result) @@ -380,7 +387,10 @@ extension OutputProtocol { } extension OutputProtocol where OutputType == Void { - internal func captureOutput(from fileDescriptor: consuming IODescriptor?) async throws {} + internal func captureOutput( + from fileDescriptor: consuming IODescriptor?, + executionContext: ExecutionContext? = nil + ) async throws {} /// Converts the output from a raw span to the expected output type. public func output(from span: RawSpan) throws { diff --git a/Tests/SubprocessTests/ExecutionContextTests.swift b/Tests/SubprocessTests/ExecutionContextTests.swift new file mode 100644 index 0000000..68f2c5e --- /dev/null +++ b/Tests/SubprocessTests/ExecutionContextTests.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// +// 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 { + /// 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) + } + + /// Every field of `ExecutionContext` must round-trip from the + /// originating `Configuration`. + @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) + } +} diff --git a/Tests/SubprocessTests/IntegrationTests.swift b/Tests/SubprocessTests/IntegrationTests.swift index 810b7b0..0f2de29 100644 --- a/Tests/SubprocessTests/IntegrationTests.swift +++ b/Tests/SubprocessTests/IntegrationTests.swift @@ -74,18 +74,18 @@ extension SubprocessIntegrationTests { @Test func testExecutableNamedCannotResolve() async throws { #if os(Windows) - let underlying = SubprocessError.WindowsError(rawValue: DWORD(ERROR_FILE_NOT_FOUND)) + let expectedUnderlying = SubprocessError.WindowsError(rawValue: 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 { @@ -120,21 +120,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( rawValue: 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) @@ -727,10 +727,7 @@ extension SubprocessIntegrationTests { arguments: ["/c", "cd"], workingDirectory: invalidPath ) - let underlying = SubprocessError.WindowsError(rawValue: DWORD(ERROR_DIRECTORY)) - let expectedError: SubprocessError = .failedToChangeWorkingDirectory( - #"X:\Does\Not\Exist"#, underlyingError: underlying - ) + let expectedUnderlying = SubprocessError.WindowsError(rawValue: DWORD(ERROR_DIRECTORY)) #else let invalidPath: FilePath = FilePath("/does/not/exist") let setup = TestSetup( @@ -738,15 +735,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)) } } @@ -1139,7 +1137,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, @@ -1147,6 +1145,9 @@ extension SubprocessIntegrationTests { error: .discarded ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } #if SubprocessFoundation @@ -1198,7 +1199,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, @@ -1206,6 +1207,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 { @@ -1343,7 +1347,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, @@ -1351,6 +1355,9 @@ extension SubprocessIntegrationTests { error: .discarded ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } #endif @@ -1409,7 +1416,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, @@ -1417,6 +1424,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 @@ -1467,7 +1477,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, @@ -1475,6 +1485,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 { @@ -1659,7 +1672,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, @@ -1667,6 +1680,9 @@ extension SubprocessIntegrationTests { error: .data(limit: 16) ) } + + #expect(error.code == .outputLimitExceeded) + #expect(error.description == "Child process output exceeded the limit of 16 bytes.") } #endif @@ -2082,6 +2098,152 @@ extension SubprocessIntegrationTests { } } +// 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) + } + + @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) + } + + @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 testExecutionContextAttachedOnAsyncBufferSequenceLineLimitExceeded() async throws { + #if os(Windows) + let setup = TestSetup( + executable: .name("powershell.exe"), + arguments: [ + "-NoProfile", + "-Command", + """ + $b = [byte[]]::new(1000) + for ($i = 0; $i -lt 1000; $i++) { $b[$i] = 0x78 } + [Console]::OpenStandardOutput().Write($b, 0, $b.Length) + """, + ] + ) + #else + let setup = TestSetup( + executable: .path("/bin/sh"), + arguments: ["-c", "yes x | tr -d '\\n' | head -c 1000"] + ) + #endif + + let error = try await #require(throws: SubprocessError.self) { + _ = try await _run( + setup, + input: .none, + error: .discarded + ) { execution, standardOutput in + for try await _ in standardOutput.strings( + separatedBy: .lineBreaks, + bufferingPolicy: .maxLineLength(64) + ) { + // Drain until the iterator throws. + } + } + } + + #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) + } +} + // MARK: - Other Tests extension SubprocessIntegrationTests { @Test func testTerminateProcess() async throws { diff --git a/Tests/SubprocessTests/ProcessMonitoringTests.swift b/Tests/SubprocessTests/ProcessMonitoringTests.swift index 5a1f474..8596048 100644 --- a/Tests/SubprocessTests/ProcessMonitoringTests.swift +++ b/Tests/SubprocessTests/ProcessMonitoringTests.swift @@ -216,9 +216,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 func testDoesNotReapUnrelatedChildProcess() async throws {