Gracefully stop a child process before a macOS app quits
By Flavio Copes
Interrupt a media process, escalate after a timeout, finalize its output, and defer macOS application termination until the file is safe.
Closing a recorder app should not destroy the recording.
The child process needs time to stop. The app may also need to repair the media container before macOS terminates it.
This requires a small shutdown protocol.
Interrupt before terminating
When the user presses Stop:
func stop() {
guard process.isRunning else {
return
}
stopWasRequested = true
state = .stopping
process.interrupt()
}
interrupt() sends the equivalent of SIGINT.
Command-line media tools often treat it as a request to flush, close files, and exit.
terminate() is more forceful. Keep it as the fallback.
Add a bounded escalation
let stoppingProcess = process
Task { @MainActor in
do {
try await Task.sleep(for: .seconds(8))
} catch {
return
}
guard stoppingProcess.isRunning else {
return
}
stoppingProcess.terminate()
}
Capture the process you asked to stop. If the controller starts another process during the delay, the timeout must not terminate that new process.
This example assumes the controller and its Process references are isolated to the main actor.
This adds one escalation instead of jumping directly to the stronger signal.
terminate() sends SIGTERM. A process can still catch or ignore it, so this alone does not guarantee a bounded shutdown.
If the application must always exit, add a second deadline and a clearly documented final policy, such as preserving the artifacts and sending SIGKILL.
Choose the timeout for the tool. Eight seconds was enough for this local recorder, not a universal value.
Interpret exit status with user intent
A child process interrupted by the user can exit with a nonzero status.
If a real media file exists, that can still be a successful stop:
if let artifacts,
status == 0 || stopWasRequested {
finalize(artifacts)
return
}
Raw exit code is only one signal.
The product state also depends on:
- whether the user requested the stop
- whether downloading began
- whether recoverable files exist
- whether finalization succeeded
Defer application termination
AppKit lets an application reply later:
func applicationShouldTerminate(
_ sender: NSApplication
) -> NSApplication.TerminateReply {
guard recorder.state.isActive else {
return .terminateNow
}
recorder.stopForApplicationTermination {
sender.reply(
toApplicationShouldTerminate: true
)
}
return .terminateLater
}
The app remains alive while the recorder:
- interrupts the child
- waits for termination
- locates the media files
- remuxes or merges them
- verifies the result
Then it invokes the completion callback.
Preserve the completion callback
private var terminationCompletion: (() -> Void)?
func stopForApplicationTermination(
completion: @escaping () -> Void
) {
terminationCompletion = completion
stop()
}
func finishPendingTermination() {
let completion = terminationCompletion
terminationCompletion = nil
completion?()
}
Clear the callback before invoking it.
This prevents accidental double replies if two cleanup paths meet.
Always finish the termination request
Success is not the only exit path.
If finalization fails during a normal Stop action, show a useful failure state.
During application quit, persist the failure and artifact location before replying to AppKit. The window can disappear immediately after the reply, so an in-memory message may never be seen.
Every cleanup path still needs to reply. Otherwise the application can become stuck in a permanent “trying to quit” state.
The rule is:
Delay quitting while useful recovery work is running, not forever.
Graceful shutdown is part of data integrity when a child process owns the user’s file.
Related posts about swift: