3 October 2024 2min read

It’s a trap! React to Unix signals in Swift.

Sometimes in command line tools we want to perform some cleanup final task before the process is killed. To detect this scenario we can hook to unix signals like an interrupt and perform the needed work before existing from the process.

This post was originally written in 2016-05-29 as a follow up to The state of Swift scripting and discussed my old library Trap in GitHub. Since then the ecosystem has grown a lot so it has been updated in 2024 with a more modern approach.

In the server ecosystem this is very well handled with the swift-service-lifecycle package, that brings support for graceful shutdown to concurrency tasks. What might surprise you is that the same package exposes a module for handling unix signals, without the service part. I find this ideal for command line utilities that don’t require the full service lifecycle of a server application but still want to handle unix signals.

To include this module you just need to depend on the library, but instead of including the service module into your target, you just include UnixSignals.

dependencies: [
        .package(url: "https://github.com/swift-server/swift-service-lifecycle.git", exact: "2.6.1")
    ],
    targets: [
        .executableTarget(
            name: "...",
            dependencies: [
                .product(name: "UnixSignals", package: "swift-service-lifecycle")
            ]
        ),
    ]

With this implace you can now import the UnixSignals module and instantiate a UnixSignalsSequence with the specific signals you want to listen to.

import UnixSignals

for await signal in await UnixSignalsSequence(trapping: .sigint) {
    print(signal)
}

If you run this on your terminal and try to interrupt it with Ctrl + C you will see ^CSIGINT printed in the console but the process won’t stop. Now that you are handling the interruption is up to you to actually exit from the process. Isn’t it nice how respecful unix is?

If you want to kill the process use Ctrl + Z. This will send a different signal and since we’re not handling that one it will behave as default and kill the proess. Alternatively just use Activity Monitor and force quit it.

With this power in our hands we can see an example of how it would work. In the following code we spawn two child tasks, one keeps a running counter that increments after a few miliseconds; the other awaits for an interrupt signal to be triggered.

await withTaskGroup(of: Void.self) { group in
    group.addTask {
        var i = 0
        while true {
            print(i)
            i += 1
            try? await Task.sleep(for: .milliseconds(500))
        }
    }
    group.addTask {
        for await signal in await UnixSignalsSequence(trapping: .sigint) {
            print(signal)
            print("Shutting down...")
            try? await Task.sleep(for: .seconds(2))
            exit(EXIT_FAILURE)
        }
    }
}

Running this and using Ctrl + C we can see how the running counter keeps printing for a few extra seconds before the process is closed.

0
1
2
^CSIGINT
Shutting down...
3
4
5
6
7

This illustrates how we can have extra time to perform a gracefull shutdown of whatever process we are running.

If you need something more complex make sure to check out all the capabilities from swift-service-lifecycle.

If you enjoyed this post

Continue reading