185 lines
7.4 KiB
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")
|
|
}
|
|
}
|