Skip to content

macos: graceful child process termination - #9760

Open
jparise wants to merge 9 commits into
ghostty-org:mainfrom
jparise:macos-graceful-termination
Open

jparise wants to merge 9 commits into
ghostty-org:mainfrom
jparise:macos-graceful-termination

Conversation

@jparise

@jparise jparise commented Nov 30, 2025 •

Copy link
Copy Markdown
Contributor

Our previous application termination path (e.g. Cmd-Q) ultimately led to Surface.deinit, which uses a detached Task to free its Ghostty surface. That didn't give the child processes (e.g. bash) a chance to receive a SIGHUP before the app completely exits, potentially resulting in lost shell history and un-run SIGHUP handlers.

This change implements a more graceful termination sequence that explicitly signals the child processes to hang up before PTY teardown.

All of the existing close/undo/termination behaviors remain unchanged (e.g. confirmation, quit-on-last-window-closed, etc.).

Verified using:

#!/bin/bash
trap 'echo "SIGHUP at $(date)" >> /tmp/ghostty-sigup.log' SIGHUP
echo "Waiting for SIGHUP... "
read -p "Press Cmd+Q to quit Ghostty..."

Fixes: #9773

@jparise
jparise requested a review from a team as a code owner November 30, 2025 16:18
Comment thread macos/Sources/App/macOS/AppDelegate.swift Outdated
@jparise
jparise marked this pull request as draft November 30, 2025 20:15
@jparise

This comment was marked as outdated.

@jparise
jparise marked this pull request as ready for review November 30, 2025 21:41
@jparise
jparise requested a review from mitchellh December 8, 2025 15:16

@mitchellh mitchellh left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. This is a real problem, but I don't love that we dig directly into the IO backend from embedded.zig. The intention of deinit is that we should be waiting for the process to exit, it shouldn't require a separate call and wait.

@jparise

jparise commented Dec 11, 2025 •

Copy link
Copy Markdown
Contributor Author

Thanks for this. This is a real problem, but I don't love that we dig directly into the IO backend from embedded.zig. The intention of deinit is that we should be waiting for the process to exit, it shouldn't require a separate call and wait.

The ghostty_surface_free path (that calls Surface.deinit) is ... complicated. Here's where we call it from the macOS side:

deinit {
// deinit is not guaranteed to happen on the main actor and our API
// calls into libghostty must happen there so we capture the surface
// value so we don't capture `self` and then we detach it in a task.
// We can't wait for the task to succeed so this will happen sometime
// but that's okay.
let surface = self.surface
Task.detached { @MainActor in
ghostty_surface_free(surface)
}
}

But further, deinit itself isn't guaranteed to be run on application terminate. (This behavior appears to be deliberately left ambiguous by Apple, but the observed behavior is that the OS has the freedom to bulk-free an application's objects on termination without running finalizers.)

If you combine those two things (detached/async ghostty_surface_free and deinit not guaranteed on run on termination), we have a pretty constrained set of options:

  1. Block on ghostty_surface_free (always or at least during termination).
  2. Perform a two phase shutdown (stop+wait and free).

I initially implemented a version of (1) in 2c46952ef198fc41036a7b3b4d6721919862ca57 because I also wanted to (re)use the ghostty_surface_free path. I ran into problems where freeing the surface before macOS app's Surface.deinit challenged the expected surface lifetime. For example, because we'd delayed app termination to ensure the processes finish before a brutal kill, the surface is still the target of I/O events, so we'd run into crashes.

There are some other related concerns with the ghostty_surface_free path that we'd also need to think through:

a. We need (I think?) to sequentially free the surfaces due to the main thread requirement, rather than quickly signalling all of the child processes and then waiting on the batch.
b. ghostty_surface_free blocks on killPid, which waits forever: https://github.com/ghostty-org/ghostty/blob/75953d7ca761ce17f9a06e5197e0b81dad7c1855/src/termio/Exec.zig#L1171-L1175

I then implemented a version of (2) in 75953d7ca761ce17f9a06e5197e0b81dad7c1855, by way of the new ghostty_surface_process_stop. If the main concern is with the direct IO backend interaction, I can think of two other things to try:

a. Add a "stop" function to Surface as an indirection so that the embedded apprt can stop the child process through a more formal interface. (edit: 0bce07d92f41c5f9c33d93229626d91cc538971b)
b. Instead of stopping the process, add a way to move the surface into a "shutting down" state that would make it possible to call ghostty_surface_free earlier and avoid some of the problems above.

And of course any other ideas that folks might have.

@jparise
jparise requested a review from a team as a code owner December 12, 2025 14:59
@jparise
jparise requested a review from mitchellh December 22, 2025 14:18
Comment thread macos/Sources/App/macOS/AppDelegate.swift Outdated
Comment thread macos/Sources/App/macOS/AppDelegate.swift Outdated
@jparise
jparise force-pushed the macos-graceful-termination branch from 0bce07d to 6a5f9ec Compare December 23, 2025 14:27
@jparise
jparise requested a review from bo2themax December 23, 2025 15:29
@jparise
jparise force-pushed the macos-graceful-termination branch from 6a5f9ec to f69782b Compare December 23, 2025 15:31
@luisdavim

Copy link
Copy Markdown

I'm sorry for asking but, is there any update on this?

@jparise
jparise force-pushed the macos-graceful-termination branch from f69782b to 67f3b91 Compare January 12, 2026 23:40
@jparise

jparise commented Jan 12, 2026 •

Copy link
Copy Markdown
Contributor Author

I'm sorry for asking but, is there any update on this?

I just freshened it so it will cleanly merge with main. Still needs @mitchellh to revisit his review.

@jparise
jparise force-pushed the macos-graceful-termination branch from 67f3b91 to d864375 Compare February 20, 2026 15:56
@jparise
jparise force-pushed the macos-graceful-termination branch from d864375 to 8110b65 Compare March 4, 2026 16:29
@jparise
jparise force-pushed the macos-graceful-termination branch from 8110b65 to 8da6010 Compare May 4, 2026 14:11
@jparise

jparise commented May 4, 2026

Copy link
Copy Markdown
Contributor Author

@mitchellh what do you think of the ghostty_surface_process_stop-based approach that I last implemented? Does that provide the right level of abstraction you were looking for, or:

b. Instead of stopping the process, add a way to move the surface into a "shutting down" state that would make it possible to call ghostty_surface_free earlier and avoid some of the problems above.

@jparise
jparise force-pushed the macos-graceful-termination branch from eda6cb5 to 9a44fc0 Compare May 17, 2026 15:05
@jparise

jparise commented May 17, 2026 •

Copy link
Copy Markdown
Contributor Author

@mitchellh I reworked this around a new ghostty_app_quit function that coordinates the teardown work in the apprt. This means we can continue using the deinit path while still giving child processes some time to cleanly hangup.

@bo2themax This also simplifies the macOS-specific changes.

GTK and others could adopt something similar to ghostty_app_quit to realize the same benefits if needed, but I'll leave that to those maintainers.

Comment thread src/termio/Exec.zig Outdated
@jparise
jparise force-pushed the macos-graceful-termination branch from 9a44fc0 to 0bf1f75 Compare May 22, 2026 18:38
@jparise

jparise commented May 22, 2026

Copy link
Copy Markdown
Contributor Author

@bo2themax rebased on top of #12742

@jparise
jparise requested a review from bo2themax July 25, 2026 13:44
@jparise

jparise commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

@mitchellh I iterated on this some more, and I believe we can address the original issue by explicitly sending SIGHUP to all child processes immediately before application termination. Previously, PTY teardown could cause bash to begin its normal exit handling before it got the PTY-generated SIGHUP, preventing it from writing history. Sending SIGHUP first produces the expected ordering without requiring us to wait for the child processes to exit.

I verified this with both a delayed SIGHUP trap handler and bash’s built-in history writing. The handler continued after Ghostty exited, confirming that we don’t need to delay/block termination or drain the mailbox.

@mitchellh

Copy link
Copy Markdown
Contributor

Would it work to simplify this and just send the hangup before the kill? Or are we relying on some implicit delay timing here? Or am I misunderstanding the whole thing and our app quitting happens too fast before the kill (would still be relying on some implicit timing then for the sighup?)

@jparise

jparise commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Would it work to simplify this and just send the hangup before the kill? Or are we relying on some implicit delay timing here? Or am I misunderstanding the whole thing and our app quitting happens too fast before the kill (would still be relying on some implicit timing then for the sighup?)

The child process termination in this case isn't initiated through our kill* functions. Ghostty exits, the OS closes the PTY, and the resulting PTY teardown causes the child to exit. Our kill* functions already send SIGHUP (not SIGKILL) through Subprocess.stop() during surface deinit.

The root problem is that this deinit path is not reliably called when the macOS application terminates. Swift object finalizers are not guaranteed to run, and when Ghostty.Surface.deinit does run, it schedules ghostty_surface_free in a detached task that may not execute before the application exits.

My latest approach here (ghostty_app_quit) makes the hangups explicit before the application terminates, ensuring the signal is sent before PTY teardown (but we don't need to wait for it to be handled).

@jparise

jparise commented Jul 25, 2026 •

Copy link
Copy Markdown
Contributor Author

If ghostty_app_quit itself doesn't feel right, I think we could also expose a surface-level function that (just) sends the hangup signal, and we could call it for all of our active (macOS) surfaces in the termination path (i.e. implement ghostty_quit_app in the app itself, using a new ghostty_surface_hangup or similar).

That's close to what I had originally on this branch, except I was more focused on cleaning up the surface resources then too.

Or, maybe ghostty_app_quit is renamed ghostty_app_hangup. I think it's still nicer to do it in the apprt because it owns the authoritative set of surfaces.

@mitchellh

Copy link
Copy Markdown
Contributor

Okay okay I think I get it. Sorry I know you've been working on this a while so forgive the catch-up again.

The root problem is that this deinit path is not reliably called when the macOS application terminates. Swift object finalizers are not guaranteed to run, and when Ghostty.Surface.deinit does run, it schedules ghostty_surface_free in a detached task that may not execute before the application exits.

Okay, alternate idea. Would it be cleaner to make surfaces have a free-path that isn't through deinit (but is idempotent so when deinit is called its safe). Then on quite we can iterate through all open surfaces and force the free?

@mitchellh

Copy link
Copy Markdown
Contributor

To add-on here, my motivation is just to try to bring together all the "close" paths through one path rather than more APIs.

@jparise

jparise commented Jul 25, 2026

Copy link
Copy Markdown
Contributor Author

The root problem is that this deinit path is not reliably called when the macOS application terminates. Swift object finalizers are not guaranteed to run, and when Ghostty.Surface.deinit does run, it schedules ghostty_surface_free in a detached task that may not execute before the application exits.

Okay, alternate idea. Would it be cleaner to make surfaces have a free-path that isn't through deinit (but is idempotent so when deinit is called its safe). Then on quite we can iterate through all open surfaces and force the free?

That would be similar to where I started in c7bde6a0cdf0b77e831d10021b7520eda6e997fe. The main problem is that our "surface free" operation destroys the underlying (C) allocation, so we'd need to make all Swift-level surface access safe, probably by making the ghostty_surface_t an optional:

private var surface: ghostty_surface_t?

@MainActor
func free() {
    guard let surface else { return }
    self.surface = nil
    ghostty_surface_free(surface)
}

deinit {
    // call free()
}

There was some other complication I ran into back then, too, I think related to in-flight callbacks reentering and during the destruction, but maybe that's just a matter of guarding surface in more places.

@jparise
jparise force-pushed the macos-graceful-termination branch 2 times, most recently from 7f9452c to 90056c9 Compare July 26, 2026 12:34
@jparise

jparise commented Jul 26, 2026

Copy link
Copy Markdown
Contributor Author

The root problem is that this deinit path is not reliably called when the macOS application terminates. Swift object finalizers are not guaranteed to run, and when Ghostty.Surface.deinit does run, it schedules ghostty_surface_free in a detached task that may not execute before the application exits.

Okay, alternate idea. Would it be cleaner to make surfaces have a free-path that isn't through deinit (but is idempotent so when deinit is called its safe). Then on quite we can iterate through all open surfaces and force the free?

I implemented this approach, and it works well across all of my local tests. It became possible once I realized we don't need to explicitly wait on signal delivery (the previous commit), so a better outcome overall!

@jparise
jparise force-pushed the macos-graceful-termination branch 2 times, most recently from 8ef4d46 to 55ca178 Compare July 26, 2026 21:56
@jparise
jparise force-pushed the macos-graceful-termination branch from 55ca178 to b7772b0 Compare August 14, 2026 15:40
@jparise
jparise force-pushed the macos-graceful-termination branch from b7772b0 to 6907a8a Compare August 31, 2026 11:58
@jparise

jparise commented Sep 1, 2026

Copy link
Copy Markdown
Contributor Author

@mitchellh give this another look, potentially for 1.4?

@jparise
jparise force-pushed the macos-graceful-termination branch from 6907a8a to c321752 Compare October 19, 2026 14:23
Our previous application termination path (e.g. Cmd-Q) ultimately led to
Surface.deinit, which uses a detached Task to free its Ghostty surface.
That didn't give the child processes (e.g. bash) a chance to receive a
SIGHUP before the app completely exits, potentially resulting in list
shell history and un-run SIGHUP handlers.

This change implements a more graceful termination sequence that
synchronously frees all of our surfaces (via ghostty_surface_free) and
then delays application termination by a short amount (currently 100ms)
to give the child processes some time to perform their cleanup work.

All of the existing close/undo/termination behaviors remain unchanged
(e.g. confirmation, quit-on-last-window-closed, etc.).

Verified using:

    #!/bin/bash
    trap 'echo "SIGHUP at $(date)" >> /tmp/ghostty-sigup.log' SIGHUP
    echo "Waiting for SIGHUP... "
    read -p "Press Cmd+Q to quit Ghostty..."
This exported function allows the app to stop a surface's child process.
ghostty_surface_process_exited() can be used to check whether it's done
stopping.
Freeing the surfaces directly was a flawed approach because its a
fundamental structure within the application.

Instead, we now gather all of the surfaces and stop their processes. The
surfaces remain alive which allows us to poll them and delay application
termination (up to a time limit) until they've exited.
This keeps the IO backend interaction inside of the owning Surface code.
Replaces the per-surface stop+poll dance with a single libghostty
entry point that hangs up all child processes in parallel and waits
(up to a caller-provided timeout) for them to exit.

The per-surface Subprocess.stop now bounds its own wait time, too, so a
stuck child can't stall application teardown.
SIGHUP delivery occurs before PTY teardown and does not require Ghostty
to remain alive while shells handle it. Signal all child process groups
and terminate immediately rather than draining the mailbox and waiting
for each process to exit.

Restore the existing subprocess stop behavior so this termination path
does not change normal surface teardown.
Replace the app-level quit API with an explicit, idempotent free path on
the Swift Surface model. That object clears its `ghostty_surface_t`
handle before freeing so reentrant access and its eventual `deinit`
cannot free the surface twice. `Surface` is now main actor-isolated so
access to the surface handle is guaranteed to be serialized.

We also now free all surfaces from applicationWillTerminate. This
centralizes the operation after AppKit has committed to termination and
runs normal surface teardown before application teardown closes the
PTYs. The existing deinit path remains as a fallback for surfaces freed
during normal application use.

Because we free the surfaces serially and synchronously, this approach
does add a shutdown time cost. For example, if there are 10 open tabs
and each of their shell processes takes 20ms to exit, quitting the app
takes 200ms longer. This should be acceptable because the reason for the
delay is probably obvious to the user (lots of tabs), and quitting the
whole app with many tabs still active is probably uncommon.

Verified automatic bash history writing without prompt-time history
updates or a custom signal handler using:

    shopt -u histappend
    HISTCONTROL=
    HISTIGNORE=
    history -c
    echo HISTORY_MARKER

Verified explicit SIGHUP handling with an isolated interactive shell:

    trap 'echo "HUP $$ $(date +%s)" >> /tmp/ghostty-hup; exit 0' HUP

Verified that a process deliberately ignoring SIGHUP does not block
macOS application termination using:

    trap '' HUP
    while :; do sleep 1; done

As with existing surface teardown, the ignoring bash process survived
and was reparented to PID 1 after Ghostty exited.

Verified an undo-retained surface with a 30-second undo timeout by
opening two tabs, closing the second, confirming its shell remained
alive, and quitting before the undo entry expired. Its automatic history
marker was saved successfully.
@jparise
jparise force-pushed the macos-graceful-termination branch from c321752 to 275b614 Compare October 22, 2026 12:41

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

When quitting ghostty, bash isn't writing history from open sessions

4 participants