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() }
}