portico/Tests/PorticoTests/ShutdownLifecycleTests.swift

209 lines
7.3 KiB
Swift

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)
}
/// Branch swaps dismantle old windows without stranding the shutdown gate.
@Test func branchFlipsDoNotStrandTheShutdownDrain() 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 box = StateBox(false)
let app = Adw.Application(applicationId: nil, flags: .defaultFlags)
@SceneBuilder func makeScene() -> some Scene {
if Binding(box).wrappedValue {
ApplicationWindow { _ in Gtk.Label(str: "on") }.title("On")
} else {
ApplicationWindow { _ in Gtk.Label(str: "off") }.title("Off")
}
}
let host = SceneHost(app: app, makeScene: { makeScene() })
host.start()
for flip in 1...3 {
box.set(flip % 2 == 1)
let expected: SceneIdentity = flip % 2 == 1 ? .first(.leaf) : .second(.leaf)
let timedOut = await asyncPump(until: { host.liveIdentity == expected })
#expect(!timedOut, "branch swap \(flip) never landed")
}
#expect(PorticoRuntime._openWindowCount == 1)
host.live?.window.close()
let timedOut = await asyncPump(until: { PorticoRuntime.didDrain })
#expect(!timedOut, "shutdown drain never started after branch flips")
#expect(ran)
}
}