Update main loop scheduling, lifecycle, and reactive teardown
This commit is contained in:
parent
7c651e2064
commit
9f1afe9a91
28 changed files with 577 additions and 294 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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() }
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue