Add application shutdown lifecycle hooks

This commit is contained in:
Brendan Szymanski 2026-08-04 21:37:16 -04:00
parent 0b3761db4b
commit 5e625d6123
5 changed files with 320 additions and 2 deletions

View file

@ -15,14 +15,24 @@
/// Returns `nil` by default; override to enable single-instance behavior. /// Returns `nil` by default; override to enable single-instance behavior.
var applicationId: String? { get } var applicationId: String? { get }
/// Initializes the application required by the `App` protocol. /// Initializes the application - required by the `App` protocol.
init() init()
/// Asynchronous teardown performed after the last window requests close and before exit.
///
/// Runs after every handler registered with ``ApplicationLifecycle/onShutdown(_:)``.
/// The default implementation does nothing.
func shutdown() async
} }
public extension App { public extension App {
/// Default no application ID, no single-instance enforcement. /// Default - no application ID, no single-instance enforcement.
var applicationId: String? { nil } var applicationId: String? { nil }
/// Default - no teardown.
func shutdown() async {}
/// Boots the application via ``PorticoRuntime/run(_:)``. /// Boots the application via ``PorticoRuntime/run(_:)``.
static func main() { PorticoRuntime.run(Self()) } static func main() { PorticoRuntime.run(Self()) }
} }

View file

@ -0,0 +1,15 @@
/// Application-scoped lifecycle registration.
///
/// Handlers registered here run after the last window requests close and before the process
/// exits, while the GLib main loop is still spinning, so they may await.
@MainActor public enum ApplicationLifecycle {
/// Registers asynchronous teardown to run before the application exits.
///
/// Handlers run sequentially in registration order. A handler registered from an
/// `App.init()` therefore runs before the app's own `App.shutdown()`.
///
/// - Parameter work: Teardown to perform. Must not itself close windows or quit the app.
public static func onShutdown(_ work: @escaping @MainActor () async -> Void) {
PorticoRuntime._register(work)
}
}

View file

@ -68,6 +68,7 @@ public extension ApplicationWindow {
@_spi(Portico) public func attach(to app: Adw.Application) -> SceneHandle? { @_spi(Portico) public func attach(to app: Adw.Application) -> SceneHandle? {
let window = Adw.ApplicationWindow(app: app) let window = Adw.ApplicationWindow(app: app)
PorticoRuntime._installShutdownGate(on: window)
if let s = config.defaultSize { if let s = config.defaultSize {
window.setDefaultSize(width: s.width, height: s.height) window.setDefaultSize(width: s.width, height: s.height)

View file

@ -1,6 +1,7 @@
import Adw import Adw
import Gtk import Gtk
import Gio import Gio
import Synchronization
#if canImport(Glibc) #if canImport(Glibc)
import Glibc import Glibc
@ -19,6 +20,116 @@ import Darwin
/// app is drained by `g_application_run` - no boilerplate in user code and /// app is drained by `g_application_run` - no boilerplate in user code and
/// no change to the GTK application lifecycle. /// no change to the GTK application lifecycle.
@_spi(Portico) @MainActor public enum PorticoRuntime { @_spi(Portico) @MainActor public enum PorticoRuntime {
private static var shutdownHandlers: [@MainActor () async -> Void] = []
private static var application: Adw.Application?
private static var isDraining = false
@_spi(Portico) public private(set) static var didDrain = false
private static let drainDone = Mutex<Bool>(false)
private static var drainHandle: _Concurrency.Task<Void, Never>?
private static var drainTaskHandle: _Concurrency.Task<Void, Never>?
/// Number of gate-equipped windows still open. The shutdown drain starts
/// only when the last window requests close.
private static var openWindows = 0
/// Bounded total shutdown drain time.
@_spi(Portico) public static var _drainBound: Duration = .seconds(5)
/// Registers one asynchronous shutdown handler in registration order.
@_spi(Portico) public static func _register(
_ work: @escaping @MainActor () async -> Void
) {
shutdownHandlers.append(work)
}
/// Resets process-global shutdown state for isolated tests, cancelling any
/// in-flight drain watchdog and handler task.
@_spi(Portico) public static func _resetShutdownState() {
shutdownHandlers.removeAll()
application = nil
isDraining = false
didDrain = false
openWindows = 0
drainDone.withLock { $0 = false }
drainHandle?.cancel()
drainHandle = nil
drainTaskHandle?.cancel()
drainTaskHandle = nil
}
/// Installs the close-request handler that drives the shutdown drain.
///
/// The drain starts only when the last open window requests close: a close
/// request on a non-last window is allowed through immediately (the window
/// closes without tearing down shared resources). The first close request on
/// the last window is vetoed while registered handlers and the app shutdown
/// hook run; a close request during that drain is also vetoed. Once the
/// drain completes or reaches its bound, the window is closed again and
/// this handler allows the completing close request through.
@_spi(Portico) public static func _installShutdownGate(
on window: Adw.ApplicationWindow
) {
PorticoRuntime.openWindows += 1
_ = window.connectCloseRequest { _ in
if PorticoRuntime.didDrain { return false }
if PorticoRuntime.isDraining { return true }
// Non-last window: let it close without starting the drain.
PorticoRuntime.openWindows -= 1
if PorticoRuntime.openWindows > 0 { return false }
// Last window: veto now and drain handlers while the loop spins.
PorticoRuntime.isDraining = true
PorticoRuntime.drainDone.withLock { $0 = false }
PorticoRuntime.application?.hold()
PorticoRuntime.drainHandle = _Concurrency.Task {
await PorticoRuntime.runDrain(on: window)
}
return true
}
}
/// Drains registered handlers while the GLib main loop continues pumping.
///
/// The watchdog polls a completion flag on a short cadence instead of
/// awaiting the drain task, so a non-cancellable handler cannot prevent
/// the window from closing. The poll cadence is clamped to the remaining
/// bound so `_drainBound` is honored as a deadline. If the watchdog is
/// cancelled (by ``_resetShutdownState``), it leaves process state alone so
/// the reset path owns cleanup.
private static func runDrain(on window: Adw.ApplicationWindow) async {
PorticoRuntime.drainTaskHandle = _Concurrency.Task { @MainActor () -> Void in
for handler in PorticoRuntime.shutdownHandlers {
await handler()
}
PorticoRuntime.drainDone.withLock { $0 = true }
}
guard let drain = PorticoRuntime.drainTaskHandle else { return }
let pollInterval: Duration = .milliseconds(25)
var elapsed: Duration = .zero
while !PorticoRuntime.drainDone.withLock({ $0 })
&& elapsed < PorticoRuntime._drainBound
&& !_Concurrency.Task.isCancelled
{
let remaining = PorticoRuntime._drainBound - elapsed
let sleep = remaining < pollInterval ? remaining : pollInterval
_ = try? await _Concurrency.Task.sleep(for: sleep)
elapsed += sleep
}
// Cancelled by a reset: leave state for the reset path to own.
if _Concurrency.Task.isCancelled {
drain.cancel()
return
}
drain.cancel()
PorticoRuntime.didDrain = true
PorticoRuntime.application?.release()
window.close()
}
/// Boots the application, creates an `Adw.Application`, wires the /// Boots the application, creates an `Adw.Application`, wires the
/// scene tree via `connectActivate`, runs the GTK main loop, and /// scene tree via `connectActivate`, runs the GTK main loop, and
/// terminates the process with the returned exit code. /// terminates the process with the returned exit code.
@ -33,6 +144,16 @@ import Darwin
applicationId: app.applicationId, applicationId: app.applicationId,
flags: .defaultFlags flags: .defaultFlags
) )
PorticoRuntime.application = adwApp
// Register the app's own shutdown hook exactly once. ``App.init``
// runs at `Self()` construction - before ``run(_:)`` is entered - so
// any handler it registered via ``ApplicationLifecycle.onShutdown``
// is already queued by this point; appending here keeps the app hook
// last while ensuring a repeated `activate` (single-instance app) does
// not re-append it.
PorticoRuntime._register { await app.shutdown() }
let host = SceneHost(app: adwApp) { app.body } let host = SceneHost(app: adwApp) { app.body }
_ = adwApp.connectActivate { _ in host.start() } _ = adwApp.connectActivate { _ in host.start() }
exit(adwApp.run(argv: CommandLine.arguments)) exit(adwApp.run(argv: CommandLine.arguments))

View file

@ -0,0 +1,171 @@
import Testing
import Adw
import Gtk
@_spi(Portico) import Portico
@_silgen_name("g_main_context_iteration")
private nonisolated func test_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
/// Gate flipped by `restoreState` so the intentionally non-cancellable
/// hung-handler spin can exit after its test, instead of leaking a
/// main-actor task for the rest of the suite.
@MainActor private var stopHungLoop = false
@MainActor @Suite(.serialized) struct AppShutdownLifecycleTests {
/// Restores process-global shutdown state after each test, and stops any
/// intentionally-leaked hung-handler spin so it cannot peg the main actor
/// for the rest of the suite.
private func restoreState() {
stopHungLoop = true
PorticoRuntime._resetShutdownState()
PorticoRuntime._drainBound = .seconds(5)
}
/// Starts an isolated shutdown test with a short bound and a clean gate.
private func beginTest() {
stopHungLoop = false
PorticoRuntime._resetShutdownState()
PorticoRuntime._drainBound = .milliseconds(150)
}
/// Pumps the default main context until `condition` is true or `timeout` elapses.
private func asyncPump(
until condition: () -> Bool, timeout: Duration = .seconds(2)
) async -> Bool {
var expired = false
let watchdog = Timeout(interval: timeout, repeats: false) { expired = true }
defer { watchdog.remove() }
while !condition() && !expired {
_ = test_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
}
return expired
}
/// Mounts and presents a window with the shutdown gate installed.
private func mountedWindow() -> Adw.ApplicationWindow {
let app = Adw.Application(applicationId: nil, flags: .defaultFlags)
let window = Adw.ApplicationWindow(app: app)
PorticoRuntime._installShutdownGate(on: window)
window.present()
return window
}
@Test func onShutdownRunsThenWindowCloses() async {
guard Gtk.initCheck() else {
#expect(Bool(false), "GTK not initialized - tests need a display")
return
}
beginTest()
defer { restoreState() }
var record: [String] = []
ApplicationLifecycle.onShutdown {
record.append("a")
await _Concurrency.Task.yield()
record.append("b")
}
let window = mountedWindow()
window.close()
let timedOut = await asyncPump(until: { PorticoRuntime.didDrain })
#expect(!timedOut, "shutdown drain timed out")
#expect(record == ["a", "b"])
#expect(PorticoRuntime.didDrain)
}
@Test func secondCloseRequestDoesNotReRunHandler() async {
guard Gtk.initCheck() else {
#expect(Bool(false), "GTK not initialized - tests need a display")
return
}
beginTest()
defer { restoreState() }
var count = 0
ApplicationLifecycle.onShutdown { count += 1 }
let window = mountedWindow()
window.close()
let timedOut = await asyncPump(until: { PorticoRuntime.didDrain })
#expect(!timedOut, "shutdown drain timed out")
window.close()
let closeTimedOut = await asyncPump(
until: { false }, timeout: .milliseconds(100)
)
#expect(closeTimedOut, "close verification pump ended unexpectedly")
#expect(count == 1)
}
@Test func registeredHandlersRunBeforeAppShutdownHook() async {
guard Gtk.initCheck() else {
#expect(Bool(false), "GTK not initialized - tests need a display")
return
}
beginTest()
defer { restoreState() }
var record: [String] = []
ApplicationLifecycle.onShutdown { record.append("user") }
PorticoRuntime._register { record.append("app") }
let window = mountedWindow()
window.close()
let timedOut = await asyncPump(until: { PorticoRuntime.didDrain })
#expect(!timedOut, "shutdown drain timed out")
#expect(record == ["user", "app"])
}
@Test func hungHandlerStillClosesAfterBound() async {
guard Gtk.initCheck() else {
#expect(Bool(false), "GTK not initialized - tests need a display")
return
}
beginTest()
defer { restoreState() }
ApplicationLifecycle.onShutdown {
while !stopHungLoop { await _Concurrency.Task.yield() }
}
let window = mountedWindow()
window.close()
let timedOut = await asyncPump(
until: { PorticoRuntime.didDrain }, timeout: .seconds(2)
)
#expect(!timedOut, "shutdown bound did not force close")
#expect(PorticoRuntime.didDrain)
}
@Test func drainStartsOnlyOnLastWindow() async {
guard Gtk.initCheck() else {
#expect(Bool(false), "GTK not initialized - tests need a display")
return
}
beginTest()
defer { restoreState() }
var ran = false
ApplicationLifecycle.onShutdown { ran = true }
let first = mountedWindow()
let second = mountedWindow()
// Closing the first (non-last) window must NOT start the drain.
first.close()
let warmup = await asyncPump(
until: { false }, timeout: .milliseconds(60)
)
#expect(warmup, "pump ended before its timeout")
#expect(!ran)
#expect(!PorticoRuntime.didDrain)
// Closing the last window starts the drain and runs the handler.
second.close()
let drained = await asyncPump(until: { PorticoRuntime.didDrain })
#expect(!drained, "drain never completed after closing the last window")
#expect(ran)
#expect(PorticoRuntime.didDrain)
}
}