import Testing @_spi(Portico) import Portico @_silgen_name("g_main_context_iteration") private nonisolated func test_g_main_context_iteration( _ context: UnsafeMutableRawPointer?, _ mayBlock: Int32 ) -> Int32 @_silgen_name("g_main_context_find_source_by_id") private nonisolated func test_g_main_context_find_source_by_id( _ context: UnsafeMutableRawPointer?, _ sourceId: UInt32 ) -> UnsafeMutableRawPointer? @_silgen_name("g_source_destroy") private nonisolated func test_g_source_destroy(_ source: UnsafeMutableRawPointer) @MainActor @Suite(.serialized) struct MainLoopSourceTests { /// 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 } // MARK: - Duration conversion @Test func clampedMilliseconds() { #expect(_clampedMilliseconds(.seconds(1)) == 1000) #expect(_clampedMilliseconds(.milliseconds(1500)) == 1500) #expect(_clampedMilliseconds(.microseconds(500)) == 1) #expect(_clampedMilliseconds(.zero) == 0) #expect(_clampedMilliseconds(.seconds(-5)) == 0) #expect(_clampedMilliseconds(.seconds(Int64(UInt32.max) / 1000 + 1)) == UInt32.max) } // MARK: - Idle @Test func idleRunsOnceThenSelfRemoves() { var fired = 0 let id = Idle { fired += 1 } #expect(id.rawValue > 0) let timedOut = pump(until: { fired > 0 }) #expect(!timedOut, "main loop pump timed out") #expect(fired == 1) #expect(id.isRemoved) for _ in 0..<20 { _ = test_g_main_context_iteration(nil, 0) } #expect(fired == 1) } @Test func removeBeforeDispatchCancels() { var fired = false let id = Idle { fired = true } id.remove() #expect(id.isRemoved) for _ in 0..<50 { _ = test_g_main_context_iteration(nil, 0) } #expect(!fired) } @Test func removeIsIdempotent() { let id = Idle {} id.remove() id.remove() #expect(id.isRemoved) } // MARK: - Timeout @Test func timeoutRepeatsUntilSelfRemoved() { var ticks = 0 Timeout(interval: .milliseconds(1)) { source in ticks += 1 if ticks == 3 { source.remove() } } let timedOut = pump(until: { ticks >= 3 }) #expect(!timedOut, "main loop pump timed out") #expect(ticks == 3) for _ in 0..<50 { _ = test_g_main_context_iteration(nil, 0) } #expect(ticks == 3) } @Test func nonRepeatingTimeoutFiresOnce() { var ticks = 0 let id = Timeout(interval: .milliseconds(1), repeats: false) { ticks += 1 } let timedOut = pump(until: { ticks > 0 }) #expect(!timedOut, "main loop pump timed out") #expect(ticks == 1) #expect(id.isRemoved) for _ in 0..<50 { _ = test_g_main_context_iteration(nil, 0) } #expect(ticks == 1) } // MARK: - Remove from inside callback (regression) @Test func repeatingTimeoutRemoveFromCallback() { var ticks = 0 Timeout(interval: .milliseconds(1)) { source in ticks += 1 source.remove() } let timedOut = pump(until: { ticks > 0 }) #expect(!timedOut, "main loop pump timed out") #expect(ticks == 1) } /// Destroy-notify may run on a worker thread and must release the callback /// box without asserting main-actor isolation. @Test func destroyNotifyOffMainDoesNotTrap() async { var fired = false let id = Idle(repeats: true) { fired = true } guard let source = test_g_main_context_find_source_by_id(nil, id.rawValue) else { #expect(Bool(false), "attached source not found by id") return } let address = UInt(bitPattern: source) await Task.detached { test_g_source_destroy(UnsafeMutableRawPointer(bitPattern: address)!) }.value #expect(id.isRemoved) for _ in 0..<20 { _ = test_g_main_context_iteration(nil, 0) } #expect(!fired) } }