Bridge MainActor to GLib main loop on Darwin without SPI

This commit is contained in:
Brendan Szymanski 2026-08-12 02:39:44 -04:00
parent e52f9c06e8
commit e0dd29297a
3 changed files with 97 additions and 12 deletions

View file

@ -15,10 +15,14 @@ import Darwin
/// `connectActivate`, runs the GTK main loop, and propagates the exit
/// code to the process.
///
/// Installing ``GLibMainExecutor`` as the process main executor here means
/// that every `Task { }`, `await`, and main-actor resumption in a Portico
/// app is drained by `g_application_run` - no boilerplate in user code and
/// no change to the GTK application lifecycle.
/// On Linux, installing ``GLibMainExecutor`` as the process main executor
/// here means that every `Task { }`, `await`, and main-actor resumption in a
/// Portico app is drained by `g_application_run` directly. On Darwin, where
/// that install path needs a stdlib SPI Apple does not ship,
/// ``DarwinMainQueuePump`` drains the stdlib's default main executor instead
/// by periodically running `CFRunLoop` from a GLib timeout source - see its
/// doc comment for why. Either way, no boilerplate in user code and no
/// change to the GTK application lifecycle.
@_spi(Portico) @MainActor public enum PorticoRuntime {
/// The environment an ``App``'s ``DynamicProperty`` declarations resolve against.
///
@ -182,7 +186,11 @@ import Darwin
/// must conform to ``MountableScene``.
/// - Parameter app: The ``App`` instance to run.
public static func run<A: App>(_ app: A) {
#if canImport(Glibc)
GLibExecutorFactory.install()
#elseif canImport(Darwin)
DarwinMainQueuePump.install()
#endif
PorticoRuntime._resolveRootProperties(app)
let adwApp = Adw.Application(

View file

@ -0,0 +1,67 @@
#if canImport(Darwin)
import Darwin
import CoreFoundation
// MARK: - Darwin main-queue pump
/// Drains Swift's default `MainActor` executor by periodically running the
/// process's `CFRunLoop` from inside the GLib main loop.
///
/// ## Why this exists
///
/// On Linux, ``GLibMainExecutor`` is installed as the literal process main
/// executor via the stdlib's `@_spi(ExperimentalCustomExecutors)` API, so
/// every `Task { }` and `await` resumption is driven directly by GLib idle
/// sources. That SPI has no accessible implementation on Apple platforms:
/// `_Concurrency` is an OS-vended, library-evolution (resilient) framework
/// there, and Apple ships only its public `.swiftinterface` - `@_spi`
/// declarations require a `.private.swiftinterface`, which no distributed
/// Xcode or Command Line Tools install carries for this module. There is no
/// stable substitute for overriding what backs `MainActor` on Darwin today.
///
/// So on Darwin, `MainActor` keeps the stdlib's default executor, which is
/// backed by the main dispatch queue. GTK's own macOS backend
/// (`gdkmacoseventsource.c`) keeps GLib's poll-based main loop primary and
/// pulls native Cocoa events into it via a replaced `GPollFunc` - it does not
/// hand control to `CFRunLoop`. That means `Adw.Application.run` blocks the
/// thread inside GLib's loop exactly as it does on Linux, and the main
/// dispatch queue is never given a chance to run on its own.
///
/// This pump closes that gap without reaching for any private or
/// experimental API: a low-priority, always-repeating `Timeout` source drains
/// `CFRunLoopRunInMode` a few times per GLib iteration. Draining the run loop
/// in `.defaultMode` is what services the main dispatch queue on every Apple
/// platform (it is the same mechanism `RunLoop.main.run()` relies on), so any
/// `Task { @MainActor in }` continuation queued by the stdlib's default
/// executor gets a chance to run within one pump interval. Synchronous GTK
/// signal/source callbacks are unaffected either way: those already reach
/// `@MainActor` code through `MainActor.assumeIsolated`, which asserts
/// same-thread isolation rather than routing through an executor, so they
/// have never depended on which executor backs `MainActor`.
@_spi(Portico) @MainActor public enum DarwinMainQueuePump {
/// How often the pump drains the run loop. Short enough that `await`
/// resumption feels immediate against a 60-120Hz UI; long enough that the
/// idle cost is negligible (`CFRunLoopRunInMode` returns immediately when
/// nothing is pending).
private static let interval = Duration.milliseconds(4)
/// Guards the `Timeout` attach; the lazy static makes repeat calls free
/// so it is safe to call ``install()`` more than once.
private static let installOnce: Bool = {
Timeout(interval: DarwinMainQueuePump.interval, priority: .default, repeats: true) {
// `.defaultMode` is the mode `RunLoop.main`/`CFRunLoopRun()` use
// by default and the one the main dispatch queue's run-loop
// source is registered against. Loop until the run loop reports
// nothing left to do, so a burst of queued continuations drains
// in one GLib tick rather than trickling out one per `interval`.
while CFRunLoopRunInMode(.defaultMode, 0, true) == .handledSource {}
}
return true
}()
/// Starts draining the main dispatch queue from the GLib main loop if it
/// has not already been started this process lifetime. Idempotent; call
/// before ``Adw/Application/run(argv:)`` blocks the thread.
@_spi(Portico) public static func install() { _ = installOnce }
}
#endif

View file

@ -1,4 +1,6 @@
#if canImport(Glibc)
@_spi(ExperimentalCustomExecutors) import _Concurrency
#endif
import Synchronization
#if canImport(Glibc)
@ -92,7 +94,7 @@ nonisolated func portico_executor_destroy_notify(_ data: UnsafeMutableRawPointer
// MARK: - Main executor
/// A `MainExecutor` that drains Swift MainActor jobs through the GLib main loop.
/// A `SerialExecutor` that drains Swift jobs through the GLib main loop.
///
/// Each call to `enqueue` posts a one-shot `G_PRIORITY_DEFAULT_IDLE` source onto
/// the default `GMainContext`. The source's callback snapshots the pending job
@ -100,12 +102,15 @@ nonisolated func portico_executor_destroy_notify(_ data: UnsafeMutableRawPointer
/// resumption therefore yields to the GTK main loop between batches instead of
/// monopolising it.
///
/// ``GLibMainExecutor`` is installed as the process main executor by
/// ``PorticoRuntime/run(_:)``. After that, every `Task { }`, `await`, and
/// main-actor resumption is drained by `g_application_run` with no change to
/// the GTK application lifecycle.
/// On Linux, ``GLibExecutorFactory`` installs this as the process main
/// executor via a stdlib SPI unavailable on Apple platforms (see
/// ``DarwinMainQueuePump``), so every `Task { }`, `await`, and main-actor
/// resumption is drained by `g_application_run` with no change to the GTK
/// application lifecycle. On every platform it also works as a plain
/// `SerialExecutor` for an individual actor, which is how the test suite
/// exercises it without depending on the Linux-only install path.
@_spi(Portico) nonisolated public final class GLibMainExecutor:
MainExecutor, Sendable
SerialExecutor, Sendable
{
/// Pending jobs and the armed flag, guarded by a `Mutex` because `enqueue`
/// is called from cooperative-pool threads.
@ -210,10 +215,14 @@ nonisolated func portico_executor_destroy_notify(_ data: UnsafeMutableRawPointer
public func isIsolatingCurrentContext() -> Bool? { porticoIsMainThread() }
}
#if canImport(Glibc)
// MARK: - Factory
/// Installs ``GLibMainExecutor`` as the process main executor. The concurrent
/// pool is left as the stdlib's platform default.
/// Installs ``GLibMainExecutor`` as the process main executor via the
/// stdlib's `@_spi(ExperimentalCustomExecutors)` API. Linux only - see
/// ``DarwinMainQueuePump`` for the Darwin equivalent, which cannot use this
/// mechanism because Apple does not ship the private module interface the
/// SPI needs. The concurrent pool is left as the stdlib's platform default.
nonisolated struct GLibExecutorFactory: ExecutorFactory {
static let mainExecutor: any MainExecutor = GLibMainExecutor()
static let defaultExecutor: any TaskExecutor = PlatformExecutorFactory.defaultExecutor
@ -230,3 +239,4 @@ nonisolated struct GLibExecutorFactory: ExecutorFactory {
/// call from any thread before `g_application_run`.
static func install() { _ = installOnce }
}
#endif