From c49d6e9ab666f64a18a20e27b3c134dbc8b20791 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Wed, 30 Sep 2026 12:35:22 -0400 Subject: [PATCH 1/2] Add `Parameters(context:)` and adopt it A call site wanting parameters on a particular context had to declare them `var` and reassign `context` on the next line, whether or not it went on to change anything else. `Parameters.context` is already public, so this adds no surface beyond the initializer itself. * Added `Parameters.init(context:)` outside the platform conditionals, so the private and embedded builds have it as well as the open-source one. * Adopted it at 15 call sites, dropping the `context` reassignment from each. * Switched ten of those sites to `let`, since they no longer mutate the value. --- Sources/SwiftNetwork/Parameters/Parameters.swift | 6 ++++++ .../SwiftNetworkBenchmarks/BenchmarkUtility.swift | 6 ++---- Sources/Tools/IPUDPTransfer/main.swift | 3 +-- Tests/SwiftNetworkTests/QUICTestHarness.swift | 15 +++++---------- .../SwiftNetworkDemuxTests.swift | 6 ++---- .../SwiftNetworkQUICEarlyDataTests.swift | 6 ++---- .../SwiftNetworkQUICPacketParsingTests.swift | 3 +-- .../SwiftNetworkQUICStackTests.swift | 6 ++---- 8 files changed, 21 insertions(+), 30 deletions(-) diff --git a/Sources/SwiftNetwork/Parameters/Parameters.swift b/Sources/SwiftNetwork/Parameters/Parameters.swift index 4e25f92c..a1be732c 100644 --- a/Sources/SwiftNetwork/Parameters/Parameters.swift +++ b/Sources/SwiftNetwork/Parameters/Parameters.swift @@ -725,6 +725,12 @@ public struct Parameters: Hashable, CustomStringConvertible { } #endif + /// Creates default parameters that run on `context`, activating it. + public init(context: NetworkContext) { + self = Self.init() + self.context = context + } + public var context: NetworkContext { get { pathParameters.context } set { diff --git a/Sources/SwiftNetworkBenchmarks/BenchmarkUtility.swift b/Sources/SwiftNetworkBenchmarks/BenchmarkUtility.swift index 6d68e6c1..7c6409b0 100644 --- a/Sources/SwiftNetworkBenchmarks/BenchmarkUtility.swift +++ b/Sources/SwiftNetworkBenchmarks/BenchmarkUtility.swift @@ -156,8 +156,7 @@ public final class QUICBenchmarkUtility { logger: LoggingHandle ) throws -> QUICClientEndpointResult? { // Set context on parameters to activate the context before asyncing - var parameters = Parameters() - parameters.context = context + var parameters = Parameters(context: context) parameters.isServer = false parameters.defaultStack.transport = .quic(options) @@ -226,8 +225,7 @@ public final class QUICBenchmarkUtility { remoteEndpoint: Endpoint, logger: LoggingHandle ) throws -> QUICServerEndpointResult? { - var serverParameters = Parameters() - serverParameters.context = context + var serverParameters = Parameters(context: context) serverParameters.defaultStack.transport = .quic(options) serverParameters.isServer = true let serverPath = PathProperties(parameters: serverParameters) diff --git a/Sources/Tools/IPUDPTransfer/main.swift b/Sources/Tools/IPUDPTransfer/main.swift index 895aa6f7..f270f9c8 100644 --- a/Sources/Tools/IPUDPTransfer/main.swift +++ b/Sources/Tools/IPUDPTransfer/main.swift @@ -127,8 +127,7 @@ final class IPUDPTransfer { } // Server - var serverParameters = Parameters() - serverParameters.context = context + let serverParameters = Parameters(context: context) let serverPath = PathProperties(parameters: serverParameters) let (serverIPUpper, serverIPLower) = storage.createTestIPInstance() let serverIPOptions = IPProtocol.options() diff --git a/Tests/SwiftNetworkTests/QUICTestHarness.swift b/Tests/SwiftNetworkTests/QUICTestHarness.swift index 2c7e90b2..2b58b1b7 100644 --- a/Tests/SwiftNetworkTests/QUICTestHarness.swift +++ b/Tests/SwiftNetworkTests/QUICTestHarness.swift @@ -174,8 +174,7 @@ class QUICTestHarness { // Async onto the context to attach the protocol stack and start the handshake context.async { // Setup client parameters - var clientParameters = Parameters() - clientParameters.context = self.context + var clientParameters = Parameters(context: self.context) clientParameters.isServer = false // Build the QUIC connection through storage so its listener/multipath linkages are @@ -210,8 +209,7 @@ class QUICTestHarness { clientPath.effectiveMTU = 1500 // Setup server parameters - var serverParameters = Parameters() - serverParameters.context = self.context + var serverParameters = Parameters(context: self.context) serverParameters.isServer = true var (serverQUICStreamListener, serverQUICDatagramListener, serverQUICMultipath) = @@ -462,8 +460,7 @@ class QUICTestHarness { return nil } var upperHarnessConnected = false - var parameters = Parameters() - parameters.context = context + let parameters = Parameters(context: context) var upperHarness: StreamUpperHarness? let newStreamExpectation = XCTestExpectation(description: "Wait for new QUIC stream to be ready") @@ -545,8 +542,7 @@ class QUICTestHarness { return nil } var upperHarnessConnected = false - var parameters = Parameters() - parameters.context = context + let parameters = Parameters(context: context) var upperHarness: DatagramUpperHarness? let newFlowExpectation = XCTestExpectation(description: "Wait for new QUIC datagram flow to be ready") @@ -1993,8 +1989,7 @@ class QUICTestHarness { let maxStreamsUpdateExpectation = XCTestExpectation(description: "Wait for MAX_STREAMS update to be processed") context.async { // Create a new stream by hand, this should put us over the remote max streams limit - var parameters = Parameters() - parameters.context = self.context + let parameters = Parameters(context: self.context) let options = QUICProtocol.options() options.setLogID( prefix: identifier, diff --git a/Tests/SwiftNetworkTests/SwiftNetworkDemuxTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkDemuxTests.swift index 1c62b650..f4359583 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkDemuxTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkDemuxTests.swift @@ -122,8 +122,7 @@ final class SwiftNetworkDemuxTests: NetTestCase { context.async { defer { expectation.fulfill() } - var parameters = Parameters() - parameters.context = context + let parameters = Parameters(context: context) let localEndpoint = Endpoint(address: IPv4Address(Self.localIPv4Address)!, port: 1234) let remoteEndpoint = Endpoint(address: IPv4Address(Self.remoteIPv4Address)!, port: 8080) @@ -204,8 +203,7 @@ final class SwiftNetworkDemuxTests: NetTestCase { [(harness: DatagramUpperHarness, patterns: [DemuxPatternInput])] = [] for demuxedFlow in demuxedFlows { - var demuxParameters = Parameters() - demuxParameters.context = context + let demuxParameters = Parameters(context: context) guard !demuxedFlow.isEmpty else { continue } diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICEarlyDataTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICEarlyDataTests.swift index fa697410..65e385af 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICEarlyDataTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICEarlyDataTests.swift @@ -165,11 +165,9 @@ final class SwiftNetworkQUICEarlyDataTests: NetTestCase { serverIPOptions.setLogID(prefix: "L", parent: identifier, protocolLogIDNumber: 3) serverIPOptions.setProtocolInstance(serverIPLower.identifier) - var clientParameters = Parameters() - clientParameters.context = context + let clientParameters = Parameters(context: context) - var serverParameters = Parameters() - serverParameters.context = context + let serverParameters = Parameters(context: context) let clientPath = PathProperties(parameters: clientParameters) let serverPath = PathProperties(parameters: serverParameters) diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICPacketParsingTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICPacketParsingTests.swift index 6b647ee8..561a3280 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICPacketParsingTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICPacketParsingTests.swift @@ -252,8 +252,7 @@ final class SwiftNetworkQUICPacketParsingTests: NetTestCase { var result: IdleServer? harness.context.async { - var serverParameters = Parameters() - serverParameters.context = harness.context + var serverParameters = Parameters(context: harness.context) serverParameters.isServer = true var (serverQUICStreamListener, _, serverQUICMultipath) = harness.storage.createTestQUICInstance() diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift index 42649bdd..a2754127 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICStackTests.swift @@ -163,11 +163,9 @@ final class SwiftNetworkQUICStackTests: NetTestCase { serverIPOptions.setLogID(prefix: "L", parent: identifier, protocolLogIDNumber: 3) serverIPOptions.setProtocolInstance(serverIPLower.identifier) - var clientParameters = Parameters() - clientParameters.context = context + let clientParameters = Parameters(context: context) - var serverParameters = Parameters() - serverParameters.context = context + let serverParameters = Parameters(context: context) let clientPath = PathProperties(parameters: clientParameters) let serverPath = PathProperties(parameters: serverParameters) From 03cb069297ac764a76bd2f4c6cc2a84ba7deb650 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Wed, 30 Sep 2026 12:37:48 -0400 Subject: [PATCH 2/2] Add a test-support product for driving virtual time `ManualScheduler` and `ManualTimeContext` let a test drive the stack's timers without reading a real clock, now that the scheduler owns time. They sit outside the library so nothing in it depends on them, and are offered as their own product so an adopter can drive time in tests of their own code. * Added `run(until:)` analogous to XCTest's `wait(for:timeout:)`, reporting whether the condition held rather than blocking on real time. * Mocking fires each timer with `now` set to its own deadline, so a timer that reads the clock sees the instant it was scheduled for rather than the end of the advance. * Moved `NetworkClock.Instant.testBase` into the new product and deleted `Tests/QUICTests/TestClock.swift`, which held nothing else. * Imported the new product in the five suites that use `testBase`. * Added `ManualSchedulerTests` to cover the scheduler. --- Package.swift | 13 +- .../ManualScheduler.swift | 317 ++++++++++++++++ .../ManualTimeContext.swift | 68 ++++ Tests/QUICTests/AckTests.swift | 4 + Tests/QUICTests/CubicTests.swift | 4 + Tests/QUICTests/LedbatTests.swift | 4 + Tests/QUICTests/MigrationTests.swift | 4 + Tests/QUICTests/PragueTests.swift | 4 + Tests/QUICTests/TestClock.swift | 37 -- .../ManualSchedulerTests.swift | 346 ++++++++++++++++++ 10 files changed, 762 insertions(+), 39 deletions(-) create mode 100644 Sources/SwiftNetworkTestSupport/ManualScheduler.swift create mode 100644 Sources/SwiftNetworkTestSupport/ManualTimeContext.swift delete mode 100644 Tests/QUICTests/TestClock.swift create mode 100644 Tests/SwiftNetworkTests/ManualSchedulerTests.swift diff --git a/Package.swift b/Package.swift index c886ceed..9f9ef685 100644 --- a/Package.swift +++ b/Package.swift @@ -63,6 +63,10 @@ let package = Package( name: "SwiftNetworkBenchmarks", targets: ["SwiftNetworkBenchmarks"] ), + .library( + name: "SwiftNetworkTestSupport", + targets: ["SwiftNetworkTestSupport"] + ), ], traits: [ .trait( @@ -144,14 +148,19 @@ let package = Package( ], swiftSettings: availabilityMacros + settings ), + .target( + name: "SwiftNetworkTestSupport", + dependencies: ["SwiftNetwork"], + swiftSettings: availabilityMacros + settings + ), .testTarget( name: "SwiftNetworkTests", - dependencies: ["SwiftNetwork", "SwiftNetworkTestHarness"], + dependencies: ["SwiftNetwork", "SwiftNetworkTestHarness", "SwiftNetworkTestSupport"], swiftSettings: availabilityMacros + settings ), .testTarget( name: "QUICTests", - dependencies: ["SwiftNetwork", "SwiftNetworkTestHarness"], + dependencies: ["SwiftNetwork", "SwiftNetworkTestHarness", "SwiftNetworkTestSupport"], swiftSettings: availabilityMacros + settings ), .executableTarget( diff --git a/Sources/SwiftNetworkTestSupport/ManualScheduler.swift b/Sources/SwiftNetworkTestSupport/ManualScheduler.swift new file mode 100644 index 00000000..78310f37 --- /dev/null +++ b/Sources/SwiftNetworkTestSupport/ManualScheduler.swift @@ -0,0 +1,317 @@ +//===----------------------------------------------------------------------===// +// +// 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 canImport(SwiftNetwork) +@_spi(Essentials) @_spi(ProtocolProvider) import SwiftNetwork +#elseif canImport(Network) +@_spi(Essentials) @_spi(ProtocolProvider) import Network +#endif + +#if canImport(Darwin) +internal import Darwin +#elseif canImport(Glibc) +internal import Glibc +#elseif canImport(Musl) +internal import Musl +#endif + +/// A `NetworkContext.Scheduler` that virtualizes time. +/// +/// This scheduler owns both the clock and the timers, because the stack arms timers rather than +/// polling the clock. Advancing time fires each timer with `now` set to that timer's own deadline, +/// so a test can drive a retransmission or an idle timeout without sleeping. +/// +/// Every instant this API takes or returns is in the continuous domain; the absolute clock follows +/// by the same delta. +/// +/// Install it with `NetworkContext(identifier:externalScheduler:)`. +/// +/// **NOTE: **For synchronous tests only: no queue and no locking. Every call must come from the thread that +/// constructed the scheduler. +@available(Network 0.1.0, *) +@_spi(Essentials) +public final class ManualScheduler: NetworkContext.Scheduler { + + /// Comparable in firing order: by deadline, then by the order the timer was armed. + private struct Timer: Comparable { + var deadline: NetworkClock.Instant + var armOrder: UInt64 + var task: () -> Void + + func isDue(by instant: NetworkClock.Instant) -> Bool { + self.deadline <= instant + } + + /// Whether this timer fires during an advance from `continuous` to `deadline`. + /// + /// A timer armed during that advance, for an instant already reached, waits for the next + /// advance rather than firing re-entrantly, matching a serial dispatch queue. + func firesDuringAdvance( + from continuous: NetworkClock.Instant, + to end: NetworkClock.Instant, + armOrderCutoff: UInt64 + ) -> Bool { + if !self.isDue(by: end) { + return false + } + if self.isDue(by: continuous), self.armOrder >= armOrderCutoff { + return false + } + return true + } + + static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.deadline != rhs.deadline { + return lhs.deadline < rhs.deadline + } + return lhs.armOrder < rhs.armOrder + } + + static func == (lhs: Self, rhs: Self) -> Bool { + lhs.deadline == rhs.deadline && lhs.armOrder == rhs.armOrder + } + } + + private var timers: [TimerReference: Timer] = [:] + private var armOrderCounter: UInt64 = 0 + /// Work handed to `runImmediate`, waiting for the next `run()` or advance. + private var queuedWork: [() -> Void] = [] + private var continuous: NetworkClock.Instant + private var absolute: NetworkClock.Instant + + /// The thread that owns this scheduler. + private let owningThread = pthread_self() + + /// How many times `runQueuedWork` drains before concluding nothing is making progress. + private static let maximumQueuedWorkBatches = 10_000 + + /// Creates a scheduler holding both clocks, with no timers armed and no work queued. + /// + /// - Parameters: + /// - now: Must be greater than zero, because much of the stack treats `.zero` as "unset". + /// - nowAbsolute: Defaults to `now`. + @_spi(Essentials) + public init(now: NetworkClock.Instant, nowAbsolute: NetworkClock.Instant? = nil) { + precondition(now > .zero, "manual time must be greater than zero") + self.continuous = now + self.absolute = nowAbsolute ?? now + } + + @_spi(Essentials) + public var now: NetworkClock.Instant { self.continuous } + @_spi(Essentials) + public var nowAbsolute: NetworkClock.Instant { self.absolute } + + /// Whether the calling thread owns the scheduler. + @_spi(Essentials) + public var runningInScheduler: Bool { + pthread_equal(pthread_self(), self.owningThread) != 0 + } + + /// Queues `task` to run at the next `run()` or advance. + /// + /// Does not run in-line to avoid re-entrancy. + @_spi(Essentials) + public func runImmediate(_ task: @escaping (() -> Void)) { + self.queuedWork.append(task) + } + + @_spi(Essentials) + public func schedule(_ task: @escaping (() -> Void), after delay: NetworkDuration, reference: TimerReference) { + self.timers[reference] = Timer( + deadline: self.continuous.advanced(by: delay), + armOrder: self.nextArmOrder(), + task: task + ) + } + + @_spi(Essentials) + public func unschedule(reference: TimerReference) { + self.timers[reference] = nil + } + + /// Consumes an arm order, so no two timers share one. + private func nextArmOrder() -> UInt64 { + let order = self.armOrderCounter + self.armOrderCounter &+= 1 + return order + } + + /// Lets a test assert that something was scheduled, or that canceling really canceled. + @_spi(Essentials) + public var scheduledCount: Int { self.timers.count } + + /// Runs queued work and any timer already due, including whatever those queue in turn. + /// No virtual time passes. + @_spi(Essentials) + public func run() { + self.advanceTime(to: self.continuous) + } + + /// Runs queued work, including anything produced in the course of that work. + /// + /// Work queued by a task joins the back of the line rather than interrupting the batch in + /// progress, matching a serial dispatch queue. Draining in batches rather than recursing is + /// what produces that order. + /// + /// Separate from `run()` because `advanceTime(to:)` calls this method between timers and must + /// not recurse back into the timer scan partway through one. + /// + /// The loop is bounded to protect against a task that re-queues itself. + private func runQueuedWork() { + var batches = 0 + while !self.queuedWork.isEmpty { + batches &+= 1 + if batches > Self.maximumQueuedWorkBatches { + preconditionFailure( + """ + Queued work did not settle in \(Self.maximumQueuedWorkBatches) batches. \ + A task is requeuing itself without making progress; it most likely needs \ + virtual time to advance, which cannot happen while queued work is draining. + """ + ) + } + let batch = self.queuedWork + self.queuedWork.removeAll() + for task in batch { + task() + } + } + } + + /// Moves time forward by `duration`, firing every timer that comes due at its own deadline. + @_spi(Essentials) + public func advanceTime(by duration: NetworkDuration) { + precondition(duration >= .zero, "manual time must not go backwards") + self.advanceTime(to: self.continuous.advanced(by: duration)) + } + + /// Moves time forward to `deadline`, firing every timer that comes due. + /// + /// Time is set to each timer's own deadline before that timer runs, not jumped straight to + /// `deadline`, so a timer that reads `now` sees the time it was scheduled for. + @_spi(Essentials) + public func advanceTime(to deadline: NetworkClock.Instant) { + precondition(deadline >= self.continuous, "manual time must not go backwards") + + // Store the initial value so we don't fire timers armed by this drain. + var armOrderCutoff = self.armOrderCounter + + // Run queued work before we advance time. + self.runQueuedWork() + + while true { + // Folded into one pass: this scan runs once per timer fired, and filtering first would + // allocate a dictionary on every pass. + let next = self.timers.min { lhs, rhs in + let lhsFires = lhs.value.firesDuringAdvance( + from: self.continuous, + to: deadline, + armOrderCutoff: armOrderCutoff + ) + let rhsFires = rhs.value.firesDuringAdvance( + from: self.continuous, + to: deadline, + armOrderCutoff: armOrderCutoff + ) + if lhsFires != rhsFires { + return lhsFires + } + return lhs.value < rhs.value + } + guard let next, + next.value.firesDuringAdvance( + from: self.continuous, + to: deadline, + armOrderCutoff: armOrderCutoff + ) + else { + break + } + + self.timers[next.key] = nil + let instantBefore = self.continuous + self.setContinuousTime(to: next.value.deadline) + next.value.task() + // Timers armed before now are late rather than re-entrant, so let them fire. Only + // when the clock really moved: otherwise a timer that rearms itself for the same + // instant fires forever. + if self.continuous != instantBefore { + armOrderCutoff = self.armOrderCounter + } + self.runQueuedWork() + } + + self.setContinuousTime(to: deadline) + self.runQueuedWork() + } + + /// Sets the continuous clock to `instant`, moving the absolute clock by the same delta. + /// + /// NOTE: `instant`s earlier than current time are ignored. + private func setContinuousTime(to instant: NetworkClock.Instant) { + let delta = self.continuous.duration(to: instant) + guard delta > .zero else { return } + self.continuous = instant + self.absolute = self.absolute.advanced(by: delta) + } + + // MARK: - Running + + private var earliestDeadline: NetworkClock.Instant? { + self.timers.values.lazy.map(\.deadline).min() + } + + /// Drives the scheduler until `condition` holds or `limit` is exhausted, and reports which. + /// + /// Use this where a test would otherwise wait for a callback: nothing else is running to + /// signal one, so progress has to come from this call. Each round drains queued work and then, + /// if the condition still does not hold, jumps to the next armed deadline. Draining first lets + /// work already queued satisfy the condition without any time passing. + /// + /// Prefer `run()` and `advanceTime(by:)` where a test knows how much progress it expects. + /// + /// - Parameters: + /// - condition: Checked after the drain and again after the advance. + /// - limit: How much virtual time to allow before giving up. Nothing real elapses. + /// - rounds: Backstop against a condition that never holds while the scheduler keeps making + /// progress. + /// - Returns: Whether `condition` held. A `false` means the budget or the round count ran out + /// with the condition still unmet, so a caller that ignores it goes on to assert against a + /// state that never arrived. + @_spi(Essentials) + public func run( + until condition: () -> Bool, + limit: NetworkDuration = .seconds(30), + rounds: Int = 10_000 + ) -> Bool { + let deadline = self.continuous.advanced(by: limit) + + for _ in 0.. ManualScheduler { + ManualScheduler(now: self.base, nowAbsolute: nowAbsolute) + } + + // MARK: - Time + + /// Reading repeatedly must yield the same instant; otherwise a duration measured across a test + /// depends on the real clock. + func testTimeDoesNotMoveOnItsOwn() { + let scheduler = self.makeScheduler() + XCTAssertEqual(scheduler.now, self.base) + XCTAssertEqual(scheduler.now, self.base) + } + + func testAbsoluteDefaultsToContinuous() { + XCTAssertEqual(self.makeScheduler().nowAbsolute, self.base) + } + + func testContinuousAndAbsoluteAreSeparate() { + let absolute = NetworkClock.Instant(milliseconds: 5000) + let scheduler = self.makeScheduler(nowAbsolute: absolute) + XCTAssertEqual(scheduler.now, self.base) + XCTAssertEqual(scheduler.nowAbsolute, absolute) + } + + func testAdvanceMovesBothClocks() { + let absolute = NetworkClock.Instant(milliseconds: 5000) + let scheduler = self.makeScheduler(nowAbsolute: absolute) + scheduler.advanceTime(by: .milliseconds(250)) + XCTAssertEqual(scheduler.now, self.base.advanced(by: .milliseconds(250))) + XCTAssertEqual(scheduler.nowAbsolute, absolute.advanced(by: .milliseconds(250))) + } + + /// `Pacer` converts between the two domains using the offset between them, so a scheduler that + /// let them drift would make `Pacer` compute a nonsense interval. + func testAdvanceKeepsTheOffsetBetweenTheClocksFixed() { + let absolute = NetworkClock.Instant(milliseconds: 5000) + let scheduler = self.makeScheduler(nowAbsolute: absolute) + let offsetBefore = scheduler.nowAbsolute.duration(to: scheduler.now) + + scheduler.advanceTime(by: .milliseconds(250)) + + XCTAssertEqual(scheduler.nowAbsolute.duration(to: scheduler.now), offsetBefore) + } + + func testAdvanceAccumulates() { + let scheduler = self.makeScheduler() + for _ in 0..<3 { + scheduler.advanceTime(by: .milliseconds(100)) + } + XCTAssertEqual(scheduler.now, self.base.advanced(by: .milliseconds(300))) + } + + func testAdvanceByZeroLeavesTheClockAlone() { + let scheduler = self.makeScheduler() + scheduler.advanceTime(by: .zero) + XCTAssertEqual(scheduler.now, self.base) + } + + /// `System.Time.now()` truncates to microseconds, so nanosecond steps are only observable on a + /// manual clock. + func testNanosecondResolutionIsPreserved() { + let scheduler = ManualScheduler(now: NetworkClock.Instant(nanoseconds: 1)) + scheduler.advanceTime(by: .nanoseconds(1)) + XCTAssertEqual(scheduler.now.time, .nanoseconds(2)) + } + + /// Elapsed time must be exact, with no dependency on how long the test itself took to run. + func testDurationIsExactAcrossAdvances() { + let scheduler = self.makeScheduler() + let start = scheduler.now + scheduler.advanceTime(by: .milliseconds(5)) + XCTAssertEqual(start.duration(to: scheduler.now), .milliseconds(5)) + } + + // MARK: - Timers + + /// Advancing the clock must fire a timer that comes due. Nothing in the stack polls the clock, + /// so a timer only runs because the scheduler ran it. + func testAdvanceFiresADueTimer() { + let scheduler = self.makeScheduler() + var fired = 0 + scheduler.schedule({ fired += 1 }, after: .milliseconds(100), reference: TimerReference()) + XCTAssertEqual(fired, 0) + + scheduler.advanceTime(by: .milliseconds(100)) + + XCTAssertEqual(fired, 1) + } + + /// `run()` must fire a timer whose deadline has already arrived. A zero-delay wakeup is + /// reachable from production: `Timer.recalculate` clamps an overdue deadline to `.zero`. If + /// `run()` skips it, the timer waits for an advance the test has no reason to make. + func testRunFiresATimerThatIsAlreadyDue() { + let scheduler = self.makeScheduler() + var fired = 0 + scheduler.schedule({ fired += 1 }, after: .zero, reference: TimerReference()) + + scheduler.run() + + XCTAssertEqual(fired, 1) + XCTAssertEqual(scheduler.now, self.base, "firing a due timer must not invent virtual time") + } + + func testATimerNotYetDueDoesNotFire() { + let scheduler = self.makeScheduler() + var fired = 0 + scheduler.schedule({ fired += 1 }, after: .milliseconds(100), reference: TimerReference()) + scheduler.advanceTime(by: .milliseconds(99)) + XCTAssertEqual(fired, 0) + XCTAssertEqual(scheduler.scheduledCount, 1) + } + + /// A delay under a millisecond must keep its own deadline; otherwise it arrives as a zero + /// delay. The wakeup has then already passed its deadline, so the handler recomputes the same + /// delay and arms again forever. + func testASubMillisecondDelayKeepsItsDeadline() { + let scheduler = self.makeScheduler() + var fired = 0 + scheduler.schedule({ fired += 1 }, after: .microseconds(625), reference: TimerReference()) + + scheduler.advanceTime(by: .microseconds(624)) + XCTAssertEqual(fired, 0) + + scheduler.advanceTime(by: .microseconds(1)) + XCTAssertEqual(fired, 1) + XCTAssertEqual(scheduler.now, self.base.advanced(by: .microseconds(625))) + } + + /// A timer must observe its own deadline, not the end of the advance; otherwise any duration it + /// measures against `now` is wrong. + func testATimerSeesTheTimeItWasScheduledFor() { + let scheduler = self.makeScheduler() + var observed: NetworkClock.Instant? + scheduler.schedule({ observed = scheduler.now }, after: .milliseconds(100), reference: TimerReference()) + + // Advance well past the deadline in one step. + scheduler.advanceTime(by: .milliseconds(500)) + + XCTAssertEqual(observed, self.base.advanced(by: .milliseconds(100))) + XCTAssertEqual(scheduler.now, self.base.advanced(by: .milliseconds(500))) + } + + func testTimersFireInDeadlineOrder() { + let scheduler = self.makeScheduler() + var order: [String] = [] + scheduler.schedule({ order.append("late") }, after: .milliseconds(200), reference: TimerReference()) + scheduler.schedule({ order.append("early") }, after: .milliseconds(50), reference: TimerReference()) + + scheduler.advanceTime(by: .milliseconds(300)) + + XCTAssertEqual(order, ["early", "late"]) + } + + /// Ordering must come from the order the timers were armed, not from however the scheduler + /// happens to store them. + func testTimersWithEqualDeadlinesFireInArmOrder() { + let scheduler = self.makeScheduler() + var order: [Int] = [] + // Same deadline for all three. + for index in 0..<3 { + scheduler.schedule( + { order.append(index) }, + after: .milliseconds(100), + reference: TimerReference() + ) + } + + scheduler.advanceTime(by: .milliseconds(100)) + + XCTAssertEqual(order, [0, 1, 2]) + } + + func testUnscheduleCancelsATimer() { + let scheduler = self.makeScheduler() + var fired = 0 + let reference = TimerReference() + scheduler.schedule({ fired += 1 }, after: .milliseconds(100), reference: reference) + scheduler.unschedule(reference: reference) + + scheduler.advanceTime(by: .milliseconds(500)) + + XCTAssertEqual(fired, 0) + XCTAssertEqual(scheduler.scheduledCount, 0) + } + + func testAFiredTimerDoesNotFireTwice() { + let scheduler = self.makeScheduler() + var fired = 0 + scheduler.schedule({ fired += 1 }, after: .milliseconds(100), reference: TimerReference()) + scheduler.advanceTime(by: .milliseconds(100)) + scheduler.advanceTime(by: .milliseconds(100)) + XCTAssertEqual(fired, 1) + } + + /// Rearming from inside a callback is what the QUIC timers do, so the advance has to keep + /// draining rather than taking one pass. + func testATimerScheduledByATimerAlsoFires() { + let scheduler = self.makeScheduler() + var inner = 0 + scheduler.schedule( + { scheduler.schedule({ inner += 1 }, after: .milliseconds(50), reference: TimerReference()) }, + after: .milliseconds(100), + reference: TimerReference() + ) + + scheduler.advanceTime(by: .milliseconds(300)) + + XCTAssertEqual(inner, 1) + } + + // MARK: - Immediate work + + /// `runImmediate` must queue rather than run inline; otherwise it re-enters the caller, which + /// a serial dispatch queue never does. + func testImmediateWorkIsQueuedNotRunInline() { + let scheduler = self.makeScheduler() + var ran = false + scheduler.runImmediate { ran = true } + XCTAssertFalse(ran) + scheduler.run() + XCTAssertTrue(ran) + } + + // MARK: - Wiring into a context + + /// Everything downstream reads time through `context.scheduler`, so a context handed a manual + /// scheduler must report that scheduler's time; otherwise controlling the scheduler here + /// controls nothing. + func testAContextUsesTheSuppliedScheduler() { + let scheduler = self.makeScheduler() + let context = NetworkContext(identifier: "manual", externalScheduler: scheduler) + + XCTAssertEqual(context.scheduler.now, self.base) + + scheduler.advanceTime(by: .seconds(1)) + + XCTAssertEqual(context.scheduler.now, self.base.advanced(by: .seconds(1))) + } + + /// A context given no scheduler must still read the real clock, which is what every existing + /// test depends on. + func testTheDefaultContextStillUsesTheRealClock() { + let context = NetworkContext(identifier: "default") + XCTAssertNotEqual(context.scheduler.now, .zero) + } + + /// Work submitted before an advance belongs to the instant the caller is standing on, so it + /// must run before a timer armed for later; otherwise a `runImmediate` is silently overtaken. + func testQueuedWorkRunsBeforeATimerThatComesDueDuringTheAdvance() { + let scheduler = self.makeScheduler() + var order: [String] = [] + + scheduler.schedule({ order.append("timer") }, after: .milliseconds(10), reference: TimerReference()) + scheduler.runImmediate { order.append("queued") } + + scheduler.advanceTime(by: .milliseconds(10)) + + XCTAssertEqual(order, ["queued", "timer"]) + } + + /// A timer that rearms itself for the instant it just fired on must not spin the advance. The + /// hold-back is what bounds it, so releasing the hold as the clock moves must not release this. + func testASelfRearmingZeroDelayTimerDoesNotSpinTheAdvance() { + let scheduler = self.makeScheduler() + var fired = 0 + func rearm() { + fired += 1 + if fired < 100_000 { + scheduler.schedule({ rearm() }, after: .zero, reference: TimerReference()) + } + } + scheduler.schedule({ rearm() }, after: .zero, reference: TimerReference()) + + scheduler.advanceTime(by: .milliseconds(1)) + + XCTAssertLessThan(fired, 100_000, "a timer rearming itself for the same instant spun the advance") + } + + /// A timer armed part-way through an advance, for an instant the clock has already reached, must + /// fire once the clock moves past it. Holding it for the rest of the advance leaves it arbitrarily + /// overdue -- here 990ms -- and a serial dispatch queue would have run it on the next turn. + func testATimerArmedMidAdvanceFiresOnceTheClockHasMoved() { + let scheduler = self.makeScheduler() + var inner = 0 + // The outer timer fires 10ms in and arms a zero-delay one; the advance runs on to 1000ms. + scheduler.schedule( + { scheduler.schedule({ inner += 1 }, after: .zero, reference: TimerReference()) }, + after: .milliseconds(10), + reference: TimerReference() + ) + + scheduler.advanceTime(by: .seconds(1)) + + XCTAssertEqual(inner, 1, "a zero-delay timer armed mid-advance never fired") + } + + /// A zero-delay timer armed by work that drains at the start of an advance must wait for the + /// next advance. Firing it inside the advance that armed it is re-entrancy, which a serial + /// dispatch queue never does. + func testATimerArmedByTheOpeningDrainWaitsForTheNextAdvance() { + let scheduler = self.makeScheduler() + var fired = 0 + + scheduler.runImmediate { + scheduler.schedule({ fired += 1 }, after: .zero, reference: TimerReference()) + } + + scheduler.advanceTime(by: .milliseconds(10)) + XCTAssertEqual(fired, 0, "the timer fired inside the advance that armed it") + + scheduler.run() + XCTAssertEqual(fired, 1, "the timer never fired on a later advance") + } +}