Add GLibMainExecutor and async .task modifier

This commit is contained in:
Brendan Szymanski 2026-07-29 23:55:09 -04:00
parent a12a9198dc
commit 058945af03
8 changed files with 581 additions and 24 deletions

View 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
}
}

View file

@ -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)

View file

@ -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

View file

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

View 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 }
}

View file

@ -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 }

View 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))")
}
}

View file

@ -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")
}
}