About Airlock
Project page: GitHub - MaximKotliar/Airlock: macOS fork-based process isolation for Swift · GitHub
There's no try you can wrap around a segfault. When a parser walks off the end of an attacker-controlled buffer, or a C library calls abort(), the whole process goes with it — and on macOS the usual fix is to stand up an XPC helper: a separate target, a plist service definition, Mach ports, an IPC protocol. That's the right call for a long-lived service. It's a lot of ceremony when all you wanted was to run one risky function somewhere else and get an error back if it dies.
I wanted the isolation without the ceremony, so I built Airlock. You define a task as a struct whose Input/Output are any Codable type:
struct ThumbnailTask: AirlockTask {
struct Input: Codable {
var sourceURL: URL
var maxPixelSize: Int
}
struct Output: Codable {
var pngData: Data
var pixelSize: CGSize
}
// Runs in the isolated child. A crash in here can't reach the parent.
static func main(_ input: Input) throws -> Output {
let image = try ImageRenderer.load(input.sourceURL)
return Output(pngData: image.encodePNG(max: input.maxPixelSize),
pixelSize: image.size)
}
}
Register the task once at startup, then call run wherever you need it:
Airlock.register(ThumbnailTask.self)
Airlock.engage()
let output = try Airlock.run(ThumbnailTask.self,
input: .init(sourceURL: url, maxPixelSize: 512))
run is synchronous — it blocks until the child exits and either hands you the decoded Output or throws. The crash you couldn't catch becomes an ordinary AirlockError you handle like any other:
do {
let output = try Airlock.run(ThumbnailTask.self, input: input)
use(output)
} catch let error as AirlockError {
switch error {
case .childExitedAbnormally(let status): // SIGABRT, EXC_BAD_ACCESS, ...
log("isolated task crashed: \(status)")
case .childExitCode(let code): // e.g. 4 = task main() threw
log("child exited with \(code)")
case .decodingFailed(let underlying):
log("couldn't decode output: \(underlying)")
default:
throw error // .spawnFailed, etc.
}
}
The mechanism is the interesting part. Once you've ruled out XPC and a separate helper tool as too heavy, the tempting lightweight primitive is fork() — but on macOS that's a trap: only the calling thread survives into the child, so any lock another thread held (the allocator's, say) stays stuck forever, and the Swift runtime can't survive it. So the preferred path doesn't fork. Instead it re-execs the current binary via posix_spawn, and a C-level __attribute__((constructor)) catches the child before main() runs and diverts it into the matching task. Because the child is a fresh process, the full Swift runtime is live inside it: threads, ARC, libdispatch, all normal, none of the classic post-fork hazards. Input and output are Codable values that travel through an anonymous shared-memory region — the parent shm_opens it, unlinks the name before the spawn, and passes only the bare fd, so nothing identifying ever lands in argv or on disk. If the child takes a SIGABRT or an EXC_BAD_ACCESS, run throws an AirlockError and the parent keeps going.
There's also a runUnsafely { } closure API that uses real fork() for when you don't want to define a task type. It is honestly unsafe in the textbook sense: only the forking thread survives, any lock another thread held stays locked forever, and ARC/allocators aren't safe to lean on. The ideal shape there is a C library behind a value-type wrapper on an ARC-free path. The README spells out the boundaries; if they don't fit, the posix_spawn path is the one to use.
Status. Early but real — it's at 0.1.0, the safe run API is the part I'd actually reach for, and I'm still actively working on it. No corporate sponsor, small user base; I'm putting it here because the Swift systems crowd is exactly who can tell me where it's wrong. It's 0BSD-licensed, so there are no strings on using or vendoring it.
Priorities. Getting isolation working with the least ceremony a caller can manage, and being completely honest about the safety boundary between the two APIs.
What I'm hoping for from the community. Mostly feedback, and a second set of eyes on the things I've probably overlooked — limitations, edge cases, and assumptions in the design that don't hold up once someone who knows these layers looks closely. Nothing more concrete than that yet. Issues and PRs welcome.