macos: graceful child process termination
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..."
pull/9760/head
parent
3e3705b932
commit
451506ab5a
|
|
@ -362,14 +362,33 @@ class AppDelegate: NSObject,
|
|||
return derivedConfig.shouldQuitAfterLastWindowClosed
|
||||
}
|
||||
|
||||
/// Initiates graceful application termination.
|
||||
private func terminateGracefully() -> NSApplication.TerminateReply {
|
||||
/// Free our surfaces synchronously, ensuring SIGHUP signals are sent to all
|
||||
/// child processes (e.g., bash) so they can clean up before the app exits.
|
||||
TerminalController.freeAllSurfaces()
|
||||
|
||||
if !quickController.surfaceTree.isEmpty {
|
||||
quickController.freeSurfaces()
|
||||
}
|
||||
|
||||
// Schedule termination after a brief delay to allow child processes
|
||||
// to handle SIGHUP and complete cleanup (e.g., bash saving history).
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) {
|
||||
NSApp.reply(toApplicationShouldTerminate: true)
|
||||
}
|
||||
|
||||
return .terminateLater
|
||||
}
|
||||
|
||||
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
|
||||
let windows = NSApplication.shared.windows
|
||||
if windows.isEmpty { return .terminateNow }
|
||||
if windows.isEmpty { return terminateGracefully() }
|
||||
|
||||
// If we've already accepted to install an update, then we don't need to
|
||||
// confirm quit. The user is already expecting the update to happen.
|
||||
if updateController.isInstalling {
|
||||
return .terminateNow
|
||||
return terminateGracefully()
|
||||
}
|
||||
|
||||
// This probably isn't fully safe. The isEmpty check above is aspirational, it doesn't
|
||||
|
|
@ -380,7 +399,7 @@ class AppDelegate: NSObject,
|
|||
// here because I don't want to remove it in a patch release cycle but we should
|
||||
// target removing it soon.
|
||||
if (windows.allSatisfy { !$0.isVisible }) {
|
||||
return .terminateNow
|
||||
return terminateGracefully()
|
||||
}
|
||||
|
||||
// If the user is shutting down, restarting, or logging out, we don't confirm quit.
|
||||
|
|
@ -393,8 +412,7 @@ class AppDelegate: NSObject,
|
|||
if let why = event.attributeDescriptor(forKeyword: keyword) {
|
||||
switch why.typeCodeValue {
|
||||
case kAEShutDown, kAERestart, kAEReallyLogOut:
|
||||
return .terminateNow
|
||||
|
||||
return terminateGracefully()
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
|
@ -402,7 +420,7 @@ class AppDelegate: NSObject,
|
|||
}
|
||||
|
||||
// If our app says we don't need to confirm, we can exit now.
|
||||
if !ghostty.needsConfirmQuit { return .terminateNow }
|
||||
if !ghostty.needsConfirmQuit { return terminateGracefully() }
|
||||
|
||||
return terminate()
|
||||
}
|
||||
|
|
@ -1301,7 +1319,7 @@ extension AppDelegate {
|
|||
.filter { !$0.windowCanBeClosedWithoutConfirmation() }
|
||||
|
||||
guard !controllersNeedConfirmation.isEmpty else {
|
||||
return .terminateNow
|
||||
return terminateGracefully()
|
||||
}
|
||||
|
||||
if controllersNeedConfirmation.count == 1 {
|
||||
|
|
@ -1313,7 +1331,7 @@ extension AppDelegate {
|
|||
)
|
||||
|
||||
if [.OK, .alertFirstButtonReturn].contains(response) {
|
||||
await NSApp.reply(toApplicationShouldTerminate: true)
|
||||
await MainActor.run { _ = self.terminateGracefully() }
|
||||
} else {
|
||||
await NSApp.reply(toApplicationShouldTerminate: false)
|
||||
}
|
||||
|
|
@ -1334,7 +1352,7 @@ extension AppDelegate {
|
|||
reviewWindows(controllersNeedConfirmation)
|
||||
return .terminateLater
|
||||
case .alertSecondButtonReturn:
|
||||
return .terminateNow
|
||||
return terminateGracefully()
|
||||
default:
|
||||
return .terminateCancel
|
||||
}
|
||||
|
|
@ -1360,7 +1378,7 @@ extension AppDelegate {
|
|||
return
|
||||
}
|
||||
}
|
||||
await NSApp.reply(toApplicationShouldTerminate: true)
|
||||
await MainActor.run { _ = self.terminateGracefully() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -438,6 +438,20 @@ class BaseTerminalController: NSWindowController,
|
|||
}
|
||||
}
|
||||
|
||||
/// Frees all surfaces in this controller synchronously.
|
||||
///
|
||||
/// This is used during app termination to ensure SIGHUP signals are sent
|
||||
/// to child processes before the app exits. Unlike the async Surface.deinit
|
||||
/// approach, this calls ghostty_surface_free synchronously.
|
||||
func freeSurfaces() {
|
||||
for surfaceView in surfaceTree {
|
||||
if let surface = surfaceView.surface {
|
||||
ghostty_surface_free(surface)
|
||||
}
|
||||
}
|
||||
surfaceTree = .init()
|
||||
}
|
||||
|
||||
// MARK: Split Tree Management
|
||||
|
||||
/// Find the next surface to focus when a node is being closed.
|
||||
|
|
|
|||
|
|
@ -977,6 +977,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
|
|||
undoManager?.endUndoGrouping()
|
||||
}
|
||||
|
||||
/// Frees all surfaces from all terminal controllers synchronously.
|
||||
///
|
||||
/// This is used during termination to gracefully destroy our surfaces.
|
||||
static internal func freeAllSurfaces() {
|
||||
all.forEach { $0.freeSurfaces() }
|
||||
}
|
||||
|
||||
// MARK: Undo/Redo
|
||||
|
||||
/// The state that we require to recreate a TerminalController from an undo.
|
||||
|
|
|
|||
Loading…
Reference in New Issue