Make the app reliable
Stop a child process gracefully
Request a clean stop, wait for bounded cleanup, and escalate only when the child does not finish.
12 minute lesson
Starting a child process is easy. Stopping one is where apps corrupt files. Imagine the child is exporting a video, or compacting the notes database — kill it mid-write and the output is trash.
There is a hierarchy of stops. interrupt() sends SIGINT, the same signal as Ctrl+C, and a well-behaved tool catches it and finalizes its output. terminate() sends SIGTERM, a firmer request. SIGKILL cannot be caught at all — the process stops mid-instruction, files half-written.
Model the shutdown as a sequence with a deadline:
func stop(_ process: Process, deadline: TimeInterval = 5) async {
process.interrupt()
let exited = await waitForExit(process, timeout: deadline)
if !exited {
process.terminate()
_ = await waitForExit(process, timeout: 2)
}
}
First the polite request, then a bounded wait, then escalation. Each step is observable: your model can publish stopping, waiting, and forceStopping states, and the interface can show what is happening instead of freezing on a spinner.
Pick the deadline from what the child actually does. A tool finalizing an MP4 container may legitimately need a few seconds. Five is a reasonable default; forever is not. And when you do escalate, record it — a forced stop means the output may be incomplete, and the app should say so rather than present a broken file as done.
The same discipline applies when your app is the one being asked to stop. If the user quits while an export runs, do not vanish and orphan the child. Add an NSApplicationDelegate through @NSApplicationDelegateAdaptor and implement:
func applicationShouldTerminate(
_ sender: NSApplication
) -> NSApplication.TerminateReply {
guard exporter.isRunning else { return .terminateNow }
Task {
await exporter.stopGracefully()
NSApplication.shared.reply(toApplicationShouldTerminate: true)
}
return .terminateLater
}
.terminateLater pauses the quit while your cleanup runs. The contract is strict: you must eventually call reply(toApplicationShouldTerminate:), on every path — success, failure, timeout. Miss one path and the app hangs at quit, which users experience as “it won’t close” and solve with Force Quit, defeating the whole effort.
The mistake to avoid is the opposite extreme: reaching for SIGKILL first because it is reliable. It is reliable at stopping the process and equally reliable at corrupting whatever the process was writing. Escalate to it. Never start with it.
Test the full ladder. Start a long export and quit the app: it should delay briefly, then close, and the output file should be valid. Then simulate a hung child — a script that traps SIGINT and ignores it — and confirm your escalation fires and reports that cleanup was cut short.
Lesson completed