How to intercept STDOUT and STDERR output in Swift CLI code

My open source images-to-PDF utility, pdfmaker, makes use of Apple’s PDFKit. While this partnership works as it should, just one aspect bothered me: PDFKit warnings and errors don’t bubble up to the calling code, but are piped via STDERR. The upshot: anyone running pdfmaker may see messages that it hasn’t issued. pdfmaker is a CLI tool, but I can’t just redirect the output to /dev/null — you’d lose everything, not just PDFKit’s grumbles. Instead I had to figure out how to sink PDFKit’s output even though it wasn’t coming via pdfmaker. Here’s how I did the pipework.

Steel pope (c) 2024, Tony Smith. All rights reserved.

PDFKit offers no documented call to make it less verbose, and there’s nothing to that effect in its header files. So I have to redirect any output it pipes via STDERR or STDOUT. In fact it uses only the former, but the same process applies to both.

First, create a Pipe object, which is defined by CoreFoundation so you need to include import Foundation, but you’re probably doing that anyway. A pipe is just a communications channel: for example, it’s how messages move from one an application to the Terminal.

From the Pipe instance, get its fileHandleForReading property:

if self.inputPipe == nil {
// Instantiate a new pipe and get its reading handle
self.inputPipe = Pipe()
self.pipeReadHandle = inputPipe!.fileHandleForReading
. . .
}

The fileHandleForReading property has a property of its own: readabilityHandler. This is set to a closure executed when data comes in through the pipe. This provides an opportunity for code to read what PDFKit has output. First, though we have to plumb our pipe in place of STDERR. To do so, use the dup2() function that’s a part of macOS’ foundation layer and which it shares with Linux and other Unix-based operating systems. Here’s how it’s used:

dup2(self.inputPipe!.fileHandleForWriting.fileDescriptor, STDERR_FILENO)

Essentially, this says set STDERR’s target file to that of my pipe, ie. route all data sent via STDERR to inputPipe instead. That data will then be passed into the readabilityHandler closure, which in my case looks like this:

self.pipeReadHandle!.readabilityHandler = { [weak self] fileHandle in
// Make sure `grabber` is still around before continuing
guard let grabberSelf = self else { return }
let data = fileHandle.availableData
if let string = String(data: data, encoding: String.Encoding.utf8 {
grabberSelf.contents += string
}
}

With this in place, the host class’ contents property can be analysed. pdfmaker, for example de-duplicates messages as they come in (there can be one or more for each of the images added to a new PDF files) to allow for more streamlined reporting at the end of the process.

Now, only one PDFKit call made by pdfmaker triggers unwanted output to STDERR:

if let page: PDFPage = PDFPage.init(image: image!) {
. . .
}

The value of image is an NSImage loaded from disk earlier. Really I only want to trap messages issued by PDFKit on this one call: so I wrap it in a couple of local calls, both methods of the interceptor class that pdfmaker implements:

let grabber = OutputGrabber()

. . .

grabber.openConsolePipe()
if let page: PDFPage = PDFPage.init(image: image!) {
pdfKitErr = grabber.closeConsolePipe()
. . .
}

The method openConsolePipe() contains all the code I’ve listed earlier. All closeConsolePipe() contains is the following line to re-connect STDERR in place of my custom pipe:

dup2(self.savedStderr, STDERR_FILENO)

Again, I used dup2() to make the connection. To set the value of savedStderr, I used dup2()’s companion function dup(). Essentially, this duplicates (but doesn’t replace) the specified file descriptor:

private let savedStderr = dup(STDERR_FILENO)

Whatever STDERR’s file descriptor is when pdfmaker runs I store and then restore it after setting SDTERR’s file descriptor to that of my own pipe. After doing so, any messages used by pdfmaker or anyone will appear in the terminal as expected.

Of course, rather than re-routing STDERR on a per-call basis, as I do above, I could just route everything while pdfmaker is running and only re-connect STDERR on exit. But then I’d need to do more work to separate out pdfmaker’s own STDERR output from all the rest. I may look at that for a future release, but the current implementation seems suitable lightweight.

You can examine the full interceptor (grabber) code in my pdfmaker GitHub repo. Learn more about pdfmaker itself at my website.