Add application shutdown lifecycle hooks
This commit is contained in:
parent
0b3761db4b
commit
5e625d6123
5 changed files with 320 additions and 2 deletions
|
|
@ -15,14 +15,24 @@
|
|||
/// Returns `nil` by default; override to enable single-instance behavior.
|
||||
var applicationId: String? { get }
|
||||
|
||||
/// Initializes the application — required by the `App` protocol.
|
||||
/// Initializes the application - required by the `App` protocol.
|
||||
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 {
|
||||
/// Default — no application ID, no single-instance enforcement.
|
||||
/// Default - no application ID, no single-instance enforcement.
|
||||
var applicationId: String? { nil }
|
||||
|
||||
/// Default - no teardown.
|
||||
func shutdown() async {}
|
||||
|
||||
/// Boots the application via ``PorticoRuntime/run(_:)``.
|
||||
static func main() { PorticoRuntime.run(Self()) }
|
||||
}
|
||||
|
|
|
|||
15
Sources/Portico/App/ApplicationLifecycle.swift
Normal file
15
Sources/Portico/App/ApplicationLifecycle.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -68,6 +68,7 @@ public extension ApplicationWindow {
|
|||
|
||||
@_spi(Portico) public func attach(to app: Adw.Application) -> SceneHandle? {
|
||||
let window = Adw.ApplicationWindow(app: app)
|
||||
PorticoRuntime._installShutdownGate(on: window)
|
||||
|
||||
if let s = config.defaultSize {
|
||||
window.setDefaultSize(width: s.width, height: s.height)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import Adw
|
||||
import Gtk
|
||||
import Gio
|
||||
import Synchronization
|
||||
|
||||
#if canImport(Glibc)
|
||||
import Glibc
|
||||
|
|
@ -19,6 +20,116 @@ import Darwin
|
|||
/// app is drained by `g_application_run` - no boilerplate in user code and
|
||||
/// no change to the GTK application lifecycle.
|
||||
@_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
|
||||
/// scene tree via `connectActivate`, runs the GTK main loop, and
|
||||
/// terminates the process with the returned exit code.
|
||||
|
|
@ -33,6 +144,16 @@ import Darwin
|
|||
applicationId: app.applicationId,
|
||||
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 }
|
||||
_ = adwApp.connectActivate { _ in host.start() }
|
||||
exit(adwApp.run(argv: CommandLine.arguments))
|
||||
|
|
|
|||
171
Tests/PorticoTests/ShutdownLifecycleTests.swift
Normal file
171
Tests/PorticoTests/ShutdownLifecycleTests.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue