diff --git a/Sources/Example/AsyncDemo.swift b/Sources/Example/AsyncDemo.swift new file mode 100644 index 0000000..507e639 --- /dev/null +++ b/Sources/Example/AsyncDemo.swift @@ -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 + } +} diff --git a/Sources/Example/ExampleApp.swift b/Sources/Example/ExampleApp.swift index 5ea1c62..97e7467 100644 --- a/Sources/Example/ExampleApp.swift +++ b/Sources/Example/ExampleApp.swift @@ -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) diff --git a/Sources/Portico/App/PorticoRuntime.swift b/Sources/Portico/App/PorticoRuntime.swift index 908b6aa..0b15e01 100644 --- a/Sources/Portico/App/PorticoRuntime.swift +++ b/Sources/Portico/App/PorticoRuntime.swift @@ -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(_ app: A) { + GLibExecutorFactory.install() + let adwApp = Adw.Application( applicationId: app.applicationId, flags: .defaultFlags diff --git a/Sources/Portico/Core/View+Lifecycle.swift b/Sources/Portico/Core/View+Lifecycle.swift index c595da2..5c1ff34 100644 --- a/Sources/Portico/Core/View+Lifecycle.swift +++ b/Sources/Portico/Core/View+Lifecycle.swift @@ -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 in + _Concurrency.Task(priority: priority) { await action() } + }, + stop: { (task: _Concurrency.Task) 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( + 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() }) diff --git a/Sources/Portico/MainLoop/GLibMainExecutor.swift b/Sources/Portico/MainLoop/GLibMainExecutor.swift new file mode 100644 index 0000000..eabcc9f --- /dev/null +++ b/Sources/Portico/MainLoop/GLibMainExecutor.swift @@ -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.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(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 } +} diff --git a/Sources/Portico/MainLoop/MainLoopSources.swift b/Sources/Portico/MainLoop/MainLoopSources.swift index 3cdd2de..dd7bc90 100644 --- a/Sources/Portico/MainLoop/MainLoopSources.swift +++ b/Sources/Portico/MainLoop/MainLoopSources.swift @@ -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 } diff --git a/Tests/PorticoTests/GLibMainExecutorTests.swift b/Tests/PorticoTests/GLibMainExecutorTests.swift new file mode 100644 index 0000000..14aeb70 --- /dev/null +++ b/Tests/PorticoTests/GLibMainExecutorTests.swift @@ -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(nil) + /// `isIsolatingCurrentContext()` sampled on a cooperative-pool thread. + let isolatingDetached = Mutex(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))") + } +} diff --git a/Tests/PorticoTests/LifecycleTests.swift b/Tests/PorticoTests/LifecycleTests.swift index 6d9a2e4..18e0477 100644 --- a/Tests/PorticoTests/LifecycleTests.swift +++ b/Tests/PorticoTests/LifecycleTests.swift @@ -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(_ 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") + } }