From 2be16201b617da93421ba52f6ddb8c32bd76dd3e Mon Sep 17 00:00:00 2001 From: deadcafe Date: Wed, 7 Oct 2026 04:32:37 +0530 Subject: [PATCH] Add VirtualTimeScheduler to the test harness End-to-end tests run protocol code on the real-time DefaultScheduler, so every timer-driven assertion waits for real time to pass and reads a clock the test does not control. NetworkContext already takes an external scheduler, but nothing in the test targets implements one that runs timers. VirtualTimeScheduler implements NetworkContext.Scheduler on a clock the test owns. Immediates queue until a driver method drains them, timers fire in deadline order with `now` set to each deadline before its task runs, and runningInScheduler is true only inside a task so the context's own assertion still catches state touched from outside. Timers are kept sorted and found by binary search, so arming or cancelling thousands of them stays fast, and a delay too long for the clock saturates the way DefaultScheduler clamps one. A step limit stops a driver call that still has work after that many tasks, and the stall handler is replaceable so the guard itself can be tested. Harness only: nothing under Sources/SwiftNetwork changes and no dependency is added. --- .../VirtualTimeScheduler.swift | 300 ++++++++++++++ .../VirtualTimeSchedulerTests.swift | 377 ++++++++++++++++++ 2 files changed, 677 insertions(+) create mode 100644 Sources/SwiftNetworkTestHarness/VirtualTimeScheduler.swift create mode 100644 Tests/SwiftNetworkTests/VirtualTimeSchedulerTests.swift diff --git a/Sources/SwiftNetworkTestHarness/VirtualTimeScheduler.swift b/Sources/SwiftNetworkTestHarness/VirtualTimeScheduler.swift new file mode 100644 index 00000000..cb423aa9 --- /dev/null +++ b/Sources/SwiftNetworkTestHarness/VirtualTimeScheduler.swift @@ -0,0 +1,300 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +#if !NETWORK_EMBEDDED + +#if canImport(SwiftNetwork) +@_spi(Essentials) @_spi(ProtocolProvider) import SwiftNetwork +#elseif canImport(Network) +@_spi(Essentials) @_spi(ProtocolProvider) import Network +#endif + +internal import DequeModule + +/// A scheduler that runs a `NetworkContext` on a clock the test owns. +/// +/// Nothing runs until a driver method is called, and time moves only when a driver method moves it, so a +/// test decides exactly which tasks have run and what time the library sees. Timers fire in deadline order +/// with no real waiting, and `now` inside a timer's task is that timer's deadline, which is what lets a +/// deadline the library just reached look reached rather than still pending. +/// +/// Everything here belongs to one thread: the test's. The driver methods run on it, and the library queues +/// its own work from inside the tasks they run, so there is no second thread to lock against. A library +/// path that reaches this scheduler from a dispatch queue of its own is a bug in the test's setup, not +/// something this scheduler covers for. +/// +/// `runningInScheduler` is true only while a driver method is in the middle of a task. `NetworkContext` +/// asserts on it, so touching library state from outside a task fails loudly instead of silently reading +/// state the next task will change. +@_spi(TestHarness) +@available(Network 0.1.0, *) +public final class VirtualTimeScheduler: NetworkContext.Scheduler { + + /// Non-zero because much of the stack treats `.zero` as "unset". + public static var defaultStart: NetworkClock.Instant { + NetworkClock.Instant.zero.advanced(by: .milliseconds(1000)) + } + + /// How far `nowAbsolute` runs ahead of `now`. + /// + /// The two clocks are kept apart so a context that reports one in place of the other fails an equality + /// check instead of matching by coincidence. + public static var absoluteOffset: NetworkDuration { + .milliseconds(4000) + } + + /// The most tasks one driver call may run. Reaching it with work still waiting calls `stallHandler`. + public let stepLimit: Int + + /// Called with `stepLimit` when a driver call has run that many tasks and more are waiting. + /// + /// The default stops the test with a precondition failure, since the usual cause is a task that + /// reschedules itself without end. A test of the guard itself can install a handler that records the + /// trip instead; if the handler returns, the driver call stops where it is and leaves the rest pending. + public var stallHandler: (Int) -> Void = { limit in + preconditionFailure( + "VirtualTimeScheduler ran \(limit) tasks in one driver call; " + + "raise stepLimit or look for a task that reschedules itself without end" + ) + } + + private struct PendingTimer { + let deadline: NetworkClock.Instant + /// Breaks ties between equal deadlines, so timers armed together fire in the order they were armed. + let sequence: UInt64 + let reference: TimerReference + let task: () -> Void + } + + private var immediates = Deque<() -> Void>() + /// Ordered by deadline, then by `sequence`. + private var timers = Deque() + /// Where each armed reference sits in `timers`, so a re-arm or an unschedule finds its entry by binary + /// search instead of a scan. A test that arms thousands of timers would otherwise spend its time here. + private var armed: [TimerReference: (deadline: NetworkClock.Instant, sequence: UInt64)] = [:] + private var nextSequence: UInt64 = 0 + private var running = false + private var stepsTaken = 0 + private var stalled = false + + /// - Parameters: + /// - start: What `now` reads before any driver call moves it. + /// - stepLimit: How many tasks one driver call may run before `stallHandler` is called. + public init(start: NetworkClock.Instant = VirtualTimeScheduler.defaultStart, stepLimit: Int = 100_000) { + precondition(stepLimit > 0, "stepLimit must allow at least one task") + self.now = start + self.stepLimit = stepLimit + } + + // MARK: - Scheduler + + /// Queues the task; nothing runs until a driver method drains the queue. + public func runImmediate(_ task: @escaping (() -> Void)) { + immediates.append(task) + } + + /// Arms a timer at `now` plus the delay, replacing any timer already armed under the same reference. + /// + /// Replacing mirrors `DefaultScheduler`: the library re-arms a timer by scheduling again with the + /// reference it already holds, and expects one firing, not two. A negative delay is due at once. + public func schedule(_ task: @escaping (() -> Void), after delay: NetworkDuration, reference: TimerReference) { + removeTimer(for: reference) + // A delay the clock cannot reach is a deadline that never comes. `DefaultScheduler` clamps one + // rather than trapping, so this saturates instead of overflowing. + let sinceZero = NetworkClock.Instant.zero.duration(to: now).nanoseconds + let (nanoseconds, overflow) = sinceZero.addingReportingOverflow(max(delay, .zero).nanoseconds) + let timer = PendingTimer( + deadline: overflow ? .maximum : NetworkClock.Instant.zero.advanced(by: .nanoseconds(nanoseconds)), + sequence: nextSequence, + reference: reference, + task: task + ) + nextSequence += 1 + // The sequence is the largest so far, so the slot found is after every timer with the same deadline. + timers.insert(timer, at: slot(forDeadline: timer.deadline, sequence: timer.sequence)) + armed[reference] = (timer.deadline, timer.sequence) + } + + /// Removes the pending timer armed under the reference; a reference that owns none is left alone. + public func unschedule(reference: TimerReference) { + removeTimer(for: reference) + } + + /// True only while a driver method is running a task. + public var runningInScheduler: Bool { + running + } + + /// The virtual continuous clock: it moves only through the driver methods. + public private(set) var now: NetworkClock.Instant + + /// The virtual absolute clock, a fixed `absoluteOffset` ahead of `now`. + public var nowAbsolute: NetworkClock.Instant { + now.advanced(by: VirtualTimeScheduler.absoluteOffset) + } + + // MARK: - Driving + + /// The earliest pending deadline, or nil when no timer is armed. + public var nextDeadline: NetworkClock.Instant? { + timers.first?.deadline + } + + /// How many timers are armed. + public var pendingTimerCount: Int { + timers.count + } + + /// Runs every queued immediate, including the ones queued while draining, without moving time. + public func runUntilIdle() { + drive { + drainImmediates() + } + } + + /// Moves `now` forward by the duration, firing every timer due on the way in deadline order. + public func advance(by duration: NetworkDuration) { + advance(to: now.advanced(by: duration)) + } + + /// Moves `now` to the target, firing every timer due on the way in deadline order. + /// + /// A timer armed by a task during the advance fires too if its deadline is inside the window. `now` + /// ends at the target whether or not a timer was there, and it never moves backwards. + public func advance(to target: NetworkClock.Instant) { + drive { + precondition(target >= now, "a virtual clock does not run backwards") + drainImmediates() + while hasTimer(dueBy: target), hasStepsLeft(), let timer = popTimer(dueBy: target) { + run(timer.task) + drainImmediates() + } + // A tripped stall guard leaves the clock where the last task saw it, so the timers it left behind + // are still ahead of `now`. + if !stalled { + now = target + } + } + } + + /// Moves `now` to the next deadline and fires that timer together with the immediates it queues. + /// + /// Returns false, having run only the immediates already queued, when no timer was armed. + @discardableResult + public func runUntilNextTimer() -> Bool { + var fired = false + drive { + drainImmediates() + guard hasTimer(dueBy: nil), hasStepsLeft(), let timer = popTimer(dueBy: nil) else { + return + } + fired = true + run(timer.task) + drainImmediates() + } + return fired + } + + // MARK: - Driver internals + + /// Brackets one driver call: marks the scheduler as running, resets the stall guard, and rejects a + /// driver call made from inside a task, which would run the queue on top of itself. + private func drive(_ body: () -> Void) { + precondition(!running, "a driver method was called from inside a scheduled task") + running = true + stepsTaken = 0 + stalled = false + defer { + running = false + } + body() + } + + private func drainImmediates() { + while !immediates.isEmpty, hasStepsLeft(), let task = immediates.popFirst() { + run(task) + } + } + + /// Whether this driver call may run another task; the first refusal is what trips the stall guard. + /// + /// Asked only with a task waiting, so a call that reaches the limit and has nothing left to run is a + /// finished call, not a stalled one. + private func hasStepsLeft() -> Bool { + if stalled { + return false + } + if stepsTaken < stepLimit { + return true + } + stalled = true + stallHandler(stepLimit) + return false + } + + /// Whether the earliest timer is due by the target; nil means any deadline counts. + private func hasTimer(dueBy target: NetworkClock.Instant?) -> Bool { + guard let first = timers.first else { + return false + } + if let target, target < first.deadline { + return false + } + return true + } + + /// Takes the earliest timer, if it is due by the target, and moves `now` to its deadline before anything + /// runs, so the task sees its own deadline as the present. + private func popTimer(dueBy target: NetworkClock.Instant?) -> PendingTimer? { + guard hasTimer(dueBy: target), let first = timers.first else { + return nil + } + now = first.deadline + armed[first.reference] = nil + return timers.removeFirst() + } + + private func removeTimer(for reference: TimerReference) { + guard let entry = armed.removeValue(forKey: reference) else { + return + } + let index = slot(forDeadline: entry.deadline, sequence: entry.sequence) + precondition(index < timers.count && timers[index].sequence == entry.sequence, "armed timer missing") + timers.remove(at: index) + } + + /// The index of the timer with this deadline and sequence, or where one would go: the first slot whose + /// timer is not ordered before it. + private func slot(forDeadline deadline: NetworkClock.Instant, sequence: UInt64) -> Int { + var low = 0 + var high = timers.count + while low < high { + let middle = (low + high) / 2 + let timer = timers[middle] + if timer.deadline < deadline || (timer.deadline == deadline && timer.sequence < sequence) { + low = middle + 1 + } else { + high = middle + } + } + return low + } + + private func run(_ task: () -> Void) { + stepsTaken += 1 + task() + } +} + +#endif // !NETWORK_EMBEDDED diff --git a/Tests/SwiftNetworkTests/VirtualTimeSchedulerTests.swift b/Tests/SwiftNetworkTests/VirtualTimeSchedulerTests.swift new file mode 100644 index 00000000..49954de5 --- /dev/null +++ b/Tests/SwiftNetworkTests/VirtualTimeSchedulerTests.swift @@ -0,0 +1,377 @@ +//===----------------------------------------------------------------------===// +// +// This source file is part of the Swift open source project +// +// Copyright (c) 2026 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 +// +// See LICENSE.txt for license information +// See CONTRIBUTORS.txt for the list of Swift project authors +// +// SPDX-License-Identifier: Apache-2.0 +// +//===----------------------------------------------------------------------===// + +import XCTest + +#if canImport(SwiftNetwork) +@_spi(Essentials) @_spi(ProtocolProvider) @testable import SwiftNetwork +#elseif canImport(Network) +@_spi(Essentials) @_spi(ProtocolProvider) @testable import Network +#endif + +#if canImport(SwiftNetworkTestHarness) +@_spi(TestHarness) @_spi(Essentials) @_spi(ProtocolProvider) import SwiftNetworkTestHarness +#endif + +@available(Network 0.1.0, *) +final class VirtualTimeSchedulerTests: NetTestCase { + + func testImmediatesRunInOrderAndOnlyWhenDriven() { + let scheduler = VirtualTimeScheduler() + var log: [String] = [] + + scheduler.runImmediate { log.append("first") } + scheduler.runImmediate { + log.append("second") + // Queued while draining, so it must still run inside the same call. + scheduler.runImmediate { log.append("nested") } + } + scheduler.runImmediate { log.append("third") } + + XCTAssertEqual(log, [], "nothing may run on the caller's stack") + + scheduler.runUntilIdle() + + XCTAssertEqual(log, ["first", "second", "third", "nested"]) + } + + func testTimersFireInDeadlineOrderAtTheirDeadline() { + let scheduler = VirtualTimeScheduler() + let start = scheduler.now + var log: [(String, NetworkClock.Instant)] = [] + + for (name, delay) in [("late", 30), ("early-a", 10), ("middle", 20), ("early-b", 10)] { + scheduler.schedule({ log.append((name, scheduler.now)) }, after: .milliseconds(delay), reference: .init()) + } + // A negative delay is due at once, so it comes before anything with a real delay. + scheduler.schedule({ log.append(("negative", scheduler.now)) }, after: .milliseconds(-5), reference: .init()) + + XCTAssertEqual(scheduler.nextDeadline, start) + XCTAssertEqual(scheduler.pendingTimerCount, 5) + + scheduler.advance(by: .milliseconds(50)) + + XCTAssertEqual(log.map(\.0), ["negative", "early-a", "early-b", "middle", "late"]) + XCTAssertEqual( + log.map(\.1), + [0, 10, 10, 20, 30].map { start.advanced(by: .milliseconds($0)) }, + "a task must see its own deadline as the present" + ) + XCTAssertEqual(scheduler.now, start.advanced(by: .milliseconds(50)), "the clock ends at the target") + XCTAssertEqual(scheduler.pendingTimerCount, 0) + XCTAssertNil(scheduler.nextDeadline) + } + + func testAdvanceFiresTimersArmedDuringTheAdvanceThatLandInsideTheWindow() { + let scheduler = VirtualTimeScheduler() + let start = scheduler.now + var log: [String] = [] + + scheduler.schedule( + { + log.append("first") + scheduler.schedule({ log.append("inside") }, after: .milliseconds(10), reference: .init()) + scheduler.schedule({ log.append("beyond") }, after: .milliseconds(50), reference: .init()) + }, + after: .milliseconds(10), + reference: .init() + ) + + scheduler.advance(by: .milliseconds(30)) + + XCTAssertEqual(log, ["first", "inside"]) + XCTAssertEqual(scheduler.pendingTimerCount, 1) + XCTAssertEqual(scheduler.nextDeadline, start.advanced(by: .milliseconds(60))) + XCTAssertEqual(scheduler.now, start.advanced(by: .milliseconds(30))) + + XCTAssertTrue(scheduler.runUntilNextTimer()) + XCTAssertEqual(log, ["first", "inside", "beyond"]) + XCTAssertEqual(scheduler.now, start.advanced(by: .milliseconds(60))) + XCTAssertFalse(scheduler.runUntilNextTimer(), "nothing is left to fire") + } + + /// The library answers a timer by queueing work on the context, and that work has to be done before + /// anything later fires. So an advance first runs what is already queued, and after each timer runs + /// what that timer queued before it takes the next one. + func testAdvanceRunsQueuedWorkBeforeEachTimer() { + let scheduler = VirtualTimeScheduler() + let start = scheduler.now + var log: [(String, NetworkClock.Instant)] = [] + + scheduler.runImmediate { log.append(("queued before the advance", scheduler.now)) } + scheduler.schedule( + { + log.append(("first timer", scheduler.now)) + scheduler.runImmediate { log.append(("queued by the first timer", scheduler.now)) } + }, + after: .milliseconds(10), + reference: .init() + ) + scheduler.schedule( + { log.append(("second timer", scheduler.now)) }, + after: .milliseconds(20), + reference: .init() + ) + + scheduler.advance(by: .milliseconds(30)) + + XCTAssertEqual( + log.map(\.0), + ["queued before the advance", "first timer", "queued by the first timer", "second timer"] + ) + XCTAssertEqual(log.map(\.1), [0, 10, 10, 20].map { start.advanced(by: .milliseconds($0)) }) + } + + func testRunUntilNextTimerRunsQueuedWorkOnBothSidesOfTheTimer() { + let scheduler = VirtualTimeScheduler() + var log: [String] = [] + + // The timer does not exist until the queued task has run, so the queue has to drain first. + scheduler.runImmediate { + log.append("arms the timer") + scheduler.schedule( + { + log.append("timer") + scheduler.runImmediate { log.append("queued by the timer") } + }, + after: .milliseconds(10), + reference: .init() + ) + } + + XCTAssertTrue(scheduler.runUntilNextTimer()) + XCTAssertEqual(log, ["arms the timer", "timer", "queued by the timer"]) + } + + func testUnscheduleStopsATimer() { + let scheduler = VirtualTimeScheduler() + let reference = TimerReference() + var fired = false + + scheduler.schedule({ fired = true }, after: .milliseconds(10), reference: reference) + scheduler.unschedule(reference: reference) + XCTAssertEqual(scheduler.pendingTimerCount, 0) + + // A reference that owns nothing is left alone rather than trapped on. + scheduler.unschedule(reference: TimerReference()) + + scheduler.advance(by: .seconds(1)) + XCTAssertFalse(fired) + } + + /// The library re-arms a timer by scheduling again under the reference it already holds, and the + /// default scheduler honours only the latest request. This one must do the same or a re-armed idle + /// timer would fire twice. + func testSchedulingUnderTheSameReferenceReplacesTheEarlierTimer() { + let scheduler = VirtualTimeScheduler() + let reference = TimerReference() + var log: [String] = [] + + scheduler.schedule({ log.append("earlier") }, after: .milliseconds(10), reference: reference) + scheduler.schedule({ log.append("later") }, after: .milliseconds(20), reference: reference) + XCTAssertEqual(scheduler.pendingTimerCount, 1) + + scheduler.advance(by: .milliseconds(30)) + XCTAssertEqual(log, ["later"]) + } + + /// Re-arming and unscheduling find a timer by its reference among every other armed timer. After a long + /// random run of both, the survivors must still fire in deadline order, ties in the order they were armed. + func testRandomReArmsAndUnschedulesKeepDeadlineOrder() { + let scheduler = VirtualTimeScheduler() + let references = (0..<200).map { _ in TimerReference() } + var expected: [Int: (delay: Int, order: Int)] = [:] + var fired: [Int] = [] + // A fixed seed, so a failure reproduces. + var state: UInt64 = 42 + func roll(_ bound: UInt64) -> Int { + state = state &* 6_364_136_223_846_793_005 &+ 1_442_695_040_888_963_407 + return Int((state >> 33) % bound) + } + + for order in 0..<5000 { + let index = roll(200) + if roll(4) == 0 { + scheduler.unschedule(reference: references[index]) + expected[index] = nil + } else { + // Few distinct delays, so most timers tie with others. + let delay = roll(25) + scheduler.schedule({ fired.append(index) }, after: .milliseconds(delay), reference: references[index]) + expected[index] = (delay, order) + } + } + + XCTAssertEqual(scheduler.pendingTimerCount, expected.count) + scheduler.advance(by: .milliseconds(25)) + + let expectedOrder = + expected + .sorted { ($0.value.delay, $0.value.order) < ($1.value.delay, $1.value.order) } + .map(\.key) + XCTAssertEqual(fired, expectedOrder) + XCTAssertEqual(scheduler.pendingTimerCount, 0) + } + + func testRunningInSchedulerIsTrueOnlyInsideTasks() { + let scheduler = VirtualTimeScheduler() + var seen: [Bool] = [] + + XCTAssertFalse(scheduler.runningInScheduler) + + scheduler.runImmediate { seen.append(scheduler.runningInScheduler) } + scheduler.runUntilIdle() + XCTAssertFalse(scheduler.runningInScheduler) + + scheduler.schedule({ seen.append(scheduler.runningInScheduler) }, after: .milliseconds(1), reference: .init()) + scheduler.advance(by: .milliseconds(1)) + XCTAssertFalse(scheduler.runningInScheduler) + + XCTAssertEqual(seen, [true, true]) + } + + func testStallGuardTripsOnATaskThatRequeuesItselfWithoutEnd() { + let scheduler = VirtualTimeScheduler(stepLimit: 50) + var tripped: [Int] = [] + scheduler.stallHandler = { tripped.append($0) } + var runs = 0 + + func requeue() { + runs += 1 + scheduler.runImmediate(requeue) + } + scheduler.runImmediate(requeue) + + scheduler.runUntilIdle() + + XCTAssertEqual(tripped, [50], "the guard names its limit and trips once per driver call") + XCTAssertEqual(runs, 50, "the driver call stops at the limit") + XCTAssertFalse(scheduler.runningInScheduler, "a tripped call still ends cleanly") + + // The next driver call starts a fresh count, so the loop is caught again rather than let through. + scheduler.runUntilIdle() + XCTAssertEqual(tripped, [50, 50]) + XCTAssertEqual(runs, 100) + } + + /// The guard is for work that does not end. A call that runs exactly the limit and then has nothing + /// left is finished, so it must not be reported as stalled. + func testStallGuardLeavesACallThatEndsAtTheLimitAlone() { + let scheduler = VirtualTimeScheduler(stepLimit: 50) + var tripped = false + scheduler.stallHandler = { _ in tripped = true } + var runs = 0 + + for _ in 0..<25 { + scheduler.runImmediate { runs += 1 } + scheduler.schedule({ runs += 1 }, after: .milliseconds(1), reference: .init()) + } + scheduler.advance(by: .milliseconds(1)) + + XCTAssertEqual(runs, 50) + XCTAssertFalse(tripped) + } + + /// `DefaultScheduler` clamps a delay too long to represent instead of trapping on it, so code that + /// arms such a timer must behave the same here: the timer is held and never comes due. + func testADelayBeyondTheClockIsHeldAndNeverComesDue() { + let scheduler = VirtualTimeScheduler() + var fired = false + + scheduler.schedule({ fired = true }, after: .nanoseconds(Int64.max), reference: .init()) + XCTAssertEqual(scheduler.nextDeadline, .maximum) + + scheduler.advance(by: .days(365)) + XCTAssertFalse(fired) + XCTAssertEqual(scheduler.pendingTimerCount, 1) + } + + func testStallGuardCountsTimersAcrossAnAdvance() { + let scheduler = VirtualTimeScheduler(stepLimit: 20) + var tripped: [Int] = [] + scheduler.stallHandler = { tripped.append($0) } + let start = scheduler.now + let reference = TimerReference() + var runs = 0 + + func rearm() { + runs += 1 + scheduler.schedule(rearm, after: .milliseconds(1), reference: reference) + } + scheduler.schedule(rearm, after: .milliseconds(1), reference: reference) + + scheduler.advance(by: .seconds(1)) + + XCTAssertEqual(tripped, [20]) + XCTAssertEqual(runs, 20) + XCTAssertEqual(scheduler.now, start.advanced(by: .milliseconds(20)), "the clock stops where the guard tripped") + XCTAssertEqual(scheduler.nextDeadline, start.advanced(by: .milliseconds(21)), "the re-armed timer is kept") + } + + func testBothClocksAdvanceTogetherAndStayApart() { + let scheduler = VirtualTimeScheduler() + + let offsetBefore = scheduler.now.duration(to: scheduler.nowAbsolute) + XCTAssertEqual(offsetBefore, VirtualTimeScheduler.absoluteOffset) + XCTAssertNotEqual(offsetBefore, .zero, "a context that confuses the clocks must not match by coincidence") + + scheduler.advance(by: .seconds(2)) + + XCTAssertEqual(scheduler.now.duration(to: scheduler.nowAbsolute), offsetBefore) + } + + /// The end-to-end case: a context timer armed from inside a context task fires at its virtual + /// deadline without the test waiting for it, and the context's own assertion passes on the way. + func testContextTimerFiresAtTheVirtualDeadlineWithoutRealDelay() { + let scheduler = VirtualTimeScheduler() + let context = NetworkContext(identifier: "test", externalScheduler: scheduler) + let start = context.now + var firedAt: NetworkClock.Instant? + var cancelledFired = false + + context.async { + // `assert()` is what every protocol path checks before touching context state; it has to + // hold inside a task the scheduler runs. + context.assert() + XCTAssertTrue(context.runningInContext) + + _ = context.scheduleTimer(duration: .milliseconds(250)) { + context.assert() + firedAt = context.now + } + let cancelled = context.scheduleTimer(duration: .milliseconds(100)) { + cancelledFired = true + } + context.unscheduleTimer(cancelled) + } + + XCTAssertEqual(scheduler.pendingTimerCount, 0, "nothing is armed until the task has run") + scheduler.runUntilIdle() + XCTAssertEqual(scheduler.pendingTimerCount, 1) + XCTAssertNil(firedAt) + + let clock = ContinuousClock() + let began = clock.now + scheduler.advance(by: .seconds(10)) + let elapsed = began.duration(to: clock.now) + + XCTAssertEqual(firedAt, start.advanced(by: .milliseconds(250))) + XCTAssertFalse(cancelledFired) + XCTAssertEqual(context.now, start.advanced(by: .seconds(10))) + XCTAssertEqual(context.nowAbsolute, scheduler.nowAbsolute) + // Ten virtual seconds must not cost anything like ten real ones; the bound is loose on purpose + // so a slow CI machine cannot fail it. + XCTAssertLessThan(elapsed, .seconds(2)) + } +}