Skip to content
Open
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
17 changes: 14 additions & 3 deletions Sources/Fluid/Services/TerminalService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -108,12 +108,23 @@ final class TerminalService {
}
}

// Drain stdout and stderr concurrently while the process is still
// running. A child that writes more than the pipe buffer (~64KB)
// blocks on write() until its output is read, so reading only after
// waitUntilExit() would deadlock until the timeout fired and return
// truncated output. Background reads keep both pipes drained so the
// process can run to completion.
let outputHandle = outputPipe.fileHandleForReading
let errorHandle = errorPipe.fileHandleForReading
async let pendingOutput = Task.detached { outputHandle.readDataToEndOfFile() }.value
async let pendingError = Task.detached { errorHandle.readDataToEndOfFile() }.value
Comment on lines +119 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Limit captured terminal output while draining pipes

When Command Mode runs a command that produces unbounded or very large output, such as yes or a verbose build that runs until the 30s timeout, these detached reads now keep the pipes drained while accumulating the entire stream in Data before returning. That removes the previous OS-pipe-buffer cap, so the app can allocate hundreds of MB/GB and be killed before the timeout; please keep draining but impose a maximum captured byte count/truncation policy.

Useful? React with 👍 / 👎.

Comment on lines +119 to +120

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Cap terminal output while draining pipes

When Command Mode runs a command that produces unbounded or very high-volume output, such as yes, tail -f, or dumping a large file, these detached readDataToEndOfFile() calls now keep draining and accumulating the entire stream in memory until the process exits or the 30s timeout kills it. Removing the pipe backpressure fixes the 64KB deadlock, but without a maximum retained byte count this can consume large amounts of app memory and then append/send an enormous JSON tool result; consider draining while retaining only a bounded prefix/suffix or terminating once a limit is exceeded.

Useful? React with 👍 / 👎.


let outputData = await pendingOutput
let errorData = await pendingError

process.waitUntilExit()
timeoutTask.cancel()

let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile()
let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile()

let output = String(data: outputData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
let errorOutput = String(data: errorData, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines)

Expand Down
33 changes: 33 additions & 0 deletions Tests/FluidDictationIntegrationTests/DictationE2ETests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -691,6 +691,39 @@ final class DictationE2ETests: XCTestCase {
)
}

func testTerminalServiceExecute_largeStdoutIsNotTruncatedAndReturnsQuickly() async {
// Regression for a pipe-buffer deadlock: execute() used to call
// waitUntilExit() before draining stdout, so a command writing more than
// the ~64KB pipe buffer blocked on write() until the 30s timeout fired,
// returning truncated output with success == false. Draining stdout and
// stderr concurrently lets the command run to completion.
let service = TerminalService()
let lineCount = 200_000

let result = await service.execute(command: "seq 1 \(lineCount)")

XCTAssertTrue(result.success, "Expected success, got exitCode \(result.exitCode) error \(result.error ?? "nil")")
XCTAssertEqual(result.exitCode, 0)
XCTAssertGreaterThan(result.output.utf8.count, 64 * 1024, "Output should exceed the 64KB pipe buffer")
XCTAssertTrue(result.output.hasSuffix("\(lineCount)"), "Last line should be complete, output was truncated")
XCTAssertEqual(result.output.split(separator: "\n").count, lineCount)
XCTAssertLessThan(result.executionTimeMs, 15000, "Should return promptly, not after the ~30s timeout")
}

func testTerminalServiceExecute_largeStderrIsNotTruncated() async throws {
// The same deadlock applied to stderr; both pipes must be drained concurrently.
let service = TerminalService()
let lineCount = 200_000

let result = await service.execute(command: "seq 1 \(lineCount) 1>&2")

XCTAssertTrue(result.success, "Expected success, got exitCode \(result.exitCode)")
let stderr = try XCTUnwrap(result.error)
XCTAssertGreaterThan(stderr.utf8.count, 64 * 1024, "Stderr should exceed the 64KB pipe buffer")
XCTAssertTrue(stderr.hasSuffix("\(lineCount)"), "Last stderr line should be complete, output was truncated")
XCTAssertLessThan(result.executionTimeMs, 15000, "Should return promptly, not after the ~30s timeout")
}

private static func modelDirectoryForRun() -> URL {
// Use a stable path on CI so GitHub Actions cache can speed up runs.
if ProcessInfo.processInfo.environment["GITHUB_ACTIONS"] == "true" ||
Expand Down
Loading