Skip to content

Tests/CLITests: drain stdout/stderr concurrently in CLITest.run#1471

Merged
katiewasnothere merged 3 commits into
apple:mainfrom
mvanhorn:fix/1456-drain-stdout-stderr-concurrently
Apr 30, 2026
Merged

Tests/CLITests: drain stdout/stderr concurrently in CLITest.run#1471
katiewasnothere merged 3 commits into
apple:mainfrom
mvanhorn:fix/1456-drain-stdout-stderr-concurrently

Conversation

@mvanhorn

Copy link
Copy Markdown
Contributor

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update

Motivation and Context

CLITest.run(arguments:stdin:currentDirectory:env:) reads stdout to EOF before stderr begins draining (Tests/CLITests/Utilities/CLITest.swift:182-183). If a child process writes more than a pipe-buffer's worth of data to stderr (~64 KB on macOS), the child blocks in write() on stderr while we block in readDataToEndOfFile() on stdout, and neither side makes progress until process.waitUntilExit() returns. This is a latent deadlock that any future test or CLI verbosity bump can trigger.

This PR drains both streams concurrently using readabilityHandler closures backed by Mutex<Data> buffers. After process.waitUntilExit() returns the handlers are cleared and readDataToEndOfFile() flushes any bytes the kernel buffered between the last handler invocation and exit. The error path also clears handlers so a failed process.run() does not leak callbacks. Mutex matches the locking primitive the file already uses for commandSeq.

Fixes #1456

Testing

  • Tested locally
  • Added/updated tests
  • Added/updated docs

swift build --target CLITests passes against the change. make swift-fmt-check is clean. Behavior is unchanged for any test command that previously fit within a single pipe buffer, so the existing CLITests still exercise the helper end-to-end.

I did not add a new test that emits >64 KB to stderr because every existing CLITest invocation goes through executablePath (the container binary), and reproducing the deadlock requires a child that emits a controllable amount on stderr. Happy to follow up with a small refactor that extracts the drain into a static helper plus a regression test that drives it via /bin/sh if that would be useful.

The helper's `run(arguments:stdin:currentDirectory:env:)` method reads
stdout to EOF before stderr begins draining, which deadlocks any child
process that writes more than a pipe-buffer's worth of data to stderr
(roughly 64 KB on macOS): the child blocks in `write()` on stderr while
we block in `readDataToEndOfFile()` on stdout, and neither side makes
progress until `process.waitUntilExit()` returns. We are not seeing this
today because no test command emits that much stderr, but it is a latent
deadlock that any future test or CLI verbosity bump can trigger.

Fix it by attaching `readabilityHandler` closures to both pipes so
stdout and stderr drain concurrently into `Mutex<Data>`-protected
buffers while the process runs. After `waitUntilExit()` we clear the
handlers and call `readDataToEndOfFile()` once on each pipe to flush
any remaining bytes the kernel buffered between the last handler firing
and process exit. Mutex matches the locking primitive the file already
uses for `commandSeq`. The error path also clears the handlers so a
failed `process.run()` does not leak callbacks.

Behavior is unchanged for any test that previously fit within a single
pipe buffer.

Fixes apple#1456
@mvanhorn mvanhorn mentioned this pull request Apr 29, 2026
3 tasks
@katiewasnothere

Copy link
Copy Markdown
Contributor

Hi @mvanhorn thank you for contributing! What are your thoughts on redirecting the out/err to FileHandles vs using readabilityhandlers?

@mvanhorn

Copy link
Copy Markdown
Contributor Author

Both are valid - the issue listed each as acceptable. I went with readabilityHandler because the diff stays in one file (no temp-file lifecycle or error-path cleanup) and Mutex is already used in CLITest.swift, so guarding the buffer matches the existing style.

The FileHandle route is arguably simpler to reason about (kernel handles buffering, no two-stage drain) at the cost of FS setup and cleanup per test.

If you'd prefer the FileHandle version, happy to swap - it's a clean rewrite of the same block. Which way would you like?

@mvanhorn

Copy link
Copy Markdown
Contributor Author

also @katiewasnothere I live on Mercer Island! PNW represent!

@katiewasnothere

Copy link
Copy Markdown
Contributor

@mvanhorn I would prefer the file based option. Many CLITests and helper functions already follow a pattern of creating and cleaning up temporary directories and files.

Switch from readabilityHandler/Mutex buffers to FileHandle redirect, per
review feedback on apple#1471. Matches the temp-directory pattern already used
elsewhere in CLITests (e.g. withTempDir, TestCLIRunCommand temp files):
create a UUID-named directory under FileManager.default.temporaryDirectory,
write stdout/stderr through FileHandle, and clean up with `defer`.

Removes the two-stage drain (handler + post-waitUntilExit readDataToEndOfFile)
in favor of letting the kernel buffer to disk - eliminates the lock and the
post-exit handler-clearing edge case.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Done in 20dddd2. Followed the withTempDir pattern already in CLITest.swift - UUID-named dir under FileManager.default.temporaryDirectory, FileHandle for the writes, defer cleanup.

Net -13 lines (no Mutex, no handler-clearing, no two-stage drain).

@jglogan jglogan requested a review from katiewasnothere April 30, 2026 01:34

let stdoutURL = tempDir.appendingPathComponent("stdout")
let stderrURL = tempDir.appendingPathComponent("stderr")
FileManager.default.createFile(atPath: stdoutURL.path, contents: nil)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@mvanhorn How about using defer here?

             let stdoutHandle = try FileHandle(forWritingTo: stdoutURL)
             let stderrHandle = try FileHandle(forWritingTo: stderrURL)
+            defer { try? stdoutHandle.close() }
+            let stderrHandle = try FileHandle(forWritingTo: stderrURL)
+            defer { try? stderrHandle.close() }
             process.standardOutput = stdoutHandle
             process.standardError = stderrHandle
 
             try process.run()
             process.waitUntilExit()
-
-            try? stdoutHandle.close()
-            try? stderrHandle.close()
-
             outputData = try Data(contentsOf: stdoutURL)
             errorData = try Data(contentsOf: stderrURL)

Per review feedback on apple#1471: move stdout/stderr handle close calls into
`defer` blocks immediately after each FileHandle is opened. Same net
effect as the explicit close calls after waitUntilExit, but covers the
`try process.run()` throw path too.

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
@mvanhorn

Copy link
Copy Markdown
Contributor Author

Done in 71c649e. The defer approach also covers the try process.run() throw path, which the explicit close calls after waitUntilExit() didn't.

@katiewasnothere katiewasnothere merged commit b73933e into apple:main Apr 30, 2026
3 checks passed
noah-thor pushed a commit to noah-thor/container that referenced this pull request May 6, 2026
…e#1471)

## Type of Change
- [x] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update

## Motivation and Context

`CLITest.run(arguments:stdin:currentDirectory:env:)` reads stdout to EOF
before stderr begins draining
(`Tests/CLITests/Utilities/CLITest.swift:182-183`). If a child process
writes more than a pipe-buffer's worth of data to stderr (~64 KB on
macOS), the child blocks in `write()` on stderr while we block in
`readDataToEndOfFile()` on stdout, and neither side makes progress until
`process.waitUntilExit()` returns. This is a latent deadlock that any
future test or CLI verbosity bump can trigger.

This PR drains both streams concurrently using `readabilityHandler`
closures backed by `Mutex<Data>` buffers. After
`process.waitUntilExit()` returns the handlers are cleared and
`readDataToEndOfFile()` flushes any bytes the kernel buffered between
the last handler invocation and exit. The error path also clears
handlers so a failed `process.run()` does not leak callbacks. `Mutex`
matches the locking primitive the file already uses for `commandSeq`.

Fixes apple#1456

## Testing

- [x] Tested locally
- [ ] Added/updated tests
- [ ] Added/updated docs

`swift build --target CLITests` passes against the change. `make
swift-fmt-check` is clean. Behavior is unchanged for any test command
that previously fit within a single pipe buffer, so the existing
CLITests still exercise the helper end-to-end.

I did not add a new test that emits >64 KB to stderr because every
existing CLITest invocation goes through `executablePath` (the
`container` binary), and reproducing the deadlock requires a child that
emits a controllable amount on stderr. Happy to follow up with a small
refactor that extracts the drain into a static helper plus a regression
test that drives it via `/bin/sh` if that would be useful.

---------

Signed-off-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Co-authored-by: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Deadlock risk in CLI tests.

3 participants