Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .claude/skills/swiftyshell.md
Original file line number Diff line number Diff line change
Expand Up @@ -203,10 +203,11 @@ public extension RunnableCommandFamily {
#### OutputDestination

```swift
public enum OutputDestination: Sendable {
public enum OutputDestination: Sendable, Equatable {
case capture
case discard
case file(path: String, append: Bool)
case tee // streams live to the parent stdout/stderr AND captures into ShellOutput
}
```

Expand Down
18 changes: 18 additions & 0 deletions Sources/SwiftyShell/Core/OutputDestination.swift
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ import Foundation
/// .stderr(.file(path: "/tmp/build.log", append: true))
/// .run(in: context)
/// ```
///
/// Stream a long build live while still capturing it for parsing:
///
/// ```swift
/// let output = try await Command("./gradlew", arguments: "bundleRelease")
/// .stdout(.tee)
/// .run(in: context)
/// // Console shows progress during the build; output.stdout still holds the full log.
/// ```
public enum OutputDestination: Sendable, Equatable {
/// Captures the stream in memory and returns it via ``ShellOutput/stdout`` or
/// ``ShellOutput/stderr``.
Expand Down Expand Up @@ -63,4 +72,13 @@ public enum OutputDestination: Sendable, Equatable {
/// - append: When `true`, output is appended to any existing file contents. When `false`,
/// the file is truncated before writing.
case file(path: String, append: Bool)

/// Streams the stream to the parent process's stdout (for ``StreamKind/stdout``) or
/// stderr (for ``StreamKind/stderr``) as bytes arrive, while ALSO capturing them in
/// memory for ``ShellOutput``. Use for long-running commands where live progress matters
/// but the output is also needed for parsing.
///
/// Captured bytes count toward the output limit exactly like ``capture``. Live bytes are
/// written to the inherited FD immediately and are not buffered until exit.
case tee
}
26 changes: 26 additions & 0 deletions Sources/SwiftyShell/Internal/Execution/SubprocessExecutor.swift
Original file line number Diff line number Diff line change
Expand Up @@ -872,6 +872,32 @@ private func routeData(
break
case .file:
try fileHandle?.write(contentsOf: data)
case .tee:
// Capture for ShellOutput…
try store.append(data, to: stream)
// …and echo live to the parent process stream.
try TeeSink.shared.write(data, to: stream)
}
}

/// Serializes live `.tee` writes to the parent process's standard output and standard error.
///
/// Routing tasks for stdout and stderr run concurrently, so without coordination their writes to
/// ``FileHandle/standardOutput``/``FileHandle/standardError`` could interleave mid-buffer and
/// corrupt each other. A single shared lock guards every live write, mirroring the locking pattern
/// used by ``FileDescriptorClosure``.
private final class TeeSink: @unchecked Sendable {
static let shared = TeeSink()

private let lock = NSLock()

private init() {}

func write(_ data: Data, to stream: StreamKind) throws {
let handle: FileHandle = (stream == .stdout) ? .standardOutput : .standardError
lock.lock()
defer { lock.unlock() }
try handle.write(contentsOf: data)
}
}

Expand Down
120 changes: 120 additions & 0 deletions Tests/SwiftyShellTests/Core/OutputDestinationTeeTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import Foundation
import Testing

@testable import SwiftyShell

#if canImport(Darwin)
import Darwin
#elseif canImport(Glibc)
import Glibc
#endif

/// Redirects the given process file descriptors (e.g. `STDOUT_FILENO`) to temporary files for the
/// duration of `body`, then restores them and returns the captured contents keyed by descriptor.
///
/// Used to observe the live writes that ``OutputDestination/tee`` makes to the parent process's
/// standard output and standard error without polluting the test runner's own output.
private func capturingStandardStreams(
_ fileDescriptors: [Int32],
during body: () async -> Void
) async throws -> [Int32: String] {
var entries: [(fileDescriptor: Int32, url: URL, handle: FileHandle, saved: Int32)] = []

for fileDescriptor in fileDescriptors {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("tee-\(UUID().uuidString).log")
_ = FileManager.default.createFile(atPath: url.path, contents: nil)
let handle = try FileHandle(forWritingTo: url)
entries.append((fileDescriptor, url, handle, saved: -1))
}

fflush(nil)
for index in entries.indices {
entries[index].saved = dup(entries[index].fileDescriptor)
dup2(entries[index].handle.fileDescriptor, entries[index].fileDescriptor)
}

await body()

fflush(nil)
var results: [Int32: String] = [:]
for entry in entries {
dup2(entry.saved, entry.fileDescriptor)
close(entry.saved)
try? entry.handle.close()
results[entry.fileDescriptor] = (try? String(contentsOf: entry.url, encoding: .utf8)) ?? ""
try? FileManager.default.removeItem(at: entry.url)
}
return results
}

@Suite(.serialized)
struct OutputDestinationTeeTests {
@Test func teeStillCapturesStreamForShellOutput() async throws {
let context = ShellContext()
var captured: ShellOutput?
_ = try await capturingStandardStreams([STDOUT_FILENO]) {
captured = try? await Command("/bin/sh", arguments: "-c", "printf 'tee-capture-text'")
.stdout(.tee)
.run(in: context)
}

let output = try #require(captured)
#expect(output.stdout == "tee-capture-text")
}

@Test func teeWritesLiveToParentStandardOutput() async throws {
let context = ShellContext()
let streams = try await capturingStandardStreams([STDOUT_FILENO]) {
_ = try? await Command("/bin/sh", arguments: "-c", "printf 'tee-live-text'")
.stdout(.tee)
.run(in: context)
}

#expect(streams[STDOUT_FILENO]?.contains("tee-live-text") == true)
}

@Test func teeInterleavesStdoutAndStderrWithoutCorruption() async throws {
let context = ShellContext()
var captured: ShellOutput?
let streams = try await capturingStandardStreams([STDOUT_FILENO, STDERR_FILENO]) {
captured = try? await Command(
"/bin/sh",
arguments: "-c",
"printf 'out-stream'; printf 'err-stream' >&2"
)
.stdout(.tee)
.stderr(.tee)
.run(in: context)
}

let output = try #require(captured)
#expect(output.stdout == "out-stream")
#expect(output.stderr == "err-stream")
#expect(streams[STDOUT_FILENO]?.contains("out-stream") == true)
#expect(streams[STDERR_FILENO]?.contains("err-stream") == true)
}

@Test func teeRespectsOutputLimit() async throws {
let context = ShellContext(defaultOutputLimit: 4)
var thrown: Error?
_ = try await capturingStandardStreams([STDOUT_FILENO]) {
do {
_ = try await Command("/bin/sh", arguments: "-c", "printf 'abcdef'")
.stdout(.tee)
.run(in: context)
Issue.record("Expected outputLimitExceeded")
} catch {
thrown = error
}
}

let error = try #require(thrown as? ShellError)
guard case let .outputLimitExceeded(_, limit, partialOutput) = error else {
Issue.record("Expected outputLimitExceeded, got \(error)")
return
}
#expect(limit == 4)
#expect(partialOutput.stdout == "abcd")
}
}
Loading