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.

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.

Three ways to stop

There’s a hierarchy of stops.

interrupt() sends SIGINT, the same signal as Ctrl+C. A well-behaved tool catches it and finalizes its output.

terminate() sends SIGTERM, a firmer request. Most tools treat it the same way, but the intent is “stop now”.

SIGKILL cannot be caught at all. The process stops mid-instruction, files half-written.

Model the shutdown as a sequence

Give it 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)
  }
}

waitForExit is a small helper you write: it waits for terminationHandler to fire or the timeout to pass, whichever comes first.

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’s happening instead of freezing on a spinner.

Pick the deadline from the work

A tool finalizing an MP4 container may 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.

When your app is the one quitting

The same discipline applies when your app is asked to stop. If the user quits while an export runs, don’t 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. Users experience it as “it won’t close” and solve it with Force Quit, which defeats the whole effort.

Never start with SIGKILL

The mistake to avoid is the opposite extreme: reaching for SIGKILL first because it’s reliable. It is reliable at stopping the process. It’s 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 with a script that traps SIGINT and ignores it. Confirm your escalation fires and the app reports that cleanup was cut short.

Lesson completed