Add GLibMainExecutor and async .task modifier
This commit is contained in:
parent
a12a9198dc
commit
058945af03
8 changed files with 581 additions and 24 deletions
92
Sources/Example/AsyncDemo.swift
Normal file
92
Sources/Example/AsyncDemo.swift
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
import Portico
|
||||
|
||||
/// Demonstrates Swift concurrency in Portico: a `Task` launched from a button
|
||||
/// handler, or from the view's own `.task` lifecycle modifier, performs work
|
||||
/// off the main actor in stages, and each stage's result is written straight
|
||||
/// into `@State` when the `await` resumes on the main actor.
|
||||
///
|
||||
/// The whole module is `@MainActor`-isolated (`.defaultIsolation(MainActor.self)`
|
||||
/// in `Package.swift`), so `runAsyncWork()`'s body inherits main-actor
|
||||
/// isolation; only the explicit `Task.detached` blocks leave the main thread.
|
||||
struct AsyncDemoPage: View {
|
||||
private static let stageCount = 5
|
||||
|
||||
@State private var status = "Idle"
|
||||
@State private var progress = 0.0
|
||||
@State private var total = 0
|
||||
@State private var isRunning = false
|
||||
|
||||
var body: some View {
|
||||
Clamp {
|
||||
StatusPage {
|
||||
VStack(spacing: 12) {
|
||||
Label { status }
|
||||
.title2()
|
||||
|
||||
ProgressBar()
|
||||
.fraction { progress }
|
||||
.showText(true)
|
||||
.text { "\(Int(progress * 100))%" }
|
||||
.preferredWidth(240)
|
||||
|
||||
Label { "Accumulated total: \(total)" }
|
||||
.dimmed()
|
||||
|
||||
Button("Run Background Work") { Task { await runAsyncWork() } }
|
||||
.pill()
|
||||
.suggestedAction()
|
||||
.sensitive { !isRunning }
|
||||
}
|
||||
.halign(.center)
|
||||
.task { await runAsyncWork() } // scoped to mapped lifetime; cancelled on unmap
|
||||
}
|
||||
.title("Swift Concurrency")
|
||||
.description("Background stages update these widgets as they complete")
|
||||
.iconName("system-run-symbolic")
|
||||
}
|
||||
.hexpand(true)
|
||||
.vexpand(true)
|
||||
}
|
||||
|
||||
/// Runs the staged background job. Re-entrant calls are ignored so a
|
||||
/// second click (or a carousel re-map) cannot interleave two runs.
|
||||
/// Cooperatively cancellable: checks `Task.isCancelled` before each
|
||||
/// stage, so unmapping the view (which cancels the `.task`-owned Task)
|
||||
/// stops the loop at the next stage boundary instead of running to
|
||||
/// completion.
|
||||
private func runAsyncWork() async {
|
||||
guard !isRunning else { return }
|
||||
isRunning = true
|
||||
defer { isRunning = false }
|
||||
status = "Starting..."
|
||||
progress = 0
|
||||
total = 0
|
||||
print("[AsyncDemo] started")
|
||||
|
||||
for stage in 1...Self.stageCount {
|
||||
guard !Task.isCancelled else {
|
||||
print("[AsyncDemo] cancelled at stage \(stage)")
|
||||
return
|
||||
}
|
||||
// Off the main actor: this is where real network or CPU work goes.
|
||||
let chunk = await Self.work(stage: stage)
|
||||
|
||||
// Resumed on the main actor - these three writes drive three
|
||||
// separate live widgets from inside the background job's loop.
|
||||
total += chunk
|
||||
progress = Double(stage) / Double(Self.stageCount)
|
||||
status = "Stage \(stage) of \(Self.stageCount)"
|
||||
print("[AsyncDemo] stage \(stage) -> total \(total)")
|
||||
}
|
||||
status = "Done: \(total)"
|
||||
print("[AsyncDemo] finished: \(total)")
|
||||
}
|
||||
|
||||
/// One stage of genuinely off-main-actor work.
|
||||
private static func work(stage: Int) async -> Int {
|
||||
await Task.detached(priority: .userInitiated) {
|
||||
try? await Task.sleep(for: .milliseconds(300))
|
||||
return stage * 10
|
||||
}.value
|
||||
}
|
||||
}
|
||||
|
|
@ -20,10 +20,11 @@ struct ExampleApp: App {
|
|||
introPage
|
||||
counterPage
|
||||
settingsPage
|
||||
AsyncDemoPage()
|
||||
}
|
||||
.onPageChanged { page in currentPage = page }
|
||||
|
||||
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 3")
|
||||
Label("Uptime: \(uptime)s, Page \(currentPage + 1) of 4")
|
||||
.dimmed()
|
||||
.halign(.center)
|
||||
.margin(8)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,11 @@ import Darwin
|
|||
/// Creates an `Adw.Application`, wires the ``Scene`` tree via
|
||||
/// `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.
|
||||
@_spi(Portico) @MainActor public enum PorticoRuntime {
|
||||
/// Boots the application, creates an `Adw.Application`, wires the
|
||||
/// scene tree via `connectActivate`, runs the GTK main loop, and
|
||||
|
|
@ -22,6 +27,8 @@ import Darwin
|
|||
/// (v1 supports a single ``ApplicationWindow`` scene).
|
||||
/// - Parameter app: The ``App`` instance to run.
|
||||
public static func run<A: App>(_ app: A) {
|
||||
GLibExecutorFactory.install()
|
||||
|
||||
let adwApp = Adw.Application(
|
||||
applicationId: app.applicationId,
|
||||
flags: .defaultFlags
|
||||
|
|
|
|||
|
|
@ -27,18 +27,37 @@ extension View {
|
|||
})
|
||||
}
|
||||
|
||||
/// Schedules `body` on the GLib idle queue each time the widget becomes
|
||||
/// mapped, removing the pending source if the widget unmaps first.
|
||||
/// Runs `action` as a `Task` scoped to the widget's mapped lifetime -
|
||||
/// Portico's analogue of SwiftUI's `task(priority:_:)`.
|
||||
///
|
||||
/// Portico's synchronous analogue of SwiftUI's `task`: the work's lifetime
|
||||
/// is bounded by the widget's appearance. Unlike ``onAppear(perform:)`` the
|
||||
/// body runs after the current main-loop iteration settles, so it observes
|
||||
/// a fully laid-out widget.
|
||||
/// Starts a fresh `Task(priority:)` each time the widget becomes mapped
|
||||
/// and cancels it - cooperatively, via `Task.cancel()`, not a hard stop -
|
||||
/// as soon as the widget unmaps or the subtree tears down. Cancellation
|
||||
/// only becomes visible at a suspension point `action` awaits (or a
|
||||
/// `Task.isCancelled` / `Task.checkCancellation()` check it makes
|
||||
/// itself), matching Swift's cooperative cancellation model.
|
||||
///
|
||||
/// `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.
|
||||
///
|
||||
/// - 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.
|
||||
public func task(
|
||||
priority: SourcePriority = .defaultIdle,
|
||||
_ body: @escaping @MainActor () -> Void
|
||||
priority: TaskPriority = .userInitiated,
|
||||
_ action: @escaping @MainActor () async -> Void
|
||||
) -> AnyView {
|
||||
scopedToAppearance { Idle(priority: priority, repeats: false, body) }
|
||||
scopedToAppearance(
|
||||
start: { () -> _Concurrency.Task<Void, Never> in
|
||||
_Concurrency.Task<Void, Never>(priority: priority) { await action() }
|
||||
},
|
||||
stop: { (task: _Concurrency.Task<Void, Never>) in task.cancel() }
|
||||
)
|
||||
}
|
||||
|
||||
/// Starts a repeating timeout each time the widget becomes mapped and
|
||||
|
|
@ -54,25 +73,30 @@ extension View {
|
|||
priority: SourcePriority = .default,
|
||||
_ body: @escaping @MainActor () -> Void
|
||||
) -> AnyView {
|
||||
scopedToAppearance { Timeout(interval: interval, priority: priority, repeats: true, body) }
|
||||
scopedToAppearance(
|
||||
start: { Timeout(interval: interval, priority: priority, repeats: true, body) },
|
||||
stop: { $0.remove() }
|
||||
)
|
||||
}
|
||||
|
||||
/// Ties one GLib source to the widget's mapped state: `start` runs on each
|
||||
/// `map`, the source it returns is removed on the next `unmap`, and any
|
||||
/// still-live source is removed when the subtree tears down. At most one
|
||||
/// source is live at a time, so remap cycles do not accumulate sources.
|
||||
private func scopedToAppearance(
|
||||
_ start: @escaping @MainActor () -> SourceId
|
||||
/// Ties one cancelable handle (a GLib ``SourceId`` or a Swift `Task`) to
|
||||
/// the widget's mapped state: `start` runs on each `map` and returns the
|
||||
/// 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.
|
||||
private func scopedToAppearance<Handle>(
|
||||
start: @escaping @MainActor () -> Handle,
|
||||
stop: @escaping @MainActor (Handle) -> Void
|
||||
) -> AnyView {
|
||||
AnyView(makeWidget: { ctx in
|
||||
let w = AnyView(self).makeWidget(ctx)
|
||||
var live: SourceId?
|
||||
var live: Handle?
|
||||
let begin: @MainActor () -> Void = {
|
||||
guard live == nil else { return }
|
||||
live = start()
|
||||
}
|
||||
let end: @MainActor () -> Void = {
|
||||
live?.remove()
|
||||
if let handle = live { stop(handle) }
|
||||
live = nil
|
||||
}
|
||||
ctx.registry.add(w.connectMap { _ in begin() })
|
||||
|
|
|
|||
235
Sources/Portico/MainLoop/GLibMainExecutor.swift
Normal file
235
Sources/Portico/MainLoop/GLibMainExecutor.swift
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
@_spi(ExperimentalCustomExecutors) import _Concurrency
|
||||
import Synchronization
|
||||
|
||||
#if canImport(Glibc)
|
||||
import Glibc
|
||||
#elseif canImport(Darwin)
|
||||
import Darwin
|
||||
#endif
|
||||
|
||||
// MARK: - Raw GLib entry points
|
||||
|
||||
/// Creates a new idle source. The source will not initially be associated with any
|
||||
/// `GMainContext` and must be added to one with `portico_g_source_attach`.
|
||||
@_silgen_name("g_idle_source_new")
|
||||
nonisolated func portico_g_idle_source_new() -> UnsafeMutableRawPointer
|
||||
|
||||
/// Decreases the reference count of `source`. When the reference count reaches
|
||||
/// zero the source is freed. Call after `portico_g_source_attach` - the context
|
||||
/// holds its own reference.
|
||||
@_silgen_name("g_source_unref")
|
||||
nonisolated func portico_g_source_unref(_ source: UnsafeMutableRawPointer)
|
||||
|
||||
/// Creates a new `GMainLoop` for the given main context. Pass `nil` for the
|
||||
/// default context. `isRunning` should be `0` to create the loop in a stopped
|
||||
/// state.
|
||||
@_silgen_name("g_main_loop_new")
|
||||
nonisolated func portico_g_main_loop_new(
|
||||
_ context: UnsafeMutableRawPointer?, _ isRunning: Int32
|
||||
) -> UnsafeMutableRawPointer
|
||||
|
||||
/// Runs a main loop until ``portico_g_main_loop_quit`` is called on it.
|
||||
/// Blocks the calling thread. Portico never calls this; `g_application_run`
|
||||
/// owns the blocking loop and the executor is only installed into it.
|
||||
@_silgen_name("g_main_loop_run")
|
||||
nonisolated func portico_g_main_loop_run(_ loop: UnsafeMutableRawPointer)
|
||||
|
||||
/// Stops the given main loop. Idempotent; safe from any thread.
|
||||
@_silgen_name("g_main_loop_quit")
|
||||
nonisolated func portico_g_main_loop_quit(_ loop: UnsafeMutableRawPointer)
|
||||
|
||||
/// Decreases the reference count on the main loop. When the count reaches
|
||||
/// zero the loop is freed.
|
||||
@_silgen_name("g_main_loop_unref")
|
||||
nonisolated func portico_g_main_loop_unref(_ loop: UnsafeMutableRawPointer)
|
||||
|
||||
/// Runs a single iteration for the given main context, blocking if `mayBlock`
|
||||
/// is nonzero. Returns `1` if events were dispatched.
|
||||
@_silgen_name("g_main_context_iteration")
|
||||
nonisolated func portico_g_main_context_iteration(
|
||||
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
|
||||
) -> Int32
|
||||
|
||||
// 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`.
|
||||
#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()
|
||||
}
|
||||
#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.
|
||||
@_cdecl("portico_executor_drain")
|
||||
nonisolated func portico_executor_drain(_ data: UnsafeMutableRawPointer?) -> Int32 {
|
||||
guard let data else { return 0 }
|
||||
Unmanaged<GLibMainExecutor>.fromOpaque(data)
|
||||
.takeUnretainedValue()
|
||||
.drainPendingJobs()
|
||||
return 0
|
||||
}
|
||||
|
||||
// MARK: - Main executor
|
||||
|
||||
/// A `MainExecutor` that drains Swift MainActor 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
|
||||
/// queue and runs each job on the main actor; a task that re-enqueues on every
|
||||
/// 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.
|
||||
@_spi(Portico) nonisolated public final class GLibMainExecutor:
|
||||
MainExecutor, @unchecked Sendable
|
||||
{
|
||||
/// Pending jobs and the armed flag, guarded by a `Mutex` because `enqueue`
|
||||
/// is called from cooperative-pool threads.
|
||||
private struct Pending {
|
||||
var jobs: [UnownedJob] = []
|
||||
var armed = false
|
||||
}
|
||||
|
||||
private let pending = Mutex(Pending())
|
||||
|
||||
/// Bit-pattern of the `GMainLoop` pointer set by `run()`, stored in an
|
||||
/// `Atomic` because `UnsafeMutableRawPointer` is not `Sendable` under
|
||||
/// `StrictConcurrency=complete`.
|
||||
private let loopAddress = Atomic<UInt>(0)
|
||||
|
||||
/// Creates a main executor draining through the default `GMainContext`.
|
||||
@_spi(Portico) public init() {}
|
||||
|
||||
/// Enqueues `job` for execution on the main actor. Thread-safe; may be
|
||||
/// called from any cooperative-pool thread.
|
||||
public func enqueue(_ job: consuming ExecutorJob) {
|
||||
let unowned = UnownedJob(job)
|
||||
let needsArm = pending.withLock { p -> Bool in
|
||||
p.jobs.append(unowned)
|
||||
if p.armed { return false }
|
||||
p.armed = true
|
||||
return true
|
||||
}
|
||||
if needsArm { arm() }
|
||||
}
|
||||
|
||||
/// Attaches a one-shot idle source to the default main context. Safe from
|
||||
/// any thread: `g_source_attach` takes the context lock and wakes any
|
||||
/// blocking `g_main_context_iteration` / `g_main_loop_run`.
|
||||
private func arm() {
|
||||
let source = portico_g_idle_source_new()
|
||||
portico_g_source_set_priority(source, SourcePriority.defaultIdle.rawValue)
|
||||
portico_g_source_set_callback(
|
||||
source, portico_executor_drain,
|
||||
Unmanaged.passUnretained(self).toOpaque(), nil
|
||||
)
|
||||
_ = portico_g_source_attach(source, nil)
|
||||
portico_g_source_unref(source)
|
||||
}
|
||||
|
||||
/// Runs the jobs queued when this source was armed - a snapshot, never a
|
||||
/// drain-until-empty loop, so a task that re-enqueues on every resumption
|
||||
/// yields to the main loop between batches instead of monopolising it.
|
||||
func drainPendingJobs() {
|
||||
let batch = pending.withLock { p -> [UnownedJob] in
|
||||
p.armed = false
|
||||
let batch = p.jobs
|
||||
p.jobs.removeAll(keepingCapacity: true)
|
||||
return batch
|
||||
}
|
||||
for job in batch { job.runSynchronously(on: asUnownedSerialExecutor()) }
|
||||
}
|
||||
|
||||
/// Runs a blocked `GMainLoop` on the default context. Not reentrant.
|
||||
/// Portico never calls this - ``PorticoRuntime/run(_:)`` blocks inside
|
||||
/// `g_application_run` instead - but the executor remains usable as a
|
||||
/// standalone loop for testing or a non-GTK tool.
|
||||
public func run() throws {
|
||||
precondition(
|
||||
loopAddress.load(ordering: .acquiring) == 0,
|
||||
"GLibMainExecutor.run() is not reentrant"
|
||||
)
|
||||
let loop = portico_g_main_loop_new(nil, 0)
|
||||
loopAddress.store(UInt(bitPattern: loop), ordering: .releasing)
|
||||
portico_g_main_loop_run(loop)
|
||||
loopAddress.store(0, ordering: .releasing)
|
||||
portico_g_main_loop_unref(loop)
|
||||
}
|
||||
|
||||
/// Iterates the default main context until `condition` returns `true`.
|
||||
/// Common in tests: create some idle/timeout sources, then
|
||||
/// `runUntil { gotExpectedResult }`.
|
||||
public func runUntil(_ condition: () -> Bool) throws {
|
||||
while !condition() { _ = portico_g_main_context_iteration(nil, 1) }
|
||||
}
|
||||
|
||||
/// Quits the `GMainLoop` started by ``run()``. Idempotent; does nothing
|
||||
/// unless `run()` is currently blocking. Portico never calls this -
|
||||
/// app shutdown is GTK's business.
|
||||
public func stop() {
|
||||
let address = loopAddress.load(ordering: .acquiring)
|
||||
guard address != 0, let loop = UnsafeMutableRawPointer(bitPattern: address)
|
||||
else { return }
|
||||
portico_g_main_loop_quit(loop)
|
||||
}
|
||||
|
||||
/// Precondition that the calling thread is the GLib main thread.
|
||||
/// This is the gate that keeps `MainActor.assumeIsolated` in Portico's
|
||||
/// GLib trampolines sound.
|
||||
public func checkIsolated() {
|
||||
precondition(porticoIsMainThread(), "not on the GLib main thread")
|
||||
}
|
||||
|
||||
/// Returns `true` on the GLib main thread, `false` otherwise.
|
||||
public func isIsolatingCurrentContext() -> Bool? { porticoIsMainThread() }
|
||||
}
|
||||
|
||||
// MARK: - Factory
|
||||
|
||||
/// Installs ``GLibMainExecutor`` as the process main executor. 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
|
||||
|
||||
/// Guards the `_createExecutors` call; the lazy static makes repeat calls
|
||||
/// free so it is safe to call ``install()`` more than once.
|
||||
private static let installOnce: Bool = {
|
||||
_createExecutors(factory: GLibExecutorFactory.self)
|
||||
return true
|
||||
}()
|
||||
|
||||
/// Installs ``GLibMainExecutor`` as the process main executor if it has
|
||||
/// not already been installed this process lifetime. Idempotent; safe to
|
||||
/// call from any thread before `g_application_run`.
|
||||
static func install() { _ = installOnce }
|
||||
}
|
||||
|
|
@ -33,10 +33,11 @@
|
|||
// MARK: - Trampolines
|
||||
|
||||
/// GLib `GSourceFunc` trampoline. Bridges the C callback back to the main
|
||||
/// actor via `MainActor.assumeIsolated`, which is valid because the default
|
||||
/// main context is iterated on the main thread by `PorticoRuntime` /
|
||||
/// `Adw.Application.run`, and the public API is `@MainActor` so sources can
|
||||
/// only be created from the main thread.
|
||||
/// 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`.
|
||||
@_cdecl("portico_source_dispatch")
|
||||
nonisolated func portico_source_dispatch(_ data: UnsafeMutableRawPointer?) -> Int32 {
|
||||
guard let data else { return 0 }
|
||||
|
|
|
|||
129
Tests/PorticoTests/GLibMainExecutorTests.swift
Normal file
129
Tests/PorticoTests/GLibMainExecutorTests.swift
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
import Testing
|
||||
import Synchronization
|
||||
#if canImport(Glibc)
|
||||
import Glibc
|
||||
#endif
|
||||
@_spi(Portico) import Portico
|
||||
|
||||
// MARK: - GLib pump
|
||||
|
||||
@_silgen_name("g_main_context_iteration")
|
||||
private nonisolated func test_g_main_context_iteration(
|
||||
_ context: UnsafeMutableRawPointer?, _ mayBlock: Int32
|
||||
) -> Int32
|
||||
|
||||
// MARK: - Recorder
|
||||
|
||||
private nonisolated final class Recorder: @unchecked Sendable {
|
||||
let ran = Mutex(false)
|
||||
/// `isIsolatingCurrentContext()` sampled inside the drain callback.
|
||||
let isolatingInCallback = Mutex<Bool?>(nil)
|
||||
/// `isIsolatingCurrentContext()` sampled on a cooperative-pool thread.
|
||||
let isolatingDetached = Mutex<Bool?>(nil)
|
||||
}
|
||||
|
||||
// MARK: - Custom-executor actor
|
||||
|
||||
/// An actor whose serial executor is the `GLibMainExecutor` under test, so a
|
||||
/// hop into it produces a real `ExecutorJob` on that executor.
|
||||
private actor Subject {
|
||||
private let executor: GLibMainExecutor
|
||||
nonisolated var unownedExecutor: UnownedSerialExecutor {
|
||||
executor.asUnownedSerialExecutor()
|
||||
}
|
||||
init(_ executor: GLibMainExecutor) { self.executor = executor }
|
||||
|
||||
/// Records `isIsolatingCurrentContext` inside the actor - which runs on
|
||||
/// the GLib main iteration thread if pumped, so this should be `true`.
|
||||
func record(into r: Recorder) {
|
||||
r.isolatingInCallback.withLock { $0 = executor.isIsolatingCurrentContext() }
|
||||
r.ran.withLock { $0 = true }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Suite
|
||||
|
||||
@MainActor @Suite(.serialized) struct GLibMainExecutorTests {
|
||||
|
||||
/// 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`.
|
||||
private func pump(
|
||||
until condition: () -> Bool, timeout: Duration = .seconds(2)
|
||||
) -> 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, 1)
|
||||
}
|
||||
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.
|
||||
@Test func enqueuedJobRunsOnTheMainThreadWhenTheLoopIterates() {
|
||||
let executor = GLibMainExecutor()
|
||||
let subject = Subject(executor)
|
||||
let recorder = Recorder()
|
||||
|
||||
Task.detached { await subject.record(into: recorder) }
|
||||
|
||||
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))")
|
||||
}
|
||||
|
||||
/// Guards against the `g_main_context_invoke_full` style bug where a job
|
||||
/// runs inline inside `enqueue` before the loop iterates.
|
||||
@Test func jobIsNotRunBeforeTheLoopIterates() {
|
||||
let executor = GLibMainExecutor()
|
||||
let subject = Subject(executor)
|
||||
let recorder = Recorder()
|
||||
|
||||
Task.detached { await subject.record(into: recorder) }
|
||||
|
||||
// The job must not have run yet - we haven't pumped the context.
|
||||
#expect(!recorder.ran.withLock { $0 }, "job ran before the loop iterated")
|
||||
|
||||
let timedOut = pump(until: { recorder.ran.withLock { $0 } })
|
||||
#expect(!timedOut, "main loop pump timed out - job never ran")
|
||||
}
|
||||
|
||||
/// `isIsolatingCurrentContext` must return `true` inside the drain
|
||||
/// callback (where the thread owns the default main context) and `false`
|
||||
/// on a cooperative-pool thread - this is what keeps
|
||||
/// `MainActor.assumeIsolated` in Portico's GLib trampolines sound.
|
||||
@Test func isIsolatingCurrentContextTracksTheMainThread() {
|
||||
let executor = GLibMainExecutor()
|
||||
let subject = Subject(executor)
|
||||
let recorder = Recorder()
|
||||
|
||||
// Sample `isIsolatingCurrentContext` directly inside the detached
|
||||
// body, which runs on the cooperative pool - NOT after the actor hop,
|
||||
// which would run on the GLibMainExecutor drain callback. The trailing
|
||||
// actor hop still arms the idle source so the pump wakes and `ran` flips.
|
||||
Task.detached {
|
||||
recorder.isolatingDetached.withLock { $0 = executor.isIsolatingCurrentContext() }
|
||||
await subject.record(into: recorder)
|
||||
}
|
||||
let timedOut = pump(until: { recorder.ran.withLock { $0 } })
|
||||
#expect(!timedOut, "main loop pump timed out")
|
||||
|
||||
let isolating = recorder.isolatingDetached.withLock { $0 }
|
||||
#expect(isolating == false,
|
||||
"isIsolatingCurrentContext should be false on a cooperative-pool thread, got \(String(describing: isolating))")
|
||||
}
|
||||
}
|
||||
|
|
@ -24,6 +24,27 @@ private nonisolated func test_g_main_context_iteration(
|
|||
return expired
|
||||
}
|
||||
|
||||
/// Async analogue of `pump(until:)`, for conditions driven by a real
|
||||
/// `Task` (as `View.task(priority:_:)` now creates) rather than a GLib
|
||||
/// source directly. `pump(until:)`'s busy loop never suspends, so it
|
||||
/// starves any `Task {}` scheduled on the default Swift concurrency main
|
||||
/// executor - MainActor is a serial executor and a non-suspending loop
|
||||
/// holds its turn indefinitely. `await Task.yield()` gives the pending
|
||||
/// task a chance to run each iteration; the non-blocking GLib iteration
|
||||
/// keeps driving GTK map/unmap signals concurrently.
|
||||
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
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
|
|
@ -90,7 +111,7 @@ private nonisolated func test_g_main_context_iteration(
|
|||
#expect(quiet, "timeout kept firing after unmap")
|
||||
}
|
||||
|
||||
// MARK: - task cancelled on teardown (never mapped)
|
||||
// MARK: - task(priority:) cancelled on teardown (never mapped)
|
||||
|
||||
@Test func taskCancelledOnTeardown() {
|
||||
guard Gtk.initCheck() else {
|
||||
|
|
@ -106,4 +127,51 @@ private nonisolated func test_g_main_context_iteration(
|
|||
}
|
||||
#expect(!ran)
|
||||
}
|
||||
|
||||
// MARK: - task(priority:) runs a real async action
|
||||
|
||||
@Test func taskRunsAsyncActionToCompletion() async {
|
||||
guard Gtk.initCheck() else {
|
||||
#expect(Bool(false), "GTK not initialized - tests need a display")
|
||||
return
|
||||
}
|
||||
var stage = 0
|
||||
_ = mountInPresentedWindow(
|
||||
Label(str: "x").task {
|
||||
stage = 1
|
||||
try? await _Concurrency.Task.sleep(for: .milliseconds(5))
|
||||
stage = 2
|
||||
}
|
||||
)
|
||||
let timedOut = await asyncPump(until: { stage == 2 })
|
||||
#expect(!timedOut, "async pump timed out waiting for the task to suspend and resume")
|
||||
}
|
||||
|
||||
// MARK: - task(priority:) cancelled on unmap
|
||||
|
||||
@Test func taskCancelsOnUnmap() async {
|
||||
guard Gtk.initCheck() else {
|
||||
#expect(Bool(false), "GTK not initialized - tests need a display")
|
||||
return
|
||||
}
|
||||
var started = false
|
||||
var wasCancelled = false
|
||||
let window = mountInPresentedWindow(
|
||||
Label(str: "x").task {
|
||||
started = true
|
||||
do {
|
||||
try await _Concurrency.Task.sleep(for: .seconds(5))
|
||||
} catch is CancellationError {
|
||||
wasCancelled = true
|
||||
} catch {
|
||||
// Leave wasCancelled false - the #expect below still fails.
|
||||
}
|
||||
}
|
||||
)
|
||||
let startTimedOut = await asyncPump(until: { started })
|
||||
#expect(!startTimedOut, "task never started")
|
||||
window.setVisible(visible: false)
|
||||
let timedOut = await asyncPump(until: { wasCancelled }, timeout: .milliseconds(500))
|
||||
#expect(!timedOut, "task was never cancelled after unmap")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue