portico/Tests/PorticoTests/GLibMainExecutorTests.swift

185 lines
7.4 KiB
Swift

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
@_silgen_name("g_main_context_default")
private nonisolated func test_g_main_context_default() -> UnsafeMutableRawPointer
@_silgen_name("g_main_context_acquire")
private nonisolated func test_g_main_context_acquire(
_ context: UnsafeMutableRawPointer
) -> Int32
@_silgen_name("g_main_context_release")
private nonisolated func test_g_main_context_release(_ context: UnsafeMutableRawPointer)
// 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)
/// Whether the detached worker acquired the default context.
let acquiredContext = Mutex(false)
}
// 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).
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
}
/// Async analogue of `pump(until:)`: yields each turn so finished tasks
/// can be reclaimed while GLib keeps iterating.
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
}
// MARK: - Tests
/// The core claim: an enqueued job on a `GLibMainExecutor` is dispatched
/// by a GLib main-context iteration.
@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 == false,
"a local test executor does not run on the process initial thread")
}
/// 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))")
}
/// A worker thread that owns the default context is not the process initial
/// thread and must not be treated as the main actor's isolation thread.
@Test func defaultContextOwnerOnAWorkerThreadIsNotTheMainThread() async {
let executor = GLibMainExecutor()
let recorder = Recorder()
await Task.detached {
let context = test_g_main_context_default()
guard test_g_main_context_acquire(context) != 0 else { return }
recorder.acquiredContext.withLock { $0 = true }
recorder.isolatingDetached.withLock {
$0 = executor.isIsolatingCurrentContext()
}
test_g_main_context_release(context)
}.value
#expect(recorder.acquiredContext.withLock { $0 })
#expect(recorder.isolatingDetached.withLock { $0 } == false)
}
/// The drain source's retained executor reference is released after the
/// source fires, allowing a local executor to deallocate.
@Test func armedSourceReleasesTheExecutorWhenItFires() async {
let recorder = Recorder()
weak var executorRef: GLibMainExecutor?
do {
let executor = GLibMainExecutor()
executorRef = executor
let subject = Subject(executor)
Task.detached { await subject.record(into: recorder) }
let timedOut = await asyncPump(until: { recorder.ran.withLock { $0 } })
#expect(!timedOut, "job never drained")
}
_ = await asyncPump(until: { executorRef == nil }, timeout: .seconds(1))
#expect(executorRef == nil, "drain source retained the executor")
}
}