Expose an async main-loop pump helper for swift-testing suites (@_spi(Portico)) #1

Open
opened 2026-08-14 19:38:19 +00:00 by echo · 0 comments
Owner

Problem

swift-testing does not run @MainActor test bodies on the process's initial thread. Portico's own ObservationBridge (Sources/Portico/State/ObservationBridge.swift) hops off-main mutations back to the main actor via _Concurrency.Task { @MainActor in ObservationBridge.markDirty(id) } (see _porticoObservationDidChange), and GLibMainExecutor drains scheduled Swift jobs from a GLib idle source. Both paths only make progress when the GLib default main context is iterated from a suspension point - a synchronous busy-loop pump called before an assertion can never observe the async-scheduled update, because the Task that performs the hop has not had a chance to run yet.

Portico's own test suite already works around this: Tests/PorticoTests/ObservableTests.swift declares a private asyncPump(until:turns:) that awaits Task.yield()-style turns while iterating the main context, backed by a hand-rolled:

@_silgen_name("g_main_context_iteration")
private nonisolated func observable_g_main_context_iteration(
 _ context: UnsafeMutableRawPointer?,
 _ mayBlock: Int32
) -> Int32

This is internal to Portico's test target, so it is invisible to downstream consumers. In gtk-swift/luminate, the exact same trap showed up twice while writing suites for Observation-driven state (Preferences, ClientSessionBinder), and both call sites had to redeclare an identical async pump plus an identical @_silgen_name("g_main_context_iteration") shim, just under different names:

  • Tests/LuminateUITests/PreferenceTests.swift (preference_g_main_context_iteration)
  • Tests/LuminateTests/ClientSessionBinderTests.swift (clientSessionBinder_g_main_context_iteration)

That is three copies of the same ~15-line workaround across two repositories, and every future downstream package that tests @Observable state driven through Portico's main loop will need a fourth.

Ask

Expose Portico's own async pump as a small @_spi(Portico) API so downstream test targets can @_spi(Portico) import Portico and call it directly instead of hand-rolling @_silgen_name. Something in the shape of:

/// Drives the GLib default main context, yielding at each turn so a
/// main-actor hop scheduled from a background thread (e.g. an
/// off-main Observation mutation) can run. Returns `true` once
/// `condition()` holds, or `false` after `turns` iterations without it.
@_spi(Portico) @MainActor public func _porticoPumpMainContext(
 until condition: () -> Bool = { false },
 turns: Int = 200
) async -> Bool

A precedent for this shape of API already exists: _porticoObservationRegistrationCount() in ObservationBridge.swift is already @_spi(Portico) @MainActor public.

Where this belongs

Sources/Portico/MainLoop/GLibMainExecutor.swift already owns porticoIsMainThread() and the g_main_context_iteration main-loop plumbing for the executor; the new helper is a thin wrapper over the same primitive and seems like a natural fit there (or a small new file under Sources/Portico/State/ next to ObservationBridge.swift, since its only real caller is code awaiting an Observation-driven update). Happy to send a PR if a location is confirmed.

Repro / context

Seen while writing gtk-swift/luminate's LuminateUITests and LuminateTests suites. Full failure signature before the fix: a synchronous pump loop runs g_main_context_iteration N times immediately after mutating an @Observable model's watched preference, then asserts the dependent state updated - and the assertion fails because the Task { @MainActor in ... } hop had not yet been scheduled onto the context being pumped. Switching each suite to an async pump that awaits each turn (so the scheduled Task gets a chance to run between iterations) fixes it; that fix is exactly what this issue asks to no longer have to hand-roll.

## Problem swift-testing does not run `@MainActor` test bodies on the process's initial thread. Portico's own `ObservationBridge` (`Sources/Portico/State/ObservationBridge.swift`) hops off-main mutations back to the main actor via `_Concurrency.Task { @MainActor in ObservationBridge.markDirty(id) }` (see `_porticoObservationDidChange`), and `GLibMainExecutor` drains scheduled Swift jobs from a GLib idle source. Both paths only make progress when the GLib default main context is iterated from a suspension point - a synchronous busy-loop pump called before an assertion can never observe the async-scheduled update, because the `Task` that performs the hop has not had a chance to run yet. Portico's own test suite already works around this: `Tests/PorticoTests/ObservableTests.swift` declares a private `asyncPump(until:turns:)` that awaits `Task.yield()`-style turns while iterating the main context, backed by a hand-rolled: ```swift @_silgen_name("g_main_context_iteration") private nonisolated func observable_g_main_context_iteration( _ context: UnsafeMutableRawPointer?, _ mayBlock: Int32 ) -> Int32 ``` This is internal to Portico's test target, so it is invisible to downstream consumers. In `gtk-swift/luminate`, the exact same trap showed up twice while writing suites for Observation-driven state (`Preferences`, `ClientSessionBinder`), and both call sites had to redeclare an identical async pump plus an identical `@_silgen_name("g_main_context_iteration")` shim, just under different names: - `Tests/LuminateUITests/PreferenceTests.swift` (`preference_g_main_context_iteration`) - `Tests/LuminateTests/ClientSessionBinderTests.swift` (`clientSessionBinder_g_main_context_iteration`) That is three copies of the same ~15-line workaround across two repositories, and every future downstream package that tests `@Observable` state driven through Portico's main loop will need a fourth. ## Ask Expose Portico's own async pump as a small `@_spi(Portico)` API so downstream test targets can `@_spi(Portico) import Portico` and call it directly instead of hand-rolling `@_silgen_name`. Something in the shape of: ```swift /// Drives the GLib default main context, yielding at each turn so a /// main-actor hop scheduled from a background thread (e.g. an /// off-main Observation mutation) can run. Returns `true` once /// `condition()` holds, or `false` after `turns` iterations without it. @_spi(Portico) @MainActor public func _porticoPumpMainContext( until condition: () -> Bool = { false }, turns: Int = 200 ) async -> Bool ``` A precedent for this shape of API already exists: `_porticoObservationRegistrationCount()` in `ObservationBridge.swift` is already `@_spi(Portico) @MainActor public`. ## Where this belongs `Sources/Portico/MainLoop/GLibMainExecutor.swift` already owns `porticoIsMainThread()` and the `g_main_context_iteration` main-loop plumbing for the executor; the new helper is a thin wrapper over the same primitive and seems like a natural fit there (or a small new file under `Sources/Portico/State/` next to `ObservationBridge.swift`, since its only real caller is code awaiting an Observation-driven update). Happy to send a PR if a location is confirmed. ## Repro / context Seen while writing `gtk-swift/luminate`'s `LuminateUITests` and `LuminateTests` suites. Full failure signature before the fix: a synchronous pump loop runs `g_main_context_iteration` N times immediately after mutating an `@Observable` model's watched preference, then asserts the dependent state updated - and the assertion fails because the `Task { @MainActor in ... }` hop had not yet been scheduled onto the context being pumped. Switching each suite to an `async` pump that `await`s each turn (so the scheduled `Task` gets a chance to run between iterations) fixes it; that fix is exactly what this issue asks to no longer have to hand-roll.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: gtk-swift/portico#1
No description provided.