Leaking Pipes with Swift and External Executables

Observed with MacOS 12.4/Xcode 13.4.1/Swift 5

There are quite a lot of tutorials out there covering the basics of running external executables from within Swift and, with very little effort, it’s quite easy to throw together something like this…

import Foundation

let wrappedUname = Process()
wrappedUname.executableURL = URL(fileURLWithPath: "/usr/bin/uname")
wrappedUname.arguments = ["-v"]
let unameOutputPipe = Pipe()
let unameErrorPipe = Pipe()
wrappedUname.standardOutput = unameOutputPipe
wrappedUname.standardError = unameErrorPipe
do{
    try wrappedUname.run()
} catch {
    print("Unexpected error: \(error).")
}
wrappedUname.waitUntilExit()
let unameOutput = String(decoding: unameOutputPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
let unameError = String(decoding: unameOutputPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
print("Output: " + unameOutput)
print("Error: " + unameError)

Which will run the classic ‘uname‘ command with an argument of ‘-v’, grab and display the standard output, grab and display the standard error, and then exit. And for what it is – a cheap ‘uname -v’ wrapper for an online tutorial – its pretty inoffensive and any issues are quickly swept under the carpet with the programs exits.

The output of our short program.

And that’s it – you’ve run a program with custom arguments, and read its output back.

How to run an external program using Process – hackingwithswift.com

And, as our tutorial says, we’ve run an external program with arguments and read the various outputs back.

However, if we alter this example to run repeatedly run ‘uname -v’ and capture the output so…

import Foundation

while true {
    let wrappedUname = Process()
    wrappedUname.executableURL = URL(fileURLWithPath: "/usr/bin/uname")
    wrappedUname.arguments = ["-v"]
    let unameOutputPipe = Pipe()
    let unameErrorPipe = Pipe()
    wrappedUname.standardOutput = unameOutputPipe
    wrappedUname.standardError = unameErrorPipe
    do{
        try wrappedUname.run()
    } catch {
        print("Unexpected error: \(error).")
    }
    wrappedUname.waitUntilExit()
    let unameOutput = String(decoding: unameOutputPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
    let unameError = String(decoding: unameOutputPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
    print("Output: " + unameOutput)
    print("Error: " + unameError)
}

..we find that, when our while loop ends, it does not clean up the pipes it created and assigned, but leaves them around, leading us to eventually run out of file descriptors. This will be seen when our program with eventually start to produce ‘NSPOSIXErrorDomain‘ errors such as….

Output: Darwin Kernel Version 21.5.0: Tue Apr 26 21:08:22 PDT 2022; root:xnu-8020.121.3~4/RELEASE_X86_64

Error: 
Unexpected error: Error Domain=NSPOSIXErrorDomain Code=9 "Bad file descriptor".

If we examine our running executable with the ‘lsof -p <Program pid>’, we will find our program has created and retained a large number of Pipe file descriptors.

In this instance we reached over 9400 open file descriptors – an egregiously large amount for a program of this nature.

There are two options to fix this issue.

This first – and crudest way – would be to increase the number of file descriptors available to MacOS. This solution does not remove the problem but, rather, hides it a little longer before your system will be affected.

The second – and correct way – is to explicitly close our PIPEs after use. This can be done by calling the ‘<PipeInstance>.fileHandleForWriting.close()‘ procedure.

We can update our demo program such…

while true {
    let wrappedUname = Process()
    wrappedUname.executableURL = URL(fileURLWithPath: "/usr/bin/uname")
    wrappedUname.arguments = ["-v"]
    let unameOutputPipe = Pipe()
    let unameErrorPipe = Pipe()
    wrappedUname.standardOutput = unameOutputPipe
    wrappedUname.standardError = unameErrorPipe
    do{
        try wrappedUname.run()
    } catch {
        print("Unexpected error: \(error).")
    }
    wrappedUname.waitUntilExit()
    let unameOutput = String(decoding: unameOutputPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
    let unameError = String(decoding: unameOutputPipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self)
    try! unameOutputPipe.fileHandleForReading.close()
    try! unameErrorPipe.fileHandleForReading.close()
    print("Output: " + unameOutput)
    print("Error: " + unameError)
}

And, when run, it produces the expected output…

…yet when you inspect the running executable with ‘lsof’ there are only ever two PIPE file handles in existence at any one time.

Now, in reality, you are unlikely to want to repeatedly run the ‘uname’ command. However, the same problem will manifest if you are attempting to run external programs such as ‘ffmpeg‘ or ‘imagemagick‘ or, indeed, any other useful third party utility that you cannot incorporate into your program.