diff --git a/Sources/Portico/App/PorticoRuntime.swift b/Sources/Portico/App/PorticoRuntime.swift index 500dc54..8a1697d 100644 --- a/Sources/Portico/App/PorticoRuntime.swift +++ b/Sources/Portico/App/PorticoRuntime.swift @@ -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(_ app: A) { + #if canImport(Glibc) GLibExecutorFactory.install() + #elseif canImport(Darwin) + DarwinMainQueuePump.install() + #endif PorticoRuntime._resolveRootProperties(app) let adwApp = Adw.Application( diff --git a/Sources/Portico/MainLoop/DarwinMainQueuePump.swift b/Sources/Portico/MainLoop/DarwinMainQueuePump.swift new file mode 100644 index 0000000..b1679ea --- /dev/null +++ b/Sources/Portico/MainLoop/DarwinMainQueuePump.swift @@ -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 diff --git a/Sources/Portico/MainLoop/GLibMainExecutor.swift b/Sources/Portico/MainLoop/GLibMainExecutor.swift index c8a6e37..a6d350a 100644 --- a/Sources/Portico/MainLoop/GLibMainExecutor.swift +++ b/Sources/Portico/MainLoop/GLibMainExecutor.swift @@ -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