# Gracefully stop a child process before a macOS app quits

> Interrupt a media process, escalate after a timeout, finalize its output, and defer macOS application termination until the file is safe.

Author: [Flavio Copes](https://flaviocopes.com/about/) | Published: 2026-08-20 | Topics: [Swift](https://flaviocopes.com/tags/swift/) | Canonical: https://flaviocopes.com/gracefully-stop-child-process-macos/

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:

~~~swift
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

~~~swift
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:

~~~swift
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:

~~~swift
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:

1. interrupts the child
2. waits for termination
3. locates the media files
4. remuxes or merges them
5. verifies the result

Then it invokes the completion callback.

## Preserve the completion callback

~~~swift
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.
