Tests/CLITests: drain stdout/stderr concurrently in CLITest.run#1471
Conversation
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
|
Hi @mvanhorn thank you for contributing! What are your thoughts on redirecting the out/err to FileHandles vs using readabilityhandlers? |
|
Both are valid - the issue listed each as acceptable. I went with The If you'd prefer the |
|
also @katiewasnothere I live on Mercer Island! PNW represent! |
|
@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>
|
Done in 20dddd2. Followed the Net -13 lines (no Mutex, no handler-clearing, no two-stage drain). |
|
|
||
| let stdoutURL = tempDir.appendingPathComponent("stdout") | ||
| let stderrURL = tempDir.appendingPathComponent("stderr") | ||
| FileManager.default.createFile(atPath: stdoutURL.path, contents: nil) |
There was a problem hiding this comment.
@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>
|
Done in 71c649e. The |
…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>
Type of Change
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 inwrite()on stderr while we block inreadDataToEndOfFile()on stdout, and neither side makes progress untilprocess.waitUntilExit()returns. This is a latent deadlock that any future test or CLI verbosity bump can trigger.This PR drains both streams concurrently using
readabilityHandlerclosures backed byMutex<Data>buffers. Afterprocess.waitUntilExit()returns the handlers are cleared andreadDataToEndOfFile()flushes any bytes the kernel buffered between the last handler invocation and exit. The error path also clears handlers so a failedprocess.run()does not leak callbacks.Mutexmatches the locking primitive the file already uses forcommandSeq.Fixes #1456
Testing
swift build --target CLITestspasses against the change.make swift-fmt-checkis 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(thecontainerbinary), 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/shif that would be useful.