Update main loop scheduling, lifecycle, and reactive teardown

This commit is contained in:
Brendan Szymanski 2026-08-11 22:55:55 -04:00
parent 7c651e2064
commit 9f1afe9a91
28 changed files with 577 additions and 294 deletions

View file

@ -37,9 +37,15 @@ import Darwin
private static var drainHandle: _Concurrency.Task<Void, Never>?
private static var drainTaskHandle: _Concurrency.Task<Void, Never>?
/// Windows with a shutdown gate installed, keyed by wrapper identity so a
/// window is retired exactly once no matter which path retires it: a
/// `close-request` through the gate, or ``SceneHandle/dismantle()``, whose
/// `gtk_window_destroy` emits no `close-request` at all.
private static var gatedWindows: Set<ObjectIdentifier> = []
/// Number of gate-equipped windows still open. The shutdown drain starts
/// only when the last window requests close.
private static var openWindows = 0
/// only when the last one is retired.
@_spi(Portico) public static var _openWindowCount: Int { gatedWindows.count }
/// Bounded total shutdown drain time.
@_spi(Portico) public static var _drainBound: Duration = .seconds(5)
@ -69,7 +75,7 @@ import Darwin
application = nil
isDraining = false
didDrain = false
openWindows = 0
gatedWindows.removeAll()
drainDone.withLock { $0 = false }
drainHandle?.cancel()
drainHandle = nil
@ -89,14 +95,15 @@ import Darwin
@_spi(Portico) public static func _installShutdownGate(
on window: Adw.ApplicationWindow
) {
PorticoRuntime.openWindows += 1
let key = ObjectIdentifier(window)
guard gatedWindows.insert(key).inserted else { return }
_ = 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 }
PorticoRuntime.retireGate(key)
if !PorticoRuntime.gatedWindows.isEmpty { return false }
// Last window: veto now and drain handlers while the loop spins.
PorticoRuntime.isDraining = true
@ -109,6 +116,23 @@ import Darwin
}
}
/// Retires `window`'s shutdown gate without closing it.
///
/// Called by ``SceneHandle/dismantle()``: `gtk_window_destroy` emits no
/// `close-request`, so the gate handler never runs and the window would
/// otherwise be counted open forever. Idempotent.
@_spi(Portico) public static func _retireShutdownGate(
on window: Adw.ApplicationWindow
) {
retireGate(ObjectIdentifier(window))
}
/// Drops one gated window. Idempotent - a repeat retire is a no-op, which
/// is what makes the two retire paths safe to overlap.
private static func retireGate(_ key: ObjectIdentifier) {
gatedWindows.remove(key)
}
/// Drains registered handlers while the GLib main loop continues pumping.
///
/// The watchdog polls a completion flag on a short cadence instead of

View file

@ -26,13 +26,15 @@ import Adw
registry.teardown()
}
/// Releases reactive resources and destroys the window. Used when the runtime
/// swaps to a different scene branch. Idempotent.
/// Releases reactive resources, retires the window's shutdown gate, and
/// destroys the window. Used when the runtime swaps to a different scene
/// branch. Idempotent.
@_spi(Portico) public func dismantle() {
guard !isDismantled else { return }
releaseResources()
window.destroy()
isDismantled = true
releaseResources()
PorticoRuntime._retireShutdownGate(on: window)
window.destroy()
}
/// Re-presents the window, for a second `activate` on a single-instance app.

View file

@ -37,24 +37,28 @@ extension View {
/// `Task.isCancelled` / `Task.checkCancellation()` check it makes
/// itself), matching Swift's cooperative cancellation model.
///
/// A remap waits for the cancelled task to finish before the new body
/// starts, so two copies never run concurrently. An action that ignores
/// cancellation therefore delays its own replacement.
///
/// `action` runs on the main actor. Task creation goes through
/// ``GLibMainExecutor`` (installed by `PorticoRuntime.run`), which
/// defers the first hop to a later main-loop iteration - like the
/// modifier it replaces, `action` observes a fully laid-out widget
/// rather than firing synchronously the way ``onAppear(perform:)`` does.
/// defers the first hop to a later main-loop iteration.
///
/// - Parameters:
/// - priority: The task's priority. Defaults to `.userInitiated`,
/// matching SwiftUI's `task(priority:_:)`.
/// - action: The asynchronous work to perform. Use nested
/// `Task.detached` for CPU/IO work that must leave the main actor.
/// - priority: The task's priority. Defaults to `.userInitiated`.
/// - action: The asynchronous work to perform.
public func task(
priority: TaskPriority = .userInitiated,
_ action: @escaping @MainActor () async -> Void
) -> AnyView {
scopedToAppearance(
start: { () -> _Concurrency.Task<Void, Never> in
_Concurrency.Task<Void, Never>(priority: priority) { await action() }
start: { (previous: _Concurrency.Task<Void, Never>?) in
_Concurrency.Task<Void, Never>(priority: priority) {
await previous?.value
guard !_Concurrency.Task.isCancelled else { return }
await action()
}
},
stop: { (task: _Concurrency.Task<Void, Never>) in task.cancel() }
)
@ -74,7 +78,7 @@ extension View {
_ body: @escaping @MainActor () -> Void
) -> AnyView {
scopedToAppearance(
start: { Timeout(interval: interval, priority: priority, repeats: true, body) },
start: { _ in Timeout(interval: interval, priority: priority, repeats: true, body) },
stop: { $0.remove() }
)
}
@ -84,16 +88,23 @@ extension View {
/// live handle, `stop` is called with that handle on the next `unmap` or
/// on subtree teardown - whichever comes first. At most one handle is
/// live at a time, so remap cycles do not accumulate handles.
///
/// `start` receives the handle produced by the previous appearance (`nil`
/// on the first), so a caller that must not overlap successive runs can
/// chain on it.
private func scopedToAppearance<Handle>(
start: @escaping @MainActor () -> Handle,
start: @escaping @MainActor (Handle?) -> Handle,
stop: @escaping @MainActor (Handle) -> Void
) -> AnyView {
AnyView(makeWidget: { ctx in
let w = AnyView(self).makeWidget(ctx)
var live: Handle?
var previous: Handle?
let begin: @MainActor () -> Void = {
guard live == nil else { return }
live = start()
let handle = start(previous)
previous = handle
live = handle
}
let end: @MainActor () -> Void = {
if let handle = live { stop(handle) }

View file

@ -52,40 +52,27 @@ nonisolated func portico_g_main_context_iteration(
// MARK: - Main-thread predicate
/// Returns `true` if the calling thread is the main thread OR owns the
/// default `GMainContext`. This is deliberately broader than ownership
/// alone: `MainActor.assumeIsolated` in Portico's GLib trampolines can
/// fire from plain main-thread code (e.g. `SourceId.remove()`), not just
/// from inside a GLib dispatch. On Darwin it delegates to
/// `pthread_main_np`.
/// Returns `true` only on the process's initial thread - the thread that runs
/// `main`, owns Swift's `MainActor`, and blocks inside `g_application_run`.
///
/// Deliberately does not accept ownership of the default `GMainContext`: a
/// worker can acquire that context, but it is not the GTK main thread.
#if canImport(Darwin)
nonisolated func porticoIsMainThread() -> Bool { pthread_main_np() != 0 }
#else
@_silgen_name("gettid")
nonisolated func portico_gettid() -> Int32
@_silgen_name("g_main_context_default")
nonisolated func portico_g_main_context_default() -> UnsafeMutableRawPointer
@_silgen_name("g_main_context_is_owner")
nonisolated func portico_g_main_context_is_owner(
_ context: UnsafeMutableRawPointer
) -> Int32
nonisolated func porticoIsMainThread() -> Bool {
portico_g_main_context_is_owner(portico_g_main_context_default()) != 0
|| portico_gettid() == getpid()
}
nonisolated func porticoIsMainThread() -> Bool { portico_gettid() == getpid() }
#endif
// MARK: - Executor drain trampoline
/// `GSourceFunc` trampoline for the idle source that drains Swift jobs.
///
/// The executor pointer is stored as `GSource` user data with no destroy
/// notify; the executor is a process-lifetime singleton so there is nothing
/// to free. Returns `G_SOURCE_REMOVE` (0) because the source is one-shot -
/// each batch arms a fresh source.
/// The source owns a +1 on the executor, released by
/// ``portico_executor_destroy_notify``. Returns `G_SOURCE_REMOVE` (0) because
/// the source is one-shot - each batch arms a fresh source.
@_cdecl("portico_executor_drain")
nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int32 {
guard let data else { return 0 }
@ -95,6 +82,14 @@ nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int
return 0
}
/// `GDestroyNotify` for the executor drain source. Releases the +1 handed to
/// GLib by ``GLibMainExecutor/arm()``.
@_cdecl("portico_executor_destroy_notify")
nonisolated func portico_executor_destroy_notify(_ data: UnsafeMutableRawPointer?) {
guard let data else { return }
Unmanaged<GLibMainExecutor>.fromOpaque(data).release()
}
// MARK: - Main executor
/// A `MainExecutor` that drains Swift MainActor jobs through the GLib main loop.
@ -110,7 +105,7 @@ nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int
/// main-actor resumption is drained by `g_application_run` with no change to
/// the GTK application lifecycle.
@_spi(Portico) nonisolated public final class GLibMainExecutor:
MainExecutor, @unchecked Sendable
MainExecutor, Sendable
{
/// Pending jobs and the armed flag, guarded by a `Mutex` because `enqueue`
/// is called from cooperative-pool threads.
@ -148,9 +143,11 @@ nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int
private func arm() {
let source = portico_g_idle_source_new()
portico_g_source_set_priority(source, SourcePriority.defaultIdle.rawValue)
let notify: @convention(c) (UnsafeMutableRawPointer?) -> Void =
portico_executor_destroy_notify
portico_g_source_set_callback(
source, portico_executor_drain,
Unmanaged.passUnretained(self).toOpaque(), nil
Unmanaged.passRetained(self).toOpaque(), notify
)
_ = portico_g_source_attach(source, nil)
portico_g_source_unref(source)

View file

@ -33,11 +33,8 @@
// MARK: - Trampolines
/// GLib `GSourceFunc` trampoline. Bridges the C callback back to the main
/// actor via `MainActor.assumeIsolated`, which is valid because
/// ``GLibMainExecutor`` is installed as the process main executor by
/// ``PorticoRuntime/run(_:)``; its ``GLibMainExecutor/checkIsolated()``
/// accepts the main thread and any thread that owns the default
/// `GMainContext`.
/// actor via `MainActor.assumeIsolated`. Dispatch runs on the thread pumping
/// the GLib main context, which must be the process's main thread.
@_cdecl("portico_source_dispatch")
nonisolated func portico_source_dispatch(_ data: UnsafeMutableRawPointer?) -> Int32 {
guard let data else { return 0 }
@ -49,11 +46,13 @@ nonisolated func portico_source_dispatch(_ data: UnsafeMutableRawPointer?) -> In
/// GLib `GDestroyNotify` trampoline. Releases the +1 retain that GLib holds
/// on the callback box.
///
/// Destroy-notify may run on any thread, so this is a plain nonisolated
/// refcount release rather than a `MainActor.assumeIsolated` call.
@_cdecl("portico_source_destroy_notify")
nonisolated func portico_source_destroy_notify(_ data: UnsafeMutableRawPointer?) {
guard let data else { return }
let unmanaged = Unmanaged<SourceCallbackBox>.fromOpaque(data)
MainActor.assumeIsolated { _ = unmanaged.takeRetainedValue() }
Unmanaged<SourceCallbackBox>.fromOpaque(data).release()
}
// MARK: - Duration conversion

View file

@ -40,9 +40,9 @@ nonisolated func portico_g_source_is_destroyed(_ source: UnsafeMutableRawPointer
/// detaches it with `g_source_destroy`, so it never risks acting on a reissued
/// source tag the way `g_source_remove` does.
///
/// Letting the handle go out of scope does **not** remove the source - the same
/// contract as ``SubscriptionToken``. Removal is always explicit, or automatic
/// via the appearance-scoped ``View/task(priority:_:)`` /
/// Letting the handle go out of scope does **not** remove the source - unlike
/// ``SubscriptionToken``, which cancels on release. Removal is always explicit,
/// or automatic via the appearance-scoped ``View/task(priority:_:)`` /
/// ``View/task(every:priority:_:)`` modifiers.
@MainActor public final class SourceId {
/// The GLib source tag (`g_source_get_id`), assigned when the source is

View file

@ -30,8 +30,11 @@ extension Bool: DialogPropertySource {
/// Provides an integer literal as an `Int32` dialog property value.
extension Int: DialogPropertySource {
/// Returns this integer converted to a fixed `Int32` dialog property value.
public var _dialogPropertyValue: DialogPropertyValue<Int32> { .constant(Int32(self)) }
/// Returns this integer as a fixed `Int32` value, clamped to the `Int32`
/// range rather than trapping on an out-of-range argument.
public var _dialogPropertyValue: DialogPropertyValue<Int32> {
.constant(Int32(clamping: self))
}
}
/// Provides a fixed `Int32` dialog property value.

View file

@ -106,6 +106,16 @@ import Observation
@_spi(Portico) public func teardown() {
for d in deregistrations { d() }
deregistrations.removeAll()
if let id = observationID {
observationID = nil
ObservationBridge.unregister(id)
}
}
/// Drops the ``ObservationBridge`` registration of a tracker released
/// without ``teardown()``. `isolated deinit` is required because the bridge
/// is main-actor isolated.
isolated deinit {
if let id = observationID { ObservationBridge.unregister(id) }
}
}

View file

@ -22,6 +22,9 @@
private static var dirty: [UInt64] = []
private static var flushScheduled = false
/// Number of trackers currently registered with the bridge.
static var registrationCount: Int { registered.count }
/// Weak handle so a torn-down tracker is not kept alive by the registry.
private struct WeakTracker {
weak var tracker: DependencyTracker?
@ -67,11 +70,19 @@
/// Trampoline out of the non-isolated, `@Sendable` `onChange` closure.
///
/// Valid because every Portico mutation happens on the GLib main thread; this
/// is the same `MainActor.assumeIsolated` bridge the GLib source callbacks in
/// `MainLoopSources.swift` already rely on.
/// `onChange` runs on the thread that mutated the observable. Off-main
/// mutations therefore hop to the main actor before marking the tracker dirty.
nonisolated func _porticoObservationDidChange(_ id: UInt64) {
MainActor.assumeIsolated { ObservationBridge.markDirty(id) }
if porticoIsMainThread() {
MainActor.assumeIsolated { ObservationBridge.markDirty(id) }
} else {
_Concurrency.Task { @MainActor in ObservationBridge.markDirty(id) }
}
}
/// Number of trackers currently registered with the Observation bridge.
@_spi(Portico) @MainActor public func _porticoObservationRegistrationCount() -> Int {
ObservationBridge.registrationCount
}
/// Empty marker captured by a tracker's `onChange` closure.

View file

@ -1,8 +1,9 @@
/// A handle that can cancel a live subscription.
///
/// ``cancel()`` is idempotent; deinit does NOT automatically cancel.
/// Until explicitly cancelled, the subscription remains active regardless
/// of whether the token is still held.
/// The subscription lives exactly as long as the token: ``cancel()`` ends it
/// early, and releasing the last reference to the token ends it too. Store the
/// token (or hand it to a ``NodeRegistry``) for as long as the callback should
/// keep firing. ``cancel()`` is idempotent.
@MainActor public final class SubscriptionToken {
private var onCancel: (() -> Void)?
@ -10,9 +11,12 @@
self.onCancel = onCancel
}
/// Removes the underlying subscription. Idempotent; does NOT fire on deinit.
/// Removes the underlying subscription. Idempotent.
public func cancel() {
onCancel?()
onCancel = nil
}
/// Ends the subscription when the token is released.
isolated deinit { cancel() }
}

View file

@ -22,6 +22,14 @@ private nonisolated func test_g_main_context_iteration(
}
}
@MainActor private func asyncPump(until condition: () -> Bool, turns: Int = 500) async {
for _ in 0..<turns {
if condition() { return }
_ = test_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
}
}
// MARK: - Fixtures
@Observable
@ -49,7 +57,7 @@ private struct Editor: View {
/// `$model.isEnabled` is a two-way binding: widget writes propagate to the
/// model synchronously, and model mutations propagate back to widgets
/// through the Observation bridge.
@Test func dynamicMemberBindingIsTwoWay() {
@Test func dynamicMemberBindingIsTwoWay() async {
guard Gtk.initCheck() else { return }
let model = Model()
@ -68,13 +76,13 @@ private struct Editor: View {
// Model -> widget propagates through Observation.
model.isEnabled = false
pump(until: { row.getActive() == false })
await asyncPump(until: { row.getActive() == false })
#expect(row.getActive() == false)
}
/// `$model.title` drives the label independently of `$model.isEnabled`,
/// proving that two bindings into the same model don't interfere.
@Test func dynamicMemberBindingDrivesLabel() {
@Test func dynamicMemberBindingDrivesLabel() async {
guard Gtk.initCheck() else { return }
let model = Model()
@ -86,14 +94,14 @@ private struct Editor: View {
#expect(label.getText() == "start")
model.title = "next"
pump(until: { label.getText() == "next" })
await asyncPump(until: { label.getText() == "next" })
#expect(label.getText() == "next")
}
/// `Bindable(model).amount` vends a binding without the property-wrapper
/// declaration syntax, matching `Binding(model, \.amount)` but more
/// concise.
@Test func inlineBindableWrapsObject() {
@Test func inlineBindableWrapsObject() async {
guard Gtk.initCheck() else { return }
let model = Model()
@ -112,7 +120,7 @@ private struct Editor: View {
// Model -> widget propagates through Observation.
model.amount = 12
pump(until: { row.getValue() == 12 })
await asyncPump(until: { row.getValue() == 12 })
#expect(row.getValue() == 12)
#expect(model.amount == 12) // no write-back echo
}

View file

@ -24,18 +24,16 @@ import Adw
guard Gtk.initCheck() else { return }
let titleBox = StateBox("Title")
let subtitleBox = StateBox("Sub")
let context = MountContext()
let row = AnyView(ActionRow(
Binding(titleBox),
subtitle: Binding(subtitleBox)
)).makeWidget(MountContext()) as! Adw.ActionRow
Binding(titleBox), subtitle: Binding(subtitleBox)
)).makeWidget(context) as! Adw.ActionRow
#expect(row.getTitle() == "Title")
#expect(row.getSubtitle() == "Sub")
titleBox.set("New")
subtitleBox.set("New sub")
#expect(row.getTitle() == "New")
#expect(row.getSubtitle() == "New sub")
row.setTitle(title: "Widget title")
row.setSubtitle(subtitle: "Widget sub")
#expect(titleBox.peek() == "Widget title")
@ -44,9 +42,10 @@ import Adw
@Test func switchEntryPasswordAndSpinMixedBindings() {
guard Gtk.initCheck() else { return }
let context = MountContext()
let activeBox = StateBox(false)
let switchRow = AnyView(SwitchRow("Wi-Fi", active: Binding(activeBox)))
.makeWidget(MountContext()) as! Adw.SwitchRow
.makeWidget(context) as! Adw.SwitchRow
activeBox.set(true)
#expect(switchRow.getActive() == true)
switchRow.setActive(isActive: false)
@ -54,7 +53,7 @@ import Adw
let textBox = StateBox("initial")
let entry = AnyView(EntryRow("Name", text: Binding(textBox)))
.makeWidget(MountContext()) as! Adw.EntryRow
.makeWidget(context) as! Adw.EntryRow
textBox.set("changed")
#expect(entry.getText() == "changed")
entry.setText(text: "typed")
@ -62,23 +61,16 @@ import Adw
let passwordBox = StateBox("secret")
let password = AnyView(PasswordEntryRow("Password", text: Binding(passwordBox)))
.makeWidget(MountContext()) as! Adw.PasswordEntryRow
.makeWidget(context) as! Adw.PasswordEntryRow
password.setText(text: "updated")
#expect(passwordBox.peek() == "updated")
passwordBox.set("again")
#expect(password.getText() == "again")
let valueBox = StateBox(2.0)
let adjustment = Adjustment(
value: 1,
lower: 0,
upper: 10,
stepIncrement: 1,
pageIncrement: 1,
pageSize: 0
)
let adjustment = Adjustment(value: 1, lower: 0, upper: 10, stepIncrement: 1, pageIncrement: 1, pageSize: 0)
let spin = AnyView(SpinRow("Zoom", adjustment: adjustment, value: Binding(valueBox)))
.makeWidget(MountContext()) as! Adw.SpinRow
.makeWidget(context) as! Adw.SpinRow
#expect(spin.getValue() == 2.0)
spin.setValue(value: 4.0)
#expect(valueBox.peek() == 4.0)
@ -87,15 +79,16 @@ import Adw
@Test func bannerAndButtonRowInitializers() {
guard Gtk.initCheck() else { return }
let revealedBox = StateBox(false)
let context = MountContext()
let banner = AnyView(Banner("Offline", revealed: Binding(revealedBox)))
.makeWidget(MountContext()) as! Adw.Banner
.makeWidget(context) as! Adw.Banner
#expect(banner.getTitle() == "Offline")
revealedBox.set(true)
#expect(banner.getRevealed() == true)
banner.setRevealed(revealed: false)
#expect(revealedBox.peek() == false)
let button = AnyView(ButtonRow("Save", startIconName: "document-save") { })
.makeWidget(MountContext()) as! Adw.ButtonRow
.makeWidget(context) as! Adw.ButtonRow
#expect(button.getTitle() == "Save")
#expect(button.getStartIconName() == "document-save")
}
@ -145,9 +138,10 @@ import Adw
let titleBox = StateBox("Theme")
let itemsBox = StateBox(["A", "B"])
var selected: String?
let context = MountContext()
let row = AnyView(ComboRow(Binding(titleBox), items: Binding(itemsBox)) {
selected = $0
}).makeWidget(MountContext()) as! Adw.ComboRow
}).makeWidget(context) as! Adw.ComboRow
itemsBox.set(["X", "Y", "Z"])
row.setSelected(position: 2)
#expect(selected == "Z")

View file

@ -12,6 +12,17 @@ private nonisolated func test_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
@_silgen_name("g_main_context_default")
private nonisolated func test_g_main_context_default() -> UnsafeMutableRawPointer
@_silgen_name("g_main_context_acquire")
private nonisolated func test_g_main_context_acquire(
_ context: UnsafeMutableRawPointer
) -> Int32
@_silgen_name("g_main_context_release")
private nonisolated func test_g_main_context_release(_ context: UnsafeMutableRawPointer)
// MARK: - Recorder
private nonisolated final class Recorder: @unchecked Sendable {
@ -20,6 +31,8 @@ private nonisolated final class Recorder: @unchecked Sendable {
let isolatingInCallback = Mutex<Bool?>(nil)
/// `isIsolatingCurrentContext()` sampled on a cooperative-pool thread.
let isolatingDetached = Mutex<Bool?>(nil)
/// Whether the detached worker acquired the default context.
let acquiredContext = Mutex(false)
}
// MARK: - Custom-executor actor
@ -47,7 +60,7 @@ private actor Subject {
/// Pumps the default main context until `condition` is true OR `timeout`
/// elapses. Returns `true` if the watchdog fired (timeout elapsed without
/// `condition` becoming true). Copied from `MainLoopSourceTests`.
/// `condition` becoming true).
private func pump(
until condition: () -> Bool, timeout: Duration = .seconds(2)
) -> Bool {
@ -60,18 +73,25 @@ private actor Subject {
return expired
}
/// Async analogue of `pump(until:)`: yields each turn so finished tasks
/// can be reclaimed while GLib keeps iterating.
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
}
// MARK: - Tests
/// The core claim: an enqueued job on a `GLibMainExecutor` is dispatched
/// by a GLib main-context iteration, and inside that dispatch the thread
/// owns the default `GMainContext`.
///
/// These tests MUST NOT call `GLibExecutorFactory.install()` - that would
/// change the process main executor and swift-testing's own MainActor work
/// would then only run when someone pumps GLib, hanging the suite. Instead
/// each test drives a *local* `GLibMainExecutor` through a custom-executor
/// actor, which is probe-verified to work cross-module without the stdlib
/// SPI import.
/// by a GLib main-context iteration.
@Test func enqueuedJobRunsOnTheMainThreadWhenTheLoopIterates() {
let executor = GLibMainExecutor()
let subject = Subject(executor)
@ -82,8 +102,8 @@ private actor Subject {
let timedOut = pump(until: { recorder.ran.withLock { $0 } })
#expect(!timedOut, "main loop pump timed out - job never ran")
let isolating = recorder.isolatingInCallback.withLock { $0 }
#expect(isolating == true,
"isIsolatingCurrentContext should be true inside the drain callback, got \(String(describing: isolating))")
#expect(isolating == false,
"a local test executor does not run on the process initial thread")
}
/// Guards against the `g_main_context_invoke_full` style bug where a job
@ -126,4 +146,40 @@ private actor Subject {
#expect(isolating == false,
"isIsolatingCurrentContext should be false on a cooperative-pool thread, got \(String(describing: isolating))")
}
/// A worker thread that owns the default context is not the process initial
/// thread and must not be treated as the main actor's isolation thread.
@Test func defaultContextOwnerOnAWorkerThreadIsNotTheMainThread() async {
let executor = GLibMainExecutor()
let recorder = Recorder()
await Task.detached {
let context = test_g_main_context_default()
guard test_g_main_context_acquire(context) != 0 else { return }
recorder.acquiredContext.withLock { $0 = true }
recorder.isolatingDetached.withLock {
$0 = executor.isIsolatingCurrentContext()
}
test_g_main_context_release(context)
}.value
#expect(recorder.acquiredContext.withLock { $0 })
#expect(recorder.isolatingDetached.withLock { $0 } == false)
}
/// The drain source's retained executor reference is released after the
/// source fires, allowing a local executor to deallocate.
@Test func armedSourceReleasesTheExecutorWhenItFires() async {
let recorder = Recorder()
weak var executorRef: GLibMainExecutor?
do {
let executor = GLibMainExecutor()
executorRef = executor
let subject = Subject(executor)
Task.detached { await subject.record(into: recorder) }
let timedOut = await asyncPump(until: { recorder.ran.withLock { $0 } })
#expect(!timedOut, "job never drained")
}
_ = await asyncPump(until: { executorRef == nil }, timeout: .seconds(1))
#expect(executorRef == nil, "drain source retained the executor")
}
}

View file

@ -27,11 +27,10 @@ import Adw
let sizeBox = StateBox<Int32>(48)
let textBinding = Binding(get: { Optional("AB") }, set: { _ in })
let initialsBinding = Binding(get: { true }, set: { _ in })
let context = MountContext()
let w = AnyView(Avatar(
size: Binding(sizeBox),
text: textBinding,
showInitials: initialsBinding
)).makeWidget(MountContext())
size: Binding(sizeBox), text: textBinding, showInitials: initialsBinding
)).makeWidget(context)
guard let avatar = w as? Adw.Avatar else { return }
#expect(avatar.getSize() == 48)
sizeBox.set(64)
@ -70,9 +69,9 @@ import Adw
@Test func avatarModifierBinding() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(32)
let context = MountContext()
let w = AnyView(Avatar(size: 48, text: "X", showInitials: true)
.size(Portico.Binding(box)))
.makeWidget(MountContext())
.size(Portico.Binding(box))).makeWidget(context)
guard let avatar = w as? Adw.Avatar else { return }
#expect(avatar.getSize() == 32)
box.set(96)
@ -98,9 +97,8 @@ import Adw
@Test func comboRowUseSubtitleModifierBinding() {
guard Gtk.initCheck() else { return }
let box = StateBox<Bool>(false)
let w = AnyView(ComboRow()
.useSubtitle(Portico.Binding(box)))
.makeWidget(MountContext())
let context = MountContext()
let w = AnyView(ComboRow().useSubtitle(Portico.Binding(box))).makeWidget(context)
guard let row = w as? Adw.ComboRow else { return }
#expect(row.getUseSubtitle() == false)
box.set(true)
@ -147,8 +145,8 @@ import Adw
@Test func nullablePropertyAcceptsNonOptionalBinding() {
guard Gtk.initCheck() else { return }
let box = StateBox<String>("first")
let w = AnyView(StatusPage().description(Portico.Binding(box)))
.makeWidget(MountContext())
let context = MountContext()
let w = AnyView(StatusPage().description(Portico.Binding(box))).makeWidget(context)
guard let page = w as? Adw.StatusPage else { return }
#expect(page.getDescription() == "first")
box.set("second")
@ -161,8 +159,9 @@ import Adw
guard Gtk.initCheck() else { return }
let factory = Gtk.SignalListItemFactory()
let box = StateBox<Gtk.ListItemFactory?>(factory)
let context = MountContext()
let w = AnyView(DropDown(model: nil, expression: nil).factory(Portico.Binding(box)))
.makeWidget(MountContext())
.makeWidget(context)
guard let dropDown = w as? Gtk.DropDown else { return }
#expect(dropDown.getFactory() != nil)
box.set(nil)

View file

@ -29,17 +29,11 @@ import Adw
@Test func twoWayText() {
guard Gtk.initCheck() else { return }
let box = StateBox("hello")
let view = AnyView(
Entry().text(Binding(box))
)
let entry = view.makeWidget(MountContext()) as! Gtk.Entry
let context = MountContext()
let entry = AnyView(Entry().text(Binding(box))).makeWidget(context) as! Gtk.Entry
#expect(entry.text == "hello")
// Binding widget
box.set("world")
#expect(entry.text == "world")
// Widget binding (simulates a keystroke via changed signal)
entry.setText(text: "typed")
#expect(box.peek() == "typed")
}
@ -49,15 +43,11 @@ import Adw
@Test func twoWayToggle() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let view = AnyView(
ToggleButton().active(Binding(box))
)
let toggle = view.makeWidget(MountContext()) as! Gtk.ToggleButton
let context = MountContext()
let toggle = AnyView(ToggleButton().active(Binding(box))).makeWidget(context) as! Gtk.ToggleButton
#expect(toggle.getActive() == false)
box.set(true)
#expect(toggle.getActive() == true)
toggle.setActive(isActive: false)
#expect(box.peek() == false)
}
@ -67,12 +57,8 @@ import Adw
@Test func noEchoLoop() {
guard Gtk.initCheck() else { return }
let box = StateBox("a")
let view = AnyView(
Entry().text(Binding(box))
)
let entry = view.makeWidget(MountContext()) as! Gtk.Entry
// Set from binding side; the signal fires but the equality guard
// in the handler sees values are already equal and stops.
let context = MountContext()
let entry = AnyView(Entry().text(Binding(box))).makeWidget(context) as! Gtk.Entry
box.set("a")
#expect(box.peek() == "a")
#expect(entry.text == "a")

View file

@ -46,10 +46,10 @@ private nonisolated func test_g_main_context_iteration(
}
/// Mounts `view` into a presented `Gtk.Window` and returns the window.
/// Caller must pump the main loop after this to observe async effects.
private func mountInPresentedWindow<V: View>(_ view: V) -> Gtk.Window {
/// The caller retains `context` for the lifetime of reactive subscriptions.
private func mountInPresentedWindow<V: View>(_ view: V, context: MountContext) -> Gtk.Window {
let w = Gtk.Window()
w.setChild(child: AnyView(view).makeWidget(MountContext()))
w.setChild(child: AnyView(view).makeWidget(context))
w.present()
return w
}
@ -62,7 +62,8 @@ private nonisolated func test_g_main_context_iteration(
return
}
var appeared = 0
_ = mountInPresentedWindow(Label(str: "x").onAppear { appeared += 1 })
let context = MountContext()
_ = mountInPresentedWindow(Label(str: "x").onAppear { appeared += 1 }, context: context)
let timedOut = pump(until: { appeared > 0 })
#expect(!timedOut, "main loop pump timed out")
#expect(appeared == 1)
@ -76,8 +77,9 @@ private nonisolated func test_g_main_context_iteration(
return
}
var disappeared = 0
let context = MountContext()
let window = mountInPresentedWindow(
Label(str: "x").onDisappear { disappeared += 1 }
Label(str: "x").onDisappear { disappeared += 1 }, context: context
)
// Wait for the window to map.
let timedOut = pump(until: { window.getMapped() })
@ -98,8 +100,9 @@ private nonisolated func test_g_main_context_iteration(
return
}
var ticks = 0
let context = MountContext()
let window = mountInPresentedWindow(
Label(str: "x").task(every: .milliseconds(1)) { ticks += 1 }
Label(str: "x").task(every: .milliseconds(1)) { ticks += 1 }, context: context
)
let timedOut = pump(until: { ticks >= 2 })
#expect(!timedOut, "main loop pump timed out")
@ -136,12 +139,13 @@ private nonisolated func test_g_main_context_iteration(
return
}
var stage = 0
let context = MountContext()
_ = mountInPresentedWindow(
Label(str: "x").task {
stage = 1
try? await _Concurrency.Task.sleep(for: .milliseconds(5))
stage = 2
}
}, context: context
)
let timedOut = await asyncPump(until: { stage == 2 })
#expect(!timedOut, "async pump timed out waiting for the task to suspend and resume")
@ -156,6 +160,7 @@ private nonisolated func test_g_main_context_iteration(
}
var started = false
var wasCancelled = false
let context = MountContext()
let window = mountInPresentedWindow(
Label(str: "x").task {
started = true
@ -164,9 +169,8 @@ private nonisolated func test_g_main_context_iteration(
} catch is CancellationError {
wasCancelled = true
} catch {
// Leave wasCancelled false - the #expect below still fails.
}
}
}, context: context
)
let startTimedOut = await asyncPump(until: { started })
#expect(!startTimedOut, "task never started")
@ -174,4 +178,38 @@ private nonisolated func test_g_main_context_iteration(
let timedOut = await asyncPump(until: { wasCancelled }, timeout: .milliseconds(500))
#expect(!timedOut, "task was never cancelled after unmap")
}
/// A rapid unmap/remap never runs two copies of the task action at once.
@Test func remapDoesNotOverlapTaskBodies() async {
guard Gtk.initCheck() else {
#expect(Bool(false), "GTK not initialized - tests need a display")
return
}
var active = 0
var peak = 0
var entries = 0
let context = MountContext()
let window = mountInPresentedWindow(
Label(str: "x").task {
entries += 1
active += 1
peak = max(peak, active)
try? await _Concurrency.Task.sleep(for: .milliseconds(50))
active -= 1
}, context: context
)
#expect(!(await asyncPump(until: { entries >= 1 })), "task never started")
var expected = 1
for cycle in 1...3 {
window.setVisible(visible: false)
_ = await asyncPump(until: { false }, timeout: .milliseconds(30))
window.setVisible(visible: true)
expected += 1
let restarted = await asyncPump(until: { entries >= expected })
#expect(!restarted, "remap \(cycle) never restarted the task")
}
_ = await asyncPump(until: { active == 0 }, timeout: .seconds(1))
#expect(peak == 1, "two task bodies overlapped")
}
}

View file

@ -6,6 +6,14 @@ private nonisolated func test_g_main_context_iteration(
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
) -> Int32
@_silgen_name("g_main_context_find_source_by_id")
private nonisolated func test_g_main_context_find_source_by_id(
_ context: UnsafeMutableRawPointer?, _ sourceId: UInt32
) -> UnsafeMutableRawPointer?
@_silgen_name("g_source_destroy")
private nonisolated func test_g_source_destroy(_ source: UnsafeMutableRawPointer)
@MainActor @Suite(.serialized) struct MainLoopSourceTests {
/// Pumps the default main context until `condition` is true OR `timeout`
@ -110,4 +118,22 @@ private nonisolated func test_g_main_context_iteration(
#expect(!timedOut, "main loop pump timed out")
#expect(ticks == 1)
}
/// Destroy-notify may run on a worker thread and must release the callback
/// box without asserting main-actor isolation.
@Test func destroyNotifyOffMainDoesNotTrap() async {
var fired = false
let id = Idle(repeats: true) { fired = true }
guard let source = test_g_main_context_find_source_by_id(nil, id.rawValue) else {
#expect(Bool(false), "attached source not found by id")
return
}
let address = UInt(bitPattern: source)
await Task.detached {
test_g_source_destroy(UnsafeMutableRawPointer(bitPattern: address)!)
}.value
#expect(id.isRemoved)
for _ in 0..<20 { _ = test_g_main_context_iteration(nil, 0) }
#expect(!fired)
}
}

View file

@ -52,8 +52,8 @@ import Testing
@Test func reactivePreferredWidth() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(40)
let w = AnyView(Label(str: "x").preferredWidth(Binding(box))).makeWidget(
MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").preferredWidth(Binding(box))).makeWidget(context)
defer { box.set(0) }
#expect(w.getSizeRequest().width == 40)
@ -63,8 +63,8 @@ import Testing
@Test func reactiveCssClass() {
guard Gtk.initCheck() else { return }
let box = StateBox("a")
let w = AnyView(Label(str: "x").cssClass(Binding(box))).makeWidget(
MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").cssClass(Binding(box))).makeWidget(context)
defer { box.set("") }
#expect(w.hasCssClass(cssClass: "a"))
@ -76,8 +76,8 @@ import Testing
@Test func reactivePreferredHeight() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(99)
let w = AnyView(Label(str: "x").preferredHeight(Binding(box))).makeWidget(
MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").preferredHeight(Binding(box))).makeWidget(context)
_ = box
#expect(w.getSizeRequest().height == 99)
@ -88,8 +88,8 @@ import Testing
@Test func reactiveHexpand() {
guard Gtk.initCheck() else { return }
let box = StateBox<Bool>(false)
let w = AnyView(Label(str: "x").hexpand(Binding(box))).makeWidget(
MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").hexpand(Binding(box))).makeWidget(context)
_ = box
#expect(w.getHexpand() == false)
@ -100,8 +100,8 @@ import Testing
@Test func reactiveVexpand() {
guard Gtk.initCheck() else { return }
let box = StateBox<Bool>(false)
let w = AnyView(Label(str: "x").vexpand(Binding(box))).makeWidget(
MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").vexpand(Binding(box))).makeWidget(context)
_ = box
#expect(w.getVexpand() == false)
@ -112,8 +112,8 @@ import Testing
@Test func reactiveMargin() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(5)
let w = AnyView(Label(str: "x").margin(Binding(box))).makeWidget(
MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").margin(Binding(box))).makeWidget(context)
_ = box
#expect(w.getMarginStart() == 5)
@ -144,12 +144,13 @@ import Testing
@Test func reactiveWidthPreservesHeight() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(50)
let context = MountContext()
let w = AnyView(
Label(str: "x")
.preferredWidth(100) // static height 0 (unset)
.preferredHeight(200) // static width 100 preserved
.preferredWidth(Binding(box)) // reactive width
).makeWidget(MountContext())
.preferredWidth(100)
.preferredHeight(200)
.preferredWidth(Binding(box))
).makeWidget(context)
defer { box.set(0) }
// After static: width=100, height=200
@ -168,9 +169,9 @@ import Testing
@Test func bindBoolToVisible() {
guard Gtk.initCheck() else { return }
let box = StateBox<Bool>(true)
let w = AnyView(
Label(str: "x").bind(Binding(box), to: "visible")
).makeWidget(MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").bind(Binding(box), to: "visible"))
.makeWidget(context)
defer { box.set(false) }
#expect(w.getVisible() == true)
@ -181,9 +182,9 @@ import Testing
@Test func bindStringToTooltip() {
guard Gtk.initCheck() else { return }
let box = StateBox("hello")
let w = AnyView(
Label(str: "x").bind(Binding(box), to: "tooltip-text")
).makeWidget(MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").bind(Binding(box), to: "tooltip-text"))
.makeWidget(context)
defer { box.set("") }
#expect(w.getTooltipText() == "hello")
@ -194,9 +195,9 @@ import Testing
@Test func bindIntToWidthRequest() {
guard Gtk.initCheck() else { return }
let box = StateBox<Int32>(50)
let w = AnyView(
Label(str: "x").bind(Binding(box), to: "width-request")
).makeWidget(MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").bind(Binding(box), to: "width-request"))
.makeWidget(context)
_ = box
#expect(w.getSizeRequest().width == 50)
@ -207,9 +208,9 @@ import Testing
@Test func bindDoubleToOpacity() {
guard Gtk.initCheck() else { return }
let box = StateBox<Double>(1.0)
let w = AnyView(
Label(str: "x").bind(Binding(box), to: "opacity")
).makeWidget(MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").bind(Binding(box), to: "opacity"))
.makeWidget(context)
_ = box
#expect(w.getOpacity() == 1.0)

View file

@ -42,9 +42,8 @@ import PorticoGtk
@Test func menuButtonLabelModifierReactive() {
guard Gtk.initCheck() else { return }
let box = StateBox("a")
let w = AnyView(
MenuButton().label(Binding(box))
).makeWidget(MountContext())
let context = MountContext()
let w = AnyView(MenuButton().label(Binding(box))).makeWidget(context)
guard let mb = w as? Gtk.MenuButton else {
Issue.record("Expected Gtk.MenuButton, got \(type(of: w))")
return

View file

@ -22,6 +22,15 @@ private nonisolated func observable_g_main_context_iteration(
}
}
/// Async pump that yields so a main-actor observation hop can run.
@MainActor private func asyncPump(until condition: () -> Bool, turns: Int = 500) async {
for _ in 0..<turns {
if condition() { return }
_ = observable_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
}
}
// MARK: - Observable model & probes
@Observable
@ -93,6 +102,12 @@ private struct Readout: View {
@Observable
private final class Model { var text = "before" }
/// A deliberately nonisolated observable model for the off-main mutation test.
@Observable
private nonisolated final class OffMainModel: @unchecked Sendable {
var text = "before"
}
@Observable
private final class Mixed { var observed = 0 }
@ -118,7 +133,7 @@ private final class Bench { var amount: Double = 0 }
/// `@Environment(AppState.self)`. Mutating the model from either one
/// updates the widget built by the other, in both directions, with no
/// `Binding` and no tree rebuild.
@Test func observableEnvironmentPropagatesBothWays() {
@Test func observableEnvironmentPropagatesBothWays() async {
guard Gtk.initCheck() else { return }
BodyCount.reset()
@ -141,14 +156,14 @@ private final class Bench { var amount: Double = 0 }
// Direction 1: mutate the model directly (as A's handler would).
state.isFullscreen = true
pump(until: { widgets[0].getText() == "A:on" })
await asyncPump(until: { widgets[0].getText() == "A:on" })
#expect(widgets.map { $0.getText() } == ["A:on", "B:on"])
// Direction 2: mutate through B's own `@Environment` declaration.
let paneB = PaneB()
_ = AnyView(paneB.environment(state)).makeWidget(ctx)
paneB.toggleFromB()
pump(until: { widgets[0].getText() == "A:off" })
await asyncPump(until: { widgets[0].getText() == "A:off" })
#expect(widgets.map { $0.getText() } == ["A:off", "B:off"])
// Targeted: same widget objects, zero body re-evaluations.
@ -158,7 +173,7 @@ private final class Bench { var amount: Double = 0 }
/// The remaining direction: a widget edit writes back into the model and
/// reaches the other layout.
@Test func widgetEditWritesBackThroughTheModel() {
@Test func widgetEditWritesBackThroughTheModel() async {
guard Gtk.initCheck() else { return }
let state = AppState()
@ -180,12 +195,12 @@ private final class Bench { var amount: Double = 0 }
// Simulate the user flipping the switch.
row.setActive(isActive: true)
#expect(state.isFullscreen) // widget -> model is synchronous
pump(until: { readout.getText() == "A:on" })
await asyncPump(until: { readout.getText() == "A:on" })
#expect(readout.getText() == "A:on")
// And model -> widget still works in the same tree.
state.isFullscreen = false
pump(until: { row.getActive() == false })
await asyncPump(until: { row.getActive() == false })
#expect(row.getActive() == false)
#expect(readout.getText() == "A:off")
}
@ -206,7 +221,7 @@ private final class Bench { var amount: Double = 0 }
}
/// Several mutations in one turn coalesce into a single re-evaluation.
@Test func observableMutationsCoalesce() {
@Test func observableMutationsCoalesce() async {
guard Gtk.initCheck() else { return }
let state = AppState()
@ -221,7 +236,7 @@ private final class Bench { var amount: Double = 0 }
state.title = "a"
state.title = "b"
state.title = "c"
pump(until: { label.getText() == "c" })
await asyncPump(until: { label.getText() == "c" })
#expect(label.getText() == "c")
#expect(evaluations == 2) // one flush, not three
@ -246,7 +261,7 @@ private final class Bench { var amount: Double = 0 }
/// widget bound to an observable connects the same single `notify` handler
/// a `@State` binding does - and terminates the echo after one hop rather
/// than oscillating.
@Test func twoWayObservableBindingDoesNotEcho() {
@Test func twoWayObservableBindingDoesNotEcho() async {
guard Gtk.initCheck() else { return }
let model = Bench()
@ -265,7 +280,7 @@ private final class Bench { var amount: Double = 0 }
// model -> widget, on the coalesced flush, exactly once
model.amount = 12
pump(until: { row.getValue() == 12 })
await asyncPump(until: { row.getValue() == 12 })
#expect(row.getValue() == 12)
#expect(model.amount == 12) // no write-back echo changed it
}
@ -274,7 +289,7 @@ private final class Bench { var amount: Double = 0 }
/// observation on every StateBox-driven re-run, and Observation keeps each
/// stale registration until it fires. The bridge de-duplicates by tracker,
/// so the fan-out costs one flush, not one per stale registration.
@Test func staleRearmsCollapseToOneReevaluation() {
@Test func staleRearmsCollapseToOneReevaluation() async {
let box = StateBox(0)
let model = Mixed()
var runs = 0
@ -294,20 +309,20 @@ private final class Bench { var amount: Double = 0 }
// One observable mutation fires all 50 stale onChange closures; the
// bridge collapses them into a single queued re-evaluation.
model.observed = 1
pump(until: { runs == 52 })
await asyncPump(until: { runs == 52 })
#expect(runs == 52)
// And the stale registrations are gone: the next mutation behaves the
// same, not worse.
model.observed = 2
pump(until: { runs == 53 })
await asyncPump(until: { runs == 53 })
#expect(runs == 53)
tracker.teardown()
}
/// End to end: a non-static interpolation reading nothing reactive leaves
/// no tracker behind, while one reading an observable stays live.
@Test func interpolationRetentionIsExact() {
@Test func interpolationRetentionIsExact() async {
guard Gtk.initCheck() else { return }
let inert = 41
@ -324,14 +339,14 @@ private final class Bench { var amount: Double = 0 }
#expect(live.getText() == "value before")
model.text = "after"
pump(until: { live.getText() == "value after" })
await asyncPump(until: { live.getText() == "value after" })
#expect(live.getText() == "value after")
}
/// A mutation is NOT visible in the widget on the same turn: Observation's
/// `onChange` is a willSet hook, so the new value is not yet readable and
/// the flush has to be deferred.
@Test func observableUpdateIsDeferredNotSynchronous() {
@Test func observableUpdateIsDeferredNotSynchronous() async {
guard Gtk.initCheck() else { return }
let model = Model()
@ -342,13 +357,13 @@ private final class Bench { var amount: Double = 0 }
model.text = "after"
#expect(label.getText() == "before") // same turn: still stale
pump(until: { label.getText() == "after" })
await asyncPump(until: { label.getText() == "after" })
#expect(label.getText() == "after")
}
/// ...but it always lands BEFORE anything at GTK's redraw priority, so no
/// frame can be composited from stale widget state.
@Test func flushRunsBeforeGtkRedrawPriority() {
@Test func flushRunsBeforeGtkRedrawPriority() async {
guard Gtk.initCheck() else { return }
let model = Model()
@ -360,9 +375,10 @@ private final class Bench { var amount: Double = 0 }
var textSeenAtResize: String?
model.text = "after"
await _Concurrency.Task.yield()
Idle(priority: SourcePriority(rawValue: 110)) { textSeenAtResize = label.getText() }
Idle(priority: SourcePriority(rawValue: 120)) { textSeenAtRedraw = label.getText() }
pump(until: { textSeenAtRedraw != nil })
await asyncPump(until: { textSeenAtRedraw != nil })
#expect(textSeenAtResize == "after")
#expect(textSeenAtRedraw == "after")
@ -374,10 +390,28 @@ private final class Bench { var amount: Double = 0 }
guard Gtk.initCheck() else { return }
let box = StateBox("before")
let ctx = MountContext()
let label = AnyView(Label(str: "").label(Portico.Binding(box)))
.makeWidget(MountContext()) as! Gtk.Label
.makeWidget(ctx) as! Gtk.Label
#expect(label.getText() == "before")
box.set("after")
#expect(label.getText() == "after") // no pump needed
}
/// An off-main observable mutation hops to the main actor before the
/// observation bridge updates the mounted label.
@Test func offMainObservableMutationDoesNotTrap() async {
guard Gtk.initCheck() else { return }
let model = OffMainModel()
let ctx = MountContext()
let label = AnyView(
Portico.Label(str: "").label { model.text }
).makeWidget(ctx) as! Gtk.Label
defer { ctx.registry.teardown() }
#expect(label.getText() == "before")
await _Concurrency.Task.detached { model.text = "after" }.value
await asyncPump(until: { label.getText() == "after" })
#expect(label.getText() == "after")
}
}

View file

@ -18,6 +18,14 @@ private nonisolated func onChange_g_main_context_iteration(
}
}
@MainActor private func asyncPumpOnChange(until condition: () -> Bool, turns: Int = 500) async {
for _ in 0..<turns {
if condition() { return }
_ = onChange_g_main_context_iteration(nil, 0)
await _Concurrency.Task.yield()
}
}
/// Observable model used to verify closure-backed Observation updates.
@Observable
private final class OnChangeModel {
@ -114,7 +122,7 @@ private struct OnChangePair: Equatable {
#expect(pairs == [OnChangePair(old: 1, new: 2)])
}
@Test func trackedClosureFiresForObservable() {
@Test func trackedClosureFiresForObservable() async {
guard Gtk.initCheck() else { return }
let model = OnChangeModel()
var pairs: [OnChangePair] = []
@ -125,7 +133,7 @@ private struct OnChangePair: Equatable {
defer { ctx.registry.teardown() }
model.number = 1
pumpOnChange(until: { pairs == [OnChangePair(old: 0, new: 1)] })
await asyncPumpOnChange(until: { pairs == [OnChangePair(old: 0, new: 1)] })
#expect(pairs == [OnChangePair(old: 0, new: 1)])
}

View file

@ -79,9 +79,10 @@ private nonisolated func test_g_main_context_iteration(
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let show = Binding(box)
let context = MountContext()
let host = AnyView(VStack {}.dialog(isPresented: show, title: "Details", contentWidth: 360, canClose: false) {
Label(str: "content")
}).makeWidget(MountContext())
}).makeWidget(context)
let window = Adw.Window()
window.setContent(content: host)
window.present()
@ -101,9 +102,10 @@ private nonisolated func test_g_main_context_iteration(
let titleBox = StateBox("First")
let show = Binding(showBox)
let title = Binding(titleBox)
let context = MountContext()
let host = AnyView(VStack {}.dialog(isPresented: show, title: title) {
Label(str: "content")
}).makeWidget(MountContext())
}).makeWidget(context)
let window = Adw.Window()
window.setContent(content: host)
window.present()
@ -177,4 +179,38 @@ private nonisolated func test_g_main_context_iteration(
pump(until: { dismissed.value }, limit: .seconds(5))
#expect(dismissed.value)
}
/// Out-of-range integer dialog properties clamp instead of trapping.
@Test func oversizedIntDialogPropertyClamps() {
guard case .constant(let high) = Int.max._dialogPropertyValue else {
#expect(Bool(false), "Int source must be a constant")
return
}
#expect(high == Int32.max)
guard case .constant(let low) = Int.min._dialogPropertyValue else {
#expect(Bool(false), "Int source must be a constant")
return
}
#expect(low == Int32.min)
}
/// An oversized content width still permits dialog presentation.
@Test func dialogWithOversizedContentWidthPresents() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let show = Binding(box)
let context = MountContext()
let host = AnyView(VStack {}.dialog(isPresented: show, contentWidth: Int.max) {
Label(str: "content")
}).makeWidget(context)
let window = Adw.Window()
window.setContent(content: host)
window.present()
pump(until: { host.getMapped() }, limit: .seconds(2))
show.wrappedValue = true
pump(until: { window.getVisibleDialog() != nil }, limit: .seconds(2))
#expect(window.getVisibleDialog() != nil)
window.close()
context.registry.teardown()
}
}

View file

@ -9,9 +9,10 @@ import Testing
@Test func explicitBindingUpdatesLabel() {
guard Gtk.initCheck() else { return }
let box = StateBox("a")
let w = AnyView(Label(str: "").label(Binding(box))).makeWidget(MountContext())
let ctx = MountContext()
let w = AnyView(Label(str: "").label(Binding(box))).makeWidget(ctx)
let lbl = w as! Gtk.Label
defer { box.set("") } // suppress unused-variable in release
defer { box.set("") }
#expect(lbl.getText() == "a")
box.set("b")
@ -21,8 +22,8 @@ import Testing
@Test func explicitBindingUpdatesMultipleTimes() {
guard Gtk.initCheck() else { return }
let box = StateBox("first")
let lbl = AnyView(Label(str: "").label(Binding(box))).makeWidget(
MountContext()) as! Gtk.Label
let ctx = MountContext()
let lbl = AnyView(Label(str: "").label(Binding(box))).makeWidget(ctx) as! Gtk.Label
defer { box.set("") }
#expect(lbl.getText() == "first")
@ -50,10 +51,10 @@ import Testing
guard Gtk.initCheck() else { return }
MountProbe.count = 0
let box = StateBox(0)
let ctx = MountContext()
defer { box.set(0) }
let lbl = AnyView(ProbeLabel(box: box))
.makeWidget(MountContext()) as! Gtk.Label
let lbl = AnyView(ProbeLabel(box: box)).makeWidget(ctx) as! Gtk.Label
#expect(MountProbe.count == 1)
#expect(lbl.getText() == "n=0")
@ -66,10 +67,10 @@ import Testing
guard Gtk.initCheck() else { return }
MountProbe.count = 0
let box = StateBox("a")
let ctx = MountContext()
defer { box.set("") }
let lbl = AnyView(ProbeBindingLabel(box: box))
.makeWidget(MountContext()) as! Gtk.Label
let lbl = AnyView(ProbeBindingLabel(box: box)).makeWidget(ctx) as! Gtk.Label
#expect(MountProbe.count == 1)
#expect(lbl.getText() == "a")
@ -105,8 +106,9 @@ import Testing
guard Gtk.initCheck() else { return }
let box = StateBox("a")
let parent = Gtk.Box(orientation: .vertical, spacing: 0)
let ctx = MountContext()
do {
let w = AnyView(Label(str: "").label(Binding(box))).makeWidget(MountContext())
let w = AnyView(Label(str: "").label(Binding(box))).makeWidget(ctx)
parent.append(child: w)
}
box.set("b")

View file

@ -168,4 +168,42 @@ private nonisolated func test_g_main_context_iteration(
#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)
}
}

View file

@ -10,52 +10,78 @@ import Testing
@Test func stateBoxNotifiesSubscribers() {
let box = StateBox(1)
var seen: [Int] = []
_ = box.subscribe { seen.append($0) }
box.set(2)
box.set(3)
#expect(seen == [2, 3])
let token = box.subscribe { seen.append($0) }
withExtendedLifetime(token) {
box.set(2)
box.set(3)
#expect(seen == [2, 3])
}
}
@Test func reentrantSetDoesNotClobberOuterNotify() {
let box = StateBox(0)
var log: [Int] = []
var didReenter = false
var tokens: [SubscriptionToken] = []
for _ in 0..<3 {
_ = box.subscribe { value in
tokens.append(box.subscribe { value in
log.append(value)
if !didReenter {
didReenter = true
box.set(99)
}
}
})
}
box.set(1)
withExtendedLifetime(tokens) {
box.set(1)
#expect(log.count == 6)
#expect(log.filter { $0 == 1 }.count == 3)
#expect(log.filter { $0 == 99 }.count == 3)
let nestedIndexes = log.indices.filter { log[$0] == 99 }
#expect(nestedIndexes.count == 3)
#expect(nestedIndexes.last! - nestedIndexes.first! == 2)
#expect(box.peek() == 99)
#expect(log.count == 6)
#expect(log.filter { $0 == 1 }.count == 3)
#expect(log.filter { $0 == 99 }.count == 3)
let nestedIndexes = log.indices.filter { log[$0] == 99 }
#expect(nestedIndexes.count == 3)
#expect(nestedIndexes.last! - nestedIndexes.first! == 2)
#expect(box.peek() == 99)
}
}
@Test func setIfChangedSkipsEqualWrites() {
let box = StateBox(7)
var callCount = 0
_ = box.subscribe { _ in callCount += 1 }
let token = box.subscribe { _ in callCount += 1 }
let binding = Binding(box)
binding.setIfChanged(7)
withExtendedLifetime(token) {
binding.setIfChanged(7)
#expect(callCount == 0)
#expect(box.peek() == 7)
binding.setIfChanged(8)
#expect(callCount == 1)
#expect(box.peek() == 8)
}
}
/// Dropping a token cancels the subscription it owns.
@Test func droppedTokenCancelsSubscription() {
let box = StateBox(0)
var callCount = 0
do { _ = box.subscribe { _ in callCount += 1 } }
box.set(1)
#expect(callCount == 0)
#expect(box.peek() == 7)
}
binding.setIfChanged(8)
#expect(callCount == 1)
#expect(box.peek() == 8)
/// An observing tracker dropped without `teardown()` unregisters itself.
@Test func droppedObservingTrackerUnregisters() {
let before = _porticoObservationRegistrationCount()
for _ in 0..<1000 {
let tracker = DependencyTracker { }
tracker.run()
}
#expect(_porticoObservationRegistrationCount() == before)
}
@Test func cancelStopsNotifications() {
@ -143,9 +169,10 @@ import Testing
let b = Binding(get: { 42 }, set: { _ in })
var called = false
let token = b.subscribe { _ in called = true }
_ = token
// Custom bindings never fire subscriptions
#expect(!called)
withExtendedLifetime(token) {
// Custom bindings never fire subscriptions
#expect(!called)
}
}
// MARK: - State property wrapper

View file

@ -24,9 +24,9 @@ import Adw
@Test func bindingTogglesInPlace() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let w = AnyView(Label(str: "x").card(Binding(box))).makeWidget(MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").card(Binding(box))).makeWidget(context)
defer { box.set(false) }
#expect(!w.hasCssClass(cssClass: "card"))
box.set(true)
#expect(w.hasCssClass(cssClass: "card"))
@ -37,9 +37,9 @@ import Adw
@Test func bindingInitialTrue() {
guard Gtk.initCheck() else { return }
let box = StateBox(true)
let w = AnyView(Label(str: "x").card(Binding(box))).makeWidget(MountContext())
let context = MountContext()
let w = AnyView(Label(str: "x").card(Binding(box))).makeWidget(context)
defer { box.set(false) }
#expect(w.hasCssClass(cssClass: "card"))
}

View file

@ -11,17 +11,12 @@ import Adw
@Test func switchRowActiveTwoWay() {
guard Gtk.initCheck() else { return }
let box = StateBox(false)
let view = AnyView(
SwitchRow().active(Binding(box))
)
let row = view.makeWidget(MountContext()) as! Adw.SwitchRow
let context = MountContext()
let view = AnyView(SwitchRow().active(Binding(box)))
let row = view.makeWidget(context) as! Adw.SwitchRow
#expect(row.getActive() == false)
// Binding -> widget
box.set(true)
#expect(row.getActive() == true)
// Widget -> binding (simulates user toggling)
row.setActive(isActive: false)
#expect(box.peek() == false)
}
@ -107,17 +102,12 @@ import Adw
@Test func spinRowValueTwoWay() {
guard Gtk.initCheck() else { return }
let box = StateBox(10.0)
let view = AnyView(
SpinRow(min: 0, max: 100, step: 5).value(Binding(box))
)
let row = view.makeWidget(MountContext()) as! Adw.SpinRow
let context = MountContext()
let view = AnyView(SpinRow(min: 0, max: 100, step: 5).value(Binding(box)))
let row = view.makeWidget(context) as! Adw.SpinRow
#expect(row.getValue() == 10.0)
// Binding -> widget
box.set(42.0)
#expect(row.getValue() == 42.0)
// Widget -> binding
row.setValue(value: 20.0)
#expect(box.peek() == 20.0)
}
@ -128,17 +118,12 @@ import Adw
@Test func enumPropertyTwoWay() {
guard Gtk.initCheck() else { return }
let box = StateBox(Gtk.Align.start)
let view = AnyView(
Label(str: "x").halign(Binding(box))
)
let w = view.makeWidget(MountContext())
let context = MountContext()
let view = AnyView(Label(str: "x").halign(Binding(box)))
let w = view.makeWidget(context)
#expect(w.getHalign() == Gtk.Align.start)
// Binding -> widget
box.set(.end)
#expect(w.getHalign() == .end)
// Widget -> binding
w.setHalign(align: .center)
#expect(box.peek() == .center)
}
@ -150,21 +135,14 @@ import Adw
@Test func liftedOptionalSkipsNil() {
guard Gtk.initCheck() else { return }
let box = StateBox("A")
let view = AnyView(
Portico.Window().title(Binding(box))
)
let win = view.makeWidget(MountContext()) as! Adw.Window
let context = MountContext()
let view = AnyView(Portico.Window().title(Binding(box)))
let win = view.makeWidget(context) as! Adw.Window
#expect(win.getTitle() == "A")
// Binding -> widget
box.set("B")
#expect(win.getTitle() == "B")
// Widget -> binding (non-nil)
win.setTitle(title: "C")
#expect(box.peek() == "C")
// Widget -> nil should NOT write back
win.setTitle(title: nil)
#expect(box.peek() == "C")
}
@ -172,26 +150,19 @@ import Adw
// MARK: - Non-Equatable stays one-way
/// Gtk.Adjustment is not Equatable, so the binding is one-way only.
/// We verify by checking that the binding pushes a value (adj.getValue())
/// but the widget->binding direction does NOT write back.
@Test func nonEquatableStaysOneWay() {
guard Gtk.initCheck() else { return }
let initial = Gtk.Adjustment(value: 0, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
let other = Gtk.Adjustment(value: 50, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
let box = StateBox(initial)
let view = AnyView(
SpinButton(adjustment: nil, climbRate: 0, digits: 0).adjustment(Binding(box))
)
let sb = view.makeWidget(MountContext()) as! Gtk.SpinButton
// Binding -> widget works (one-way push); verify via the value
let context = MountContext()
let view = AnyView(SpinButton(adjustment: nil, climbRate: 0, digits: 0).adjustment(Binding(box)))
let sb = view.makeWidget(context) as! Gtk.SpinButton
box.set(other)
#expect(sb.getAdjustment().getValue() == 50.0)
// Widget -> binding does NOT write back (non-Equatable)
let third = Gtk.Adjustment(value: 75, lower: 0, upper: 100, stepIncrement: 1, pageIncrement: 10, pageSize: 0)
sb.setAdjustment(adjustment: third)
#expect(box.peek() === other) // unchanged
#expect(box.peek() === other)
}
// MARK: - Teardown stops write-back

View file

@ -7,11 +7,11 @@ import Testing
@Test func backwardReference() {
guard Gtk.initCheck() else { return }
let ref = WidgetRef<Adw.Carousel>()
let context = MountContext()
let root = AnyView(VStack {
Carousel { Label(str: "a") }.ref(ref)
CarouselIndicatorDots().carousel(ref.projectedValue)
}).makeWidget(MountContext()) as! Gtk.Box
}).makeWidget(context) as! Gtk.Box
let first = root.getFirstChild()!
let carousel = Adw.Carousel(retaining: first.pointer)
let dots = Adw.CarouselIndicatorDots(retaining: first.getNextSibling()!.pointer)
@ -21,11 +21,11 @@ import Testing
@Test func forwardReference() {
guard Gtk.initCheck() else { return }
let ref = WidgetRef<Adw.Carousel>()
let context = MountContext()
let root = AnyView(VStack {
CarouselIndicatorDots().carousel(ref.projectedValue)
Carousel { Label(str: "a") }.ref(ref)
}).makeWidget(MountContext()) as! Gtk.Box
}).makeWidget(context) as! Gtk.Box
let first = root.getFirstChild()!
let dots = Adw.CarouselIndicatorDots(retaining: first.pointer)
let carousel = Adw.Carousel(retaining: first.getNextSibling()!.pointer)
@ -36,17 +36,14 @@ import Testing
guard Gtk.initCheck() else { return }
let ref = WidgetRef<Adw.Carousel>()
let items = StateBox<[Int]>([])
let context = MountContext()
let root = AnyView(VStack {
CarouselIndicatorDots().carousel(ref.projectedValue)
ForEach(Binding(items), id: \.self) { _ in
Carousel().ref(ref)
}
}).makeWidget(MountContext()) as! Gtk.Box
ForEach(Binding(items), id: \.self) { _ in Carousel().ref(ref) }
}).makeWidget(context) as! Gtk.Box
let first = root.getFirstChild()!
let dots = Adw.CarouselIndicatorDots(retaining: first.pointer)
#expect(dots.getCarousel() == nil)
items.set([1])
let carousel = Adw.Carousel(retaining: first.getNextSibling()!.pointer)
#expect(dots.getCarousel()?.pointer == carousel.pointer)
@ -73,14 +70,16 @@ import Testing
/// A `WidgetRef` declared at the concrete subclass feeds a modifier whose property
/// is declared `Gtk.Widget?`; the generated overload is generic, so `Binding`'s
/// invariance does not reject it.
/// A `WidgetRef` declared at the concrete subclass feeds a modifier whose property
/// is declared `Gtk.Widget?`; the generated overload is generic.
@Test func widgetTypedBindingOverload() {
guard Gtk.initCheck() else { return }
let entryRef = WidgetRef<Gtk.Entry>()
let context = MountContext()
let root = AnyView(VStack {
Entry().ref(entryRef)
Label(str: "N").mnemonicWidget(entryRef.projectedValue)
}).makeWidget(MountContext()) as! Gtk.Box
}).makeWidget(context) as! Gtk.Box
let first = root.getFirstChild()!
let entry = Gtk.Entry(retaining: first.pointer)
let label = Gtk.Label(retaining: first.getNextSibling()!.pointer)