209 lines
7.3 KiB
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)
|
|
}
|
|
}
|