Intercepting stdout in Swift
In the process of creating tests for mas,
I needed to validate the text that was being output to stdout for the user.
One way to do this would be to introduce a façade for output.
This output controller would have a production version that uses the typical print
function and the test version stores the strings sent to it so that the values
can be compared to the expected values. However, I figured I would try to
intercept character data sent to stdout instead. While this isn’t too difficult,
restoring the original stdout ended up being rather tricky as there wasn’t a good
example to copy.
Objectives
- Store characters written to stdout in a
String - Pass the unmodified data through so it can be viewed in the Xcode console
- Only used for unit tests
- Don’t break anything
Research
As the first step in SODD, I made sure to Google whether anyone had figured this out before. @ericasadun has a great post on Swift Logging, but it’s from the Swift 1-2 days and a bit dated now. Plus, I dislike calling C functions from Swift and want to minimize the use of C APIs.
I found a newer post by @thesaadismail on Eavesdropping on Swift’s Print Statements which served as my starting point. There are a few key points in his post:
-
dup2can be used to connect aPipeto an existing file handle like stdout - use both an input
Pipeand an outputPipeif you want to have output continue to appear in the Xcode console - don’t read from
Pipes directly as they will block the current thread- instead use
readInBackgroundAndNotify()
- instead use
Implementation
I created a class to hold this funcationality so that it could be reused by different tests.
OutputListener
class OutputListener {
/// consumes the messages on STDOUT
let inputPipe = Pipe()
/// outputs messages back to STDOUT
let outputPipe = Pipe()
/// Buffers strings written to stdout
var contents = ""
}
Here we have the minimal storage for my implementation. inputPipe will bring input to
my test listener, outputPipe will handle sending text back to stdout and contents
will build up a string of all the data that passes through.
init
One-time setup code to wire up the two Pipes and capture contents.
init() {
// Set up a read handler which fires when data is written to our inputPipe
inputPipe.fileHandleForReading.readabilityHandler = { [weak self] fileHandle in
guard let strongSelf = self else { return }
let data = fileHandle.availableData
if let string = String(data: data, encoding: String.Encoding.utf8) {
strongSelf.contents += string
}
// Write input back to stdout
strongSelf.outputPipe.fileHandleForWriting.write(data)
}
}
This uses readabilityHandler instead of notifications for less code and no need to
repeatedly call readInBackgroundAndNotify().
While trying to get this to actually work, I found that calling either readDataToEndOfFile()
or readData(ofLength:) immediately blocks the current thread seemingly forever. This may be
because my inputPipe is still open so the file has no “end”.
availableData is the property to use as it will have a Data object of the character data
written to the pipe’s file handle so far.
openConsolePipe
This is the code that actually wires up the pipes to intercept stdout. It uses the esoteric
dup2 C function.
/// Sets up the "tee" of piped output, intercepting stdout then passing it through.
func openConsolePipe() {
// Copy STDOUT file descriptor to outputPipe for writing strings back to STDOUT
dup2(stdoutFileDescriptor, outputPipe.fileHandleForWriting.fileDescriptor)
// Intercept STDOUT with inputPipe
dup2(inputPipe.fileHandleForWriting.fileDescriptor, stdoutFileDescriptor)
}
stdoutFileDescriptoris my computed property forFileHandle.standardOutput.fileDescriptor, which is the same value asSTDOUT_FILENO, or simply1.
This works, but it’s the one piece of magic from @thesaadismail’s post that I don’t
fully understand. The calls to dup2 return the 2nd argument’s value indicating success,
however there was no change to any fileDescriptor property values as I was expecting.
FileHandle.fileDescriptor is read-only so perhaps the Swift Foundation functionality
doesn’t refresh this value.
Things went swimmingly at this point when running a single test. However, when I ran the entire
mas test suite some calls to print() would blow up with SIGPIPE 💥.
😕
It was clear to me that monkeying with stdout was causing these issues. I attempted to use dup2
to restore stdout to no avail.
💡
Then I recalled an experiment I did a few years ago to suppress all output to stdout in a little
project called nolog.
it uses freopen() to reopen stdout, pointing it to a new file path. nolog redirects stdout to
/dev/null, a well-known way to ignore output from a terminal command.
echo "can anyone hear me?" > /dev/null
Digging around in the /dev directory revealed that macOS has a /dev/stdout file, so I gave that a whirl.
closeConsolePipe
/// Tears down the "tee" of piped output.
func closeConsolePipe() {
// Restore stdout
freopen("/dev/stdout", "a", stdout)
[inputPipe.fileHandleForReading, outputPipe.fileHandleForWriting].forEach { file in
file.closeFile()
}
}
🎉 This was the missing piece I needed to restore stdout. I don’t know if the closeFile()
calls are necessary, especially in a test suite, but I like to clean up after myself 🧹.
Usage
Here’s how it works inside a test.
let output = OutputListener() output.openConsolePipe() let expectedOutput = "hi there" // run code under test that output some text print(expectedOutput, terminator: "") // output is async so need to wait for contents to be updated expect(output.contents).toEventuallyNot(beEmpty()) expect(output.contents) == expectedOutput output.closeConsolePipe()
Here I’m using the Nimble toEventuallyNot
function to take care of the asynchroncity of these file handles as they are
essentially text streams. If you are using XCTest, take a look at
Testing Asynchronous Operations with Expectations.
References
-
OutputListener.swift- used in
infocommand tests
- used in
- nolog
- Eavesdropping on Swift’s Print Statements
- Swift Logging
- Testing Asynchronous Operations with Expectations
- The Weak/Strong Dance in Swift
- stdout
- tee command
SIGPIPE