From 8456a45a42e772c437562186fef6e9c9fa3ae49a Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 4 Sep 2026 20:52:45 -0400 Subject: [PATCH 1/6] Read the clock through the scheduler Datapath code read `NetworkClock.Instant.now` directly, so no test could control it: the value came from `mach_continuous_time` however the code was driven. The scheduler already owns the timers, so it is the natural owner of time as well; one that holds scheduled tasks rather than arming an OS timer has to report the instant it fires them at. * Added `now` and `nowAbsolute` to `NetworkContext.Scheduler`, and to `DefaultScheduler` as the one conformance allowed to read the real clock. * Renamed `NetworkClock.Instant.now` to `package systemNow`, and `nowAbsolute` to `systemNowAbsolute`, so a direct read is visible at the call site rather than looking ordinary. * Routed the library's remaining reads through `context.scheduler` or the instant the caller already holds. * Passed a scheduler to `QLog`, whose start time and two event timestamps had nowhere else to get a clock from. * Left `QUICStreamLoad` reading the system clock, since a benchmark measuring wall-clock duration wants exactly that. Source-breaking for anything outside the package that conforms to `Scheduler`. --- .../SwiftNetwork/Context/NetworkContext.swift | 18 ++++++++ .../SwiftNetwork/Protocols/IPProtocol.swift | 2 +- Sources/SwiftNetwork/QUIC/Pacer.swift | 4 +- Sources/SwiftNetwork/QUIC/QLog.swift | 13 ++++-- .../SwiftNetwork/QUIC/QUICConnection.swift | 12 +++-- .../SwiftNetwork/Utilities/NetworkClock.swift | 12 +++-- Sources/Tools/QUICStreamLoad/main.swift | 12 ++--- Tests/QUICTests/CubicTests.swift | 6 +-- Tests/QUICTests/PacerTests.swift | 6 +-- .../QUICPathValidationMessageTests.swift | 2 +- Tests/QUICTests/RecoveryTests.swift | 2 +- .../SwiftNetworkClockTests.swift | 46 +++++++++---------- .../SwiftNetworkContextTests.swift | 5 ++ .../SwiftNetworkQUICIdleTests.swift | 2 +- 14 files changed, 89 insertions(+), 53 deletions(-) diff --git a/Sources/SwiftNetwork/Context/NetworkContext.swift b/Sources/SwiftNetwork/Context/NetworkContext.swift index c4f6de76..e09963a9 100644 --- a/Sources/SwiftNetwork/Context/NetworkContext.swift +++ b/Sources/SwiftNetwork/Context/NetworkContext.swift @@ -88,6 +88,17 @@ public final class NetworkContext: NetworkContextProtocol, @unchecked Sendable { func unschedule(reference: TimerReference) /// A Boolean value that indicates whether the current code is running in the scheduler. var runningInScheduler: Bool { get } + /// The scheduler's current notion of the continuous clock. + /// + /// The scheduler owns time because it owns the timers: one that holds scheduled tasks + /// instead of arming an OS timer has to report the time it fires them at, or a deadline it + /// just ran would still look like it is in the future. + var now: NetworkClock.Instant { get } + /// The scheduler's current notion of the absolute clock. + /// + /// Separate from `now` because the two clocks diverge across system sleep, and `Pacer` + /// reads the difference between them as a domain offset. + var nowAbsolute: NetworkClock.Instant { get } } /// Indicates the privacy level for the context. @@ -389,6 +400,13 @@ extension NetworkContext { // TODO: Not supported by DispatchQueue fatalError("Unsupported") } + /// The real clock. This is the one scheduler allowed to read it. + var now: NetworkClock.Instant { + NetworkClock.Instant.systemNow + } + var nowAbsolute: NetworkClock.Instant { + NetworkClock.Instant.systemNowAbsolute + } } } diff --git a/Sources/SwiftNetwork/Protocols/IPProtocol.swift b/Sources/SwiftNetwork/Protocols/IPProtocol.swift index af9ca58c..7fdd08f6 100644 --- a/Sources/SwiftNetwork/Protocols/IPProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/IPProtocol.swift @@ -2044,7 +2044,7 @@ public struct IPProtocol: NetworkProtocol { &self.instanceType, log: self.log, frames: &inboundFrames, - now: NetworkClock.Instant.now + now: self.context.scheduler.now ) guard !inboundFrames.isEmpty else { log.error("Dropped inbound packets, checking for more") diff --git a/Sources/SwiftNetwork/QUIC/Pacer.swift b/Sources/SwiftNetwork/QUIC/Pacer.swift index 18785031..bca1962d 100644 --- a/Sources/SwiftNetwork/QUIC/Pacer.swift +++ b/Sources/SwiftNetwork/QUIC/Pacer.swift @@ -60,8 +60,8 @@ struct Pacer: ~Copyable { guard let path else { return } - let continuousTime = NetworkClock.Instant.now - let absoluteTime: NetworkClock.Instant = .nowAbsolute + let continuousTime = path.parentProtocol.context.scheduler.now + let absoluteTime = path.parentProtocol.context.scheduler.nowAbsolute if packetSentTime == .zero { packetSentTime = absoluteTime diff --git a/Sources/SwiftNetwork/QUIC/QLog.swift b/Sources/SwiftNetwork/QUIC/QLog.swift index f07f7c94..4c2ca36e 100644 --- a/Sources/SwiftNetwork/QUIC/QLog.swift +++ b/Sources/SwiftNetwork/QUIC/QLog.swift @@ -588,11 +588,14 @@ final class QLog { private var eventsList: [Event] private var topLevelObject: [String: Any] private var disableTimestamps: Bool = false - private let startTime = NetworkClock.Instant.now + private let scheduler: any NetworkContext.Scheduler + private let startTime: NetworkClock.Instant var configuration: QLogConfiguration? - public init(configuration: QLogConfiguration? = nil) { + public init(configuration: QLogConfiguration? = nil, scheduler: any NetworkContext.Scheduler) { self.configuration = configuration + self.scheduler = scheduler + self.startTime = scheduler.now self.eventsList = [] self.topLevelObject = [:] self.topLevelObject["qlog_version"] = "draft-01" @@ -734,8 +737,9 @@ final class QLog { bytesInFlight: UInt64 = UInt64.max, slowStartThresh: UInt64 = UInt64.max, packetsInFlight: UInt64 = UInt64.max, - timestamp: NetworkClock.Instant = .now + timestamp: NetworkClock.Instant? = nil ) { + let timestamp = timestamp ?? scheduler.now metricsUpdated( minRTT: .zero, smoothedRTT: .zero, @@ -800,8 +804,9 @@ final class QLog { oldState: QLogCongestionState?, newState: QLogCongestionState?, trigger: QLogCongestionTrigger?, - timestamp: NetworkClock.Instant = .now + timestamp: NetworkClock.Instant? = nil ) { + let timestamp = timestamp ?? scheduler.now let congestionEvent = EventCongestionStateUpdated( oldCongestionState: oldState, newCongestionState: newState, diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 0969cf36..8dbb2d1e 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -263,7 +263,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } else if let currentSendTimestamp { return currentSendTimestamp } else { - return NetworkClock.Instant.now + return context.scheduler.now } } @@ -592,7 +592,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } // Only setup qlog if the directory is set if let qlogConfiguration { - self.qLog = QLog(configuration: qlogConfiguration) + self.qLog = QLog(configuration: qlogConfiguration, scheduler: context.scheduler) log.info("qlog setup with configuration: \(qlogConfiguration)") } #endif @@ -1563,7 +1563,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, let inboundInterval = QUICSignpost.inboundStarting(id: signpostID) // Save a timestamp to avoid calculating `now` again during processing - currentInboundReceiveTimestamp = .now + currentInboundReceiveTimestamp = context.scheduler.now // Start anew with pendingItems for applicationPendingItems // Detect if any received packet contains a QUIC Frame that unblocks @@ -2412,7 +2412,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, let outboundInterval = QUICSignpost.outboundStarting(id: signpostID) // Save a timestamp to avoid calculating `now` again during processing - currentSendTimestamp = .now + currentSendTimestamp = context.scheduler.now defer { // Always reset QUICSignpost.outboundStopping(outboundInterval) @@ -3327,7 +3327,9 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, // Deliberately the live clock rather than `self.now`: under a batch `self.now` is // pinned, and `startSendingTimestamp` came from it, so comparing the two would // always give zero and the cap could never be reached. - if startSendingTimestamp.duration(to: .now) >= Constants.maxPacketBurstDuration { + if startSendingTimestamp.duration(to: context.scheduler.now) + >= Constants.maxPacketBurstDuration + { // The maximum burst time has been reached shouldEndBurst = true } else { diff --git a/Sources/SwiftNetwork/Utilities/NetworkClock.swift b/Sources/SwiftNetwork/Utilities/NetworkClock.swift index c55c3aba..f9225c2f 100644 --- a/Sources/SwiftNetwork/Utilities/NetworkClock.swift +++ b/Sources/SwiftNetwork/Utilities/NetworkClock.swift @@ -345,7 +345,12 @@ public struct NetworkClock: Clock { self.time = time } - public static var now: Instant { + /// The system continuous clock, which no test can control. + /// + /// Reach for the scheduler's `now` instead, or the instant a caller already holds. This is + /// the raw reader that a scheduler exposes, and `check-mockable-clock.sh` fails the build + /// on any use of it outside that allowlist. + package static var systemNow: Instant { #if NETWORK_INTERNAL_TESTS let manual = manualTime.continuous if _slowPath(manual != .zero) { @@ -355,7 +360,8 @@ public struct NetworkClock: Clock { return Instant(microseconds: Int64(System.Time.now())) } - public static var nowAbsolute: Instant { + /// The system absolute clock. See `systemNow`. + package static var systemNowAbsolute: Instant { #if NETWORK_INTERNAL_TESTS let manual = manualTime.absolute if _slowPath(manual != .zero) { @@ -382,7 +388,7 @@ public struct NetworkClock: Clock { } public var now: Instant { - Instant.now + Instant.systemNow } public var minimumResolution: NetworkDuration { diff --git a/Sources/Tools/QUICStreamLoad/main.swift b/Sources/Tools/QUICStreamLoad/main.swift index 2006fc1c..d59b78ec 100644 --- a/Sources/Tools/QUICStreamLoad/main.swift +++ b/Sources/Tools/QUICStreamLoad/main.swift @@ -66,7 +66,7 @@ final class QUICStreamLoad { print( "Running QUIC stream load of \(streamCount) streams (\(concurrentStreams) at a time), with \(uploadSize) upload bytes and \(downloadSize) download bytes" ) - let startTime = NetworkClock.Instant.now + let startTime = NetworkClock.Instant.systemNow var handshakeDuration = NetworkDuration.zero var streamRoundTripDurations = [NetworkDuration]() @@ -89,7 +89,7 @@ final class QUICStreamLoad { context.activate() context.async { - let handshakeStart = NetworkClock.Instant.now + let handshakeStart = NetworkClock.Instant.systemNow // Client let clientIP = IPProtocol.instance(context: clientParameters.context) @@ -247,7 +247,7 @@ final class QUICStreamLoad { return } serverInput.start { connected in - handshakeDuration = handshakeStart.duration(to: .now) + handshakeDuration = handshakeStart.duration(to: .systemNow) group.leave() } clientInput.start() @@ -270,7 +270,7 @@ final class QUICStreamLoad { index += 1 - let streamStart = NetworkClock.Instant.now + let streamStart = NetworkClock.Instant.systemNow let myIndex = index let clientStream = StreamUpperHarness( @@ -344,7 +344,7 @@ final class QUICStreamLoad { if !clientPayloadReceived { clientStream.waitForInboundDataAvailable(completion: clientReadCompletion!) } else { - streamRoundTripDurations.append(streamStart.duration(to: .now)) + streamRoundTripDurations.append(streamStart.duration(to: .systemNow)) clientReadCompletion = nil group.leave() @@ -409,7 +409,7 @@ final class QUICStreamLoad { let meanStreamRTT = streamRoundTripDurations.reduce(NetworkDuration.zero, +) / streamRoundTripDurations.count print("Stream round trip: min = \(minStreamRTT), max = \(maxStreamRTT), mean = \(meanStreamRTT)") - let totalTime = startTime.duration(to: .now) + let totalTime = startTime.duration(to: .systemNow) let rate = (Int64(streamCount) * 1_000_000_000) / totalTime.nanoseconds print("Rate: \(rate) streams/s") diff --git a/Tests/QUICTests/CubicTests.swift b/Tests/QUICTests/CubicTests.swift index b516efc7..6ed0ea23 100644 --- a/Tests/QUICTests/CubicTests.swift +++ b/Tests/QUICTests/CubicTests.swift @@ -455,7 +455,7 @@ final class CubicTests: XCTestCase { XCTAssertEqual(path.pacer.burstSize, 10000) XCTAssertEqual(path.congestionControlWindow, 12000) - let time = NetworkClock.Instant.now + let time = NetworkClock.Instant.systemNow for _ in 0..<10 { path.congestionControlPacketsSent(bytesSent: 1000) } @@ -475,7 +475,7 @@ final class CubicTests: XCTestCase { sendTimeAbsolute: &sendTimeAbsolute, sendTimeContinuous: &sendTimeContinuous ) - let currentTime = NetworkClock.Instant.now + let currentTime = NetworkClock.Instant.systemNow // First packet should be sent out almost immediately XCTAssertTrue( sendTimeAbsolute @@ -512,7 +512,7 @@ final class CubicTests: XCTestCase { // double-check that the rounding happens as expected XCTAssertEqual(rtt.smoothedRTT.microseconds, 0, "smoothedRTT is expected to round to zero microseconds") - let time = NetworkClock.Instant.now + let time = NetworkClock.Instant.systemNow path.congestionControlPacketsSent(bytesSent: 1000) path.congestionControlAckBegin() path.congestionControlPacketsAcked(bytesAcked: 1000, sentTime: time) diff --git a/Tests/QUICTests/PacerTests.swift b/Tests/QUICTests/PacerTests.swift index f1d0b6a0..ead4e26b 100644 --- a/Tests/QUICTests/PacerTests.swift +++ b/Tests/QUICTests/PacerTests.swift @@ -41,7 +41,7 @@ final class PacerTests: XCTestCase { var sendTimeAbsolute = NetworkClock.Instant(nanoseconds: 0) var sendTimeContinuous = NetworkClock.Instant(nanoseconds: 0) - let timeBefore = NetworkClock.Instant.nowAbsolute + let timeBefore = NetworkClock.Instant.systemNowAbsolute pacer.getSendTime( path: path, packetLength: packetLength, @@ -49,7 +49,7 @@ final class PacerTests: XCTestCase { sendTimeContinuous: &sendTimeContinuous ) - let timeAfter = NetworkClock.Instant.nowAbsolute + let timeAfter = NetworkClock.Instant.systemNowAbsolute XCTAssertGreaterThanOrEqual( pacer.packetSentTime, @@ -127,7 +127,7 @@ final class PacerTests: XCTestCase { sendTimeAbsolute: &sendTimeAbsolute, sendTimeContinuous: &sendTimeContinuous ) - let currentTime = NetworkClock.Instant.now + let currentTime = NetworkClock.Instant.systemNow // First packet should be sent out almost immediately XCTAssertTrue( sendTimeAbsolute <= (currentTime + NetworkClock.Instant(nanoseconds: 1_000_000).time) diff --git a/Tests/QUICTests/QUICPathValidationMessageTests.swift b/Tests/QUICTests/QUICPathValidationMessageTests.swift index 8d5d2a2b..2f47ce44 100644 --- a/Tests/QUICTests/QUICPathValidationMessageTests.swift +++ b/Tests/QUICTests/QUICPathValidationMessageTests.swift @@ -58,7 +58,7 @@ class QUICPathValidationMessageTests: XCTestCase { func testOutgoingPathChallenge() { XCTAssertEqual(path.state, .cidAssigned) - let startTime = NetworkClock.Instant.now + let startTime = NetworkClock.Instant.systemNow var pendingItems = PendingItems(packetNumberSpace: .applicationData) path.addPendingItems(&pendingItems, now: startTime) // should be empty XCTAssertTrue(pendingItems.pathChallenges.isEmpty) diff --git a/Tests/QUICTests/RecoveryTests.swift b/Tests/QUICTests/RecoveryTests.swift index 2d7ef64c..4574a164 100644 --- a/Tests/QUICTests/RecoveryTests.swift +++ b/Tests/QUICTests/RecoveryTests.swift @@ -573,7 +573,7 @@ final class RecoveryTests: XCTestCase { packet.transmittedItems.ping = true let sentPath = connection.currentPath?.identifier ?? .none packet.sentPath = sentPath - var timeNow = NetworkClock.Instant.now + var timeNow = NetworkClock.Instant.systemNow sentPacket(packet, connection: connection) XCTAssertEqual( connection.recovery.getLargestSentPN(packetNumberSpace: .initial), diff --git a/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift index 8bee5e6f..f9bffe9a 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift @@ -271,8 +271,8 @@ final class SwiftNetworkClockTests: NetTestCase { } func testInstantNowAbsoluteIsMonotonic() throws { - let first = NetworkClock.Instant.nowAbsolute - let second = NetworkClock.Instant.nowAbsolute + let first = NetworkClock.Instant.systemNowAbsolute + let second = NetworkClock.Instant.systemNowAbsolute XCTAssertNotEqual(first.time, .zero) XCTAssertTrue(second >= first) } @@ -337,7 +337,7 @@ private struct ManualClockUnavailable: LocalizedError, CustomStringConvertible { } #endif -/// Tests for the manually advanced clock behind `NetworkClock.Instant.now`. +/// Tests for the manually advanced clock behind `NetworkClock.Instant.systemNow`. @available(Network 0.1.0, *) final class SwiftNetworkManualClockTests: NetTestCase { private let base = NetworkClock.Instant(milliseconds: 1000) @@ -353,49 +353,49 @@ final class SwiftNetworkManualClockTests: NetTestCase { } func testSystemClockIsUsedByDefault() { - let first = NetworkClock.Instant.now + let first = NetworkClock.Instant.systemNow XCTAssertNotEqual(first, .zero) usleep(1) - let second = NetworkClock.Instant.now + let second = NetworkClock.Instant.systemNow XCTAssertGreaterThan(second, first) } func testUseManualTimeFreezesTheClock() { NetworkClock.Instant.useManualTime(base) - XCTAssertEqual(NetworkClock.Instant.now, base) + XCTAssertEqual(NetworkClock.Instant.systemNow, base) // Reading repeatedly must yield the same instant: time no longer moves // on its own, which is the entire point of the manual clock. usleep(1) - XCTAssertEqual(NetworkClock.Instant.now, base) - XCTAssertEqual(NetworkClock.Instant.now, NetworkClock.Instant.now) + XCTAssertEqual(NetworkClock.Instant.systemNow, base) + XCTAssertEqual(NetworkClock.Instant.systemNow, NetworkClock.Instant.systemNow) } func testUseManualTimeDefaultsAbsoluteToContinuous() { NetworkClock.Instant.useManualTime(base) - XCTAssertEqual(NetworkClock.Instant.nowAbsolute, base) + XCTAssertEqual(NetworkClock.Instant.systemNowAbsolute, base) } func testUseManualTimeKeepsContinuousAndAbsoluteSeparate() { let absolute = NetworkClock.Instant(milliseconds: 5000) NetworkClock.Instant.useManualTime(base, absolute: absolute) - XCTAssertEqual(NetworkClock.Instant.now, base) - XCTAssertEqual(NetworkClock.Instant.nowAbsolute, absolute) + XCTAssertEqual(NetworkClock.Instant.systemNow, base) + XCTAssertEqual(NetworkClock.Instant.systemNowAbsolute, absolute) } func testUseManualTimeOverwritesAPreviousManualTime() { NetworkClock.Instant.useManualTime(base, absolute: NetworkClock.Instant(milliseconds: 5000)) let later = NetworkClock.Instant(milliseconds: 2000) NetworkClock.Instant.useManualTime(later) - XCTAssertEqual(NetworkClock.Instant.now, later) - XCTAssertEqual(NetworkClock.Instant.nowAbsolute, later) + XCTAssertEqual(NetworkClock.Instant.systemNow, later) + XCTAssertEqual(NetworkClock.Instant.systemNowAbsolute, later) } func testAdvanceManualTimeMovesBothClocks() { let absolute = NetworkClock.Instant(milliseconds: 5000) NetworkClock.Instant.useManualTime(base, absolute: absolute) NetworkClock.Instant.advanceManualTime(by: .milliseconds(250)) - XCTAssertEqual(NetworkClock.Instant.now, base.advanced(by: .milliseconds(250))) - XCTAssertEqual(NetworkClock.Instant.nowAbsolute, absolute.advanced(by: .milliseconds(250))) + XCTAssertEqual(NetworkClock.Instant.systemNow, base.advanced(by: .milliseconds(250))) + XCTAssertEqual(NetworkClock.Instant.systemNowAbsolute, absolute.advanced(by: .milliseconds(250))) } func testAdvanceManualTimeAccumulates() { @@ -403,13 +403,13 @@ final class SwiftNetworkManualClockTests: NetTestCase { for _ in 0..<3 { NetworkClock.Instant.advanceManualTime(by: .milliseconds(100)) } - XCTAssertEqual(NetworkClock.Instant.now, base.advanced(by: .milliseconds(300))) + XCTAssertEqual(NetworkClock.Instant.systemNow, base.advanced(by: .milliseconds(300))) } func testAdvanceManualTimeByZeroLeavesTheClockAlone() { NetworkClock.Instant.useManualTime(base) NetworkClock.Instant.advanceManualTime(by: .zero) - XCTAssertEqual(NetworkClock.Instant.now, base) + XCTAssertEqual(NetworkClock.Instant.systemNow, base) } func testAdvanceManualTimeKeepsNanosecondResolution() { @@ -417,32 +417,32 @@ final class SwiftNetworkManualClockTests: NetTestCase { // only observable on the manual clock. NetworkClock.Instant.useManualTime(NetworkClock.Instant(nanoseconds: 1)) NetworkClock.Instant.advanceManualTime(by: .nanoseconds(1)) - XCTAssertEqual(NetworkClock.Instant.now.time, .nanoseconds(2)) + XCTAssertEqual(NetworkClock.Instant.systemNow.time, .nanoseconds(2)) } func testDurationIsMeasuredAcrossManualAdvances() { NetworkClock.Instant.useManualTime(base) - let start = NetworkClock.Instant.now + let start = NetworkClock.Instant.systemNow NetworkClock.Instant.advanceManualTime(by: .milliseconds(5)) // The reason the manual clock exists: an exact, reproducible elapsed // time with no dependency on how long the test itself took to run. - XCTAssertEqual(start.duration(to: NetworkClock.Instant.now), .milliseconds(5)) + XCTAssertEqual(start.duration(to: NetworkClock.Instant.systemNow), .milliseconds(5)) } func testUseSystemTimeRestoresTheSystemClock() { NetworkClock.Instant.useManualTime(base) - XCTAssertEqual(NetworkClock.Instant.now, base) + XCTAssertEqual(NetworkClock.Instant.systemNow, base) NetworkClock.Instant.useSystemTime() // `System.Time.now()` reports microseconds since boot, so the restored // clock cannot still read the 1 s manual value, and it must keep moving. - let restored = NetworkClock.Instant.now + let restored = NetworkClock.Instant.systemNow XCTAssertNotEqual(restored, base) usleep(1) - let next = NetworkClock.Instant.now + let next = NetworkClock.Instant.systemNow XCTAssertGreaterThan(next, restored) } diff --git a/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift index 7e53a94f..4af7697c 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift @@ -118,6 +118,11 @@ final class SwiftNetworkContextTests: NetTestCase { } var runningInScheduler: Bool { true } + + /// Fixed, because this scheduler never runs anything: a test asserting on the delay it was + /// handed does not depend on what time the scheduler thinks it is. + var now: NetworkClock.Instant { NetworkClock.Instant(milliseconds: 1000) } + var nowAbsolute: NetworkClock.Instant { now } } func testContextTimerReferences() { diff --git a/Tests/SwiftNetworkTests/SwiftNetworkQUICIdleTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkQUICIdleTests.swift index dcf27af2..86cab0d0 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkQUICIdleTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkQUICIdleTests.swift @@ -85,7 +85,7 @@ final class SwiftNetworkQUICIdleTests: NetTestCase { ) // Once the delayed ACK has been sent there are no obligations left. - client.ack.timerFired(at: .now) + client.ack.timerFired(at: .systemNow) XCTAssertEqual( client.ack.unackedPacketCount, 0, From 493aeb9f3c48a9f8426f90a438be3e4237e1bf22 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 4 Sep 2026 20:55:59 -0400 Subject: [PATCH 2/6] Stamp both clocks together for a batch `Pacer.getSendTime` runs once per paced packet and read both clocks on every call, converting between the domains by subtracting one reading from the other. Two reads per packet, and a difference that picks up the gap between them as though it were domain offset. * Added `currentAbsoluteTimestamp` beside the existing batch timestamp, stamped and cleared with it at both the inbound and outbound boundaries. * Added `QUICConnection.nowAbsolute`, falling back to the scheduler when no batch is in flight. * Pointed `Pacer` at the pair, so inside a batch it reads no clock at all. The offset error was the read gap, well under the pacing interval, so this is a hot-path saving rather than a fix. `Pacer` already went through the scheduler, so it was not blocking clock injection. --- Sources/SwiftNetwork/QUIC/Pacer.swift | 4 ++-- Sources/SwiftNetwork/QUIC/QUICConnection.swift | 16 ++++++++++++++++ 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/Pacer.swift b/Sources/SwiftNetwork/QUIC/Pacer.swift index bca1962d..ebd771aa 100644 --- a/Sources/SwiftNetwork/QUIC/Pacer.swift +++ b/Sources/SwiftNetwork/QUIC/Pacer.swift @@ -60,8 +60,8 @@ struct Pacer: ~Copyable { guard let path else { return } - let continuousTime = path.parentProtocol.context.scheduler.now - let absoluteTime = path.parentProtocol.context.scheduler.nowAbsolute + let continuousTime = path.parentProtocol.now + let absoluteTime = path.parentProtocol.nowAbsolute if packetSentTime == .zero { packetSentTime = absoluteTime diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index 8dbb2d1e..fa979257 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -254,6 +254,12 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, var currentInboundReceiveTimestamp: NetworkClock.Instant? var currentSendTimestamp: NetworkClock.Instant? + /// The absolute-clock reading taken alongside whichever timestamp above is set. + /// + /// `Pacer` converts between the two clock domains by subtracting one reading from the other, so + /// the pair must be sampled together; sampled a moment apart, the gap between the reads folds + /// into the offset. Stamping it here also keeps `getSendTime` from reading either clock. + var currentAbsoluteTimestamp: NetworkClock.Instant? @_optimize(speed) @inline(always) @@ -267,6 +273,12 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } } + @_optimize(speed) + @inline(always) + var nowAbsolute: NetworkClock.Instant { + currentAbsoluteTimestamp ?? context.scheduler.nowAbsolute + } + var lastPacketReceivedTimestamp: NetworkClock.Instant = .zero var lastAckElicitingPacketSentTimestamp: NetworkClock.Instant = .zero var lastShorthandTimestamp: NetworkClock.Instant = .zero @@ -1564,6 +1576,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, // Save a timestamp to avoid calculating `now` again during processing currentInboundReceiveTimestamp = context.scheduler.now + currentAbsoluteTimestamp = context.scheduler.nowAbsolute // Start anew with pendingItems for applicationPendingItems // Detect if any received packet contains a QUIC Frame that unblocks @@ -1577,6 +1590,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, defer { recovery.endBatch(connection: self) currentInboundReceiveTimestamp = nil + currentAbsoluteTimestamp = nil } if !pendingReassemblyDequeue.isEmpty { @@ -2413,10 +2427,12 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, // Save a timestamp to avoid calculating `now` again during processing currentSendTimestamp = context.scheduler.now + currentAbsoluteTimestamp = context.scheduler.nowAbsolute defer { // Always reset QUICSignpost.outboundStopping(outboundInterval) currentSendTimestamp = nil + currentAbsoluteTimestamp = nil } accessStreamDataToSend(flow: flowID) { streamData in From 44f410ffb88fcfcac8eb55e6eff8ed4bc1027479 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Fri, 4 Sep 2026 20:59:48 -0400 Subject: [PATCH 3/6] Remove the process-global manual clock `NetworkClock.Instant` carried its own manual clock behind `NETWORK_INTERNAL_TESTS`: a static box that `useManualTime` froze and `advanceManualTime` moved, checked on every read of the clock. Being process-global, it could not be scoped to one connection or one test, and the two readers paid for a branch on a hot path to consult it. The scheduler now provides the same control per context, so this is a replacement rather than a removal. * Removed `useSystemTime`, `useManualTime`, `advanceManualTime` and the backing store, and with them the branch each clock read carried. * Removed `SwiftNetworkManualClockTests`, the only caller. The 30 tests in `SwiftNetworkClockTests` alongside it cover `NetworkDuration` and `Instant` arithmetic and are untouched. * Dropped `-DNETWORK_INTERNAL_TESTS` from both CI scripts and the README, since nothing is behind it any more. --- .github/workflows/scripts/ci-linux.sh | 4 +- .github/workflows/scripts/ci-macos.sh | 2 +- Package.swift | 7 - README.md | 7 - .../SwiftNetwork/Utilities/NetworkClock.swift | 61 --------- .../SwiftNetworkClockTests.swift | 120 ------------------ 6 files changed, 3 insertions(+), 198 deletions(-) diff --git a/.github/workflows/scripts/ci-linux.sh b/.github/workflows/scripts/ci-linux.sh index 6fb476cc..e2381bbf 100644 --- a/.github/workflows/scripts/ci-linux.sh +++ b/.github/workflows/scripts/ci-linux.sh @@ -35,8 +35,8 @@ ci_run 'Build (reductive traits on)' \ swift build --build-tests --quiet \ --traits DisableDebugLogging,DisableErrorLogging "$@" ci_run 'Test (debug)' \ - swift test --quiet -Xswiftc -DNETWORK_INTERNAL_TESTS "$@" + swift test --quiet "$@" ci_run 'Test (release)' \ - swift test --quiet -c release -Xswiftc -DNETWORK_INTERNAL_TESTS "$@" + swift test --quiet -c release "$@" ci_finish diff --git a/.github/workflows/scripts/ci-macos.sh b/.github/workflows/scripts/ci-macos.sh index 8af8d8e6..85ecbc4b 100644 --- a/.github/workflows/scripts/ci-macos.sh +++ b/.github/workflows/scripts/ci-macos.sh @@ -34,6 +34,6 @@ xcodebuild_step 'watchOS build' 'generic/platform=watchos' xcodebuild_step 'tvOS build' 'generic/platform=tvos' xcodebuild_step 'visionOS build' 'generic/platform=visionos' -ci_run 'swift test' xcrun swift test --quiet -Xswiftc -DNETWORK_INTERNAL_TESTS "$@" +ci_run 'swift test' xcrun swift test --quiet "$@" ci_finish diff --git a/Package.swift b/Package.swift index 083711ed..357ff54f 100644 --- a/Package.swift +++ b/Package.swift @@ -40,13 +40,6 @@ let allApplePlatforms: [Platform] = [ // Logging levels, qlog output, and QUIC signposts are configured via package // traits. See the `traits:` list on the `Package(...)` initializer below. -// -// Test-only hooks in the library, are guarded by `NETWORK_INTERNAL_TESTS`. -// Pass it on the command line instead: -// -// swift test -Xswiftc -DNETWORK_INTERNAL_TESTS -// -// Tests that depend on those hooks skip themselves when it is absent. let settings: [SwiftSetting] = [ .define("IMPORT_SWIFTTLS"), .define("EXPORT_SWIFTTLS"), diff --git a/README.md b/README.md index 3b854cb4..bc10ce57 100644 --- a/README.md +++ b/README.md @@ -37,13 +37,6 @@ Unit tests can also be run by filtering a specific class or function: % swift test --filter SwiftNetworkUDPTests.testUDPEcho ``` -Some tests depend on hooks that are compiled into the library only on demand, because they cost a small amount of performance on hot paths. -Pass the define on the command line to compile them in: - -``` -% swift test -Xswiftc -DNETWORK_INTERNAL_TESTS -``` - All unit tests are run automatically upon creation or update of a Pull Request. See [CONTRIBUTING](https://github.com/apple/swift-network-evolution/blob/main/CONTRIBUTING.md) for details. ### Versioning diff --git a/Sources/SwiftNetwork/Utilities/NetworkClock.swift b/Sources/SwiftNetwork/Utilities/NetworkClock.swift index f9225c2f..156927e7 100644 --- a/Sources/SwiftNetwork/Utilities/NetworkClock.swift +++ b/Sources/SwiftNetwork/Utilities/NetworkClock.swift @@ -256,55 +256,6 @@ public struct NetworkClock: Clock { public struct Instant: InstantProtocol, CustomStringConvertible { var time: NetworkDuration - #if NETWORK_INTERNAL_TESTS - // Backing storage for the manual clock used by tests. - // - // This is a `static let` box rather than a `static var` on purpose. - // Reading a mutable static emits a `swift_beginAccess` call for the - // dynamic exclusivity check. A `let` does not. - private final class ManualTime: @unchecked Sendable { - var continuous: Instant = .zero - var absolute: Instant = .zero - } - private static let manualTime = ManualTime() - #endif - - internal static func useSystemTime() { - #if NETWORK_INTERNAL_TESTS - manualTime.continuous = .zero - manualTime.absolute = .zero - #endif - } - - internal static func useManualTime( - _ continuous: Instant, - absolute: Instant? = nil - ) { - #if NETWORK_INTERNAL_TESTS - let absolute = absolute ?? continuous - precondition(continuous > .zero, "manual time must be greater than zero") - precondition(absolute > .zero, "manual time must be greater than zero") - manualTime.continuous = continuous - manualTime.absolute = absolute - #else - fatalError("The manual clock requires building with -DNETWORK_INTERNAL_TESTS") - #endif - } - - internal static func advanceManualTime(by duration: NetworkDuration) { - #if NETWORK_INTERNAL_TESTS - precondition(duration >= .zero, "manual time must not go backwards") - precondition( - manualTime.continuous > .zero, - "advanceManualTime(by:) requires useManualTime() first" - ) - manualTime.continuous = manualTime.continuous.advanced(by: duration) - manualTime.absolute = manualTime.absolute.advanced(by: duration) - #else - fatalError("The manual clock requires building with -DNETWORK_INTERNAL_TESTS") - #endif - } - public func advanced(by duration: NetworkDuration) -> Self { NetworkClock.Instant(self.time + duration) } @@ -351,23 +302,11 @@ public struct NetworkClock: Clock { /// the raw reader that a scheduler exposes, and `check-mockable-clock.sh` fails the build /// on any use of it outside that allowlist. package static var systemNow: Instant { - #if NETWORK_INTERNAL_TESTS - let manual = manualTime.continuous - if _slowPath(manual != .zero) { - return manual - } - #endif return Instant(microseconds: Int64(System.Time.now())) } /// The system absolute clock. See `systemNow`. package static var systemNowAbsolute: Instant { - #if NETWORK_INTERNAL_TESTS - let manual = manualTime.absolute - if _slowPath(manual != .zero) { - return manual - } - #endif return Instant(nanoseconds: Int64(System.Time.nowAbsoluteNanoseconds())) } diff --git a/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift index f9bffe9a..2dd9569f 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift @@ -327,123 +327,3 @@ final class SwiftNetworkClockTests: NetTestCase { } } } - -#if !NETWORK_INTERNAL_TESTS -private struct ManualClockUnavailable: LocalizedError, CustomStringConvertible { - var description: String { - "the manual clock is not compiled in; build with -Xswiftc -DNETWORK_INTERNAL_TESTS" - } - var errorDescription: String? { self.description } -} -#endif - -/// Tests for the manually advanced clock behind `NetworkClock.Instant.systemNow`. -@available(Network 0.1.0, *) -final class SwiftNetworkManualClockTests: NetTestCase { - private let base = NetworkClock.Instant(milliseconds: 1000) - - override func setUpWithError() throws { - #if !NETWORK_INTERNAL_TESTS - throw ManualClockUnavailable() - #endif - } - - override func tearDown() { - NetworkClock.Instant.useSystemTime() - } - - func testSystemClockIsUsedByDefault() { - let first = NetworkClock.Instant.systemNow - XCTAssertNotEqual(first, .zero) - usleep(1) - let second = NetworkClock.Instant.systemNow - XCTAssertGreaterThan(second, first) - } - - func testUseManualTimeFreezesTheClock() { - NetworkClock.Instant.useManualTime(base) - XCTAssertEqual(NetworkClock.Instant.systemNow, base) - // Reading repeatedly must yield the same instant: time no longer moves - // on its own, which is the entire point of the manual clock. - usleep(1) - XCTAssertEqual(NetworkClock.Instant.systemNow, base) - XCTAssertEqual(NetworkClock.Instant.systemNow, NetworkClock.Instant.systemNow) - } - - func testUseManualTimeDefaultsAbsoluteToContinuous() { - NetworkClock.Instant.useManualTime(base) - XCTAssertEqual(NetworkClock.Instant.systemNowAbsolute, base) - } - - func testUseManualTimeKeepsContinuousAndAbsoluteSeparate() { - let absolute = NetworkClock.Instant(milliseconds: 5000) - NetworkClock.Instant.useManualTime(base, absolute: absolute) - XCTAssertEqual(NetworkClock.Instant.systemNow, base) - XCTAssertEqual(NetworkClock.Instant.systemNowAbsolute, absolute) - } - - func testUseManualTimeOverwritesAPreviousManualTime() { - NetworkClock.Instant.useManualTime(base, absolute: NetworkClock.Instant(milliseconds: 5000)) - let later = NetworkClock.Instant(milliseconds: 2000) - NetworkClock.Instant.useManualTime(later) - XCTAssertEqual(NetworkClock.Instant.systemNow, later) - XCTAssertEqual(NetworkClock.Instant.systemNowAbsolute, later) - } - - func testAdvanceManualTimeMovesBothClocks() { - let absolute = NetworkClock.Instant(milliseconds: 5000) - NetworkClock.Instant.useManualTime(base, absolute: absolute) - NetworkClock.Instant.advanceManualTime(by: .milliseconds(250)) - XCTAssertEqual(NetworkClock.Instant.systemNow, base.advanced(by: .milliseconds(250))) - XCTAssertEqual(NetworkClock.Instant.systemNowAbsolute, absolute.advanced(by: .milliseconds(250))) - } - - func testAdvanceManualTimeAccumulates() { - NetworkClock.Instant.useManualTime(base) - for _ in 0..<3 { - NetworkClock.Instant.advanceManualTime(by: .milliseconds(100)) - } - XCTAssertEqual(NetworkClock.Instant.systemNow, base.advanced(by: .milliseconds(300))) - } - - func testAdvanceManualTimeByZeroLeavesTheClockAlone() { - NetworkClock.Instant.useManualTime(base) - NetworkClock.Instant.advanceManualTime(by: .zero) - XCTAssertEqual(NetworkClock.Instant.systemNow, base) - } - - func testAdvanceManualTimeKeepsNanosecondResolution() { - // `System.Time.now()` truncates to microseconds, so nanosecond steps are - // only observable on the manual clock. - NetworkClock.Instant.useManualTime(NetworkClock.Instant(nanoseconds: 1)) - NetworkClock.Instant.advanceManualTime(by: .nanoseconds(1)) - XCTAssertEqual(NetworkClock.Instant.systemNow.time, .nanoseconds(2)) - } - - func testDurationIsMeasuredAcrossManualAdvances() { - NetworkClock.Instant.useManualTime(base) - let start = NetworkClock.Instant.systemNow - - NetworkClock.Instant.advanceManualTime(by: .milliseconds(5)) - - // The reason the manual clock exists: an exact, reproducible elapsed - // time with no dependency on how long the test itself took to run. - XCTAssertEqual(start.duration(to: NetworkClock.Instant.systemNow), .milliseconds(5)) - } - - func testUseSystemTimeRestoresTheSystemClock() { - NetworkClock.Instant.useManualTime(base) - XCTAssertEqual(NetworkClock.Instant.systemNow, base) - - NetworkClock.Instant.useSystemTime() - - // `System.Time.now()` reports microseconds since boot, so the restored - // clock cannot still read the 1 s manual value, and it must keep moving. - let restored = NetworkClock.Instant.systemNow - XCTAssertNotEqual(restored, base) - usleep(1) - let next = NetworkClock.Instant.systemNow - XCTAssertGreaterThan(next, restored) - } - -} From f53e6ab80cd7b99d8e5756d4b855c0b5129c85cf Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Mon, 14 Sep 2026 14:58:00 -0400 Subject: [PATCH 4/6] formatting --- Sources/SwiftNetwork/Utilities/NetworkClock.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/SwiftNetwork/Utilities/NetworkClock.swift b/Sources/SwiftNetwork/Utilities/NetworkClock.swift index 156927e7..c92b0b78 100644 --- a/Sources/SwiftNetwork/Utilities/NetworkClock.swift +++ b/Sources/SwiftNetwork/Utilities/NetworkClock.swift @@ -302,12 +302,12 @@ public struct NetworkClock: Clock { /// the raw reader that a scheduler exposes, and `check-mockable-clock.sh` fails the build /// on any use of it outside that allowlist. package static var systemNow: Instant { - return Instant(microseconds: Int64(System.Time.now())) + Instant(microseconds: Int64(System.Time.now())) } /// The system absolute clock. See `systemNow`. package static var systemNowAbsolute: Instant { - return Instant(nanoseconds: Int64(System.Time.nowAbsoluteNanoseconds())) + Instant(nanoseconds: Int64(System.Time.nowAbsoluteNanoseconds())) } public static var zero: Instant { From a23c80fefac95561d8ddc94c65c7e9f035ae1de9 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Mon, 14 Sep 2026 15:57:31 -0400 Subject: [PATCH 5/6] Reach the clock through the context, not the scheduler * Added `NetworkContext.now` and `nowAbsolute` beside `resetTimer`, forwarding to the scheduler.. * Routed the eight clock reads in `QUICConnection` and `IPProtocol` through the context, which is also one member shorter at each site. * Changed `QLog` to hold the context rather than an `any NetworkContext.Scheduler`, of which that build has no instance to hand it. --- .../SwiftNetwork/Context/NetworkContext.swift | 13 +++++++++++++ Sources/SwiftNetwork/Protocols/IPProtocol.swift | 2 +- Sources/SwiftNetwork/QUIC/QLog.swift | 12 ++++++------ Sources/SwiftNetwork/QUIC/QUICConnection.swift | 16 ++++++++-------- Tests/QUICTests/QLogTests.swift | 4 ++-- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/Sources/SwiftNetwork/Context/NetworkContext.swift b/Sources/SwiftNetwork/Context/NetworkContext.swift index e09963a9..3815a8bc 100644 --- a/Sources/SwiftNetwork/Context/NetworkContext.swift +++ b/Sources/SwiftNetwork/Context/NetworkContext.swift @@ -459,6 +459,19 @@ extension NetworkContext { } #if !NETWORK_PRIVATE || NETWORK_STANDALONE + /// The context's current notion of the continuous clock. + /// + /// Time comes from whatever runs the context's timers, so a scheduler that holds scheduled + /// tasks instead of arming an OS timer reports the time it fires them at. + var now: NetworkClock.Instant { + scheduler.now + } + + /// The context's current notion of the absolute clock. + var nowAbsolute: NetworkClock.Instant { + scheduler.nowAbsolute + } + func resetTimer(for reference: TimerReference, to time: FutureTime) { switch time { case .unschedule: diff --git a/Sources/SwiftNetwork/Protocols/IPProtocol.swift b/Sources/SwiftNetwork/Protocols/IPProtocol.swift index 7fdd08f6..269c02a5 100644 --- a/Sources/SwiftNetwork/Protocols/IPProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/IPProtocol.swift @@ -2044,7 +2044,7 @@ public struct IPProtocol: NetworkProtocol { &self.instanceType, log: self.log, frames: &inboundFrames, - now: self.context.scheduler.now + now: self.context.now ) guard !inboundFrames.isEmpty else { log.error("Dropped inbound packets, checking for more") diff --git a/Sources/SwiftNetwork/QUIC/QLog.swift b/Sources/SwiftNetwork/QUIC/QLog.swift index 4c2ca36e..3fe366b2 100644 --- a/Sources/SwiftNetwork/QUIC/QLog.swift +++ b/Sources/SwiftNetwork/QUIC/QLog.swift @@ -588,14 +588,14 @@ final class QLog { private var eventsList: [Event] private var topLevelObject: [String: Any] private var disableTimestamps: Bool = false - private let scheduler: any NetworkContext.Scheduler + private let context: NetworkContext private let startTime: NetworkClock.Instant var configuration: QLogConfiguration? - public init(configuration: QLogConfiguration? = nil, scheduler: any NetworkContext.Scheduler) { + public init(configuration: QLogConfiguration? = nil, context: NetworkContext) { self.configuration = configuration - self.scheduler = scheduler - self.startTime = scheduler.now + self.context = context + self.startTime = context.now self.eventsList = [] self.topLevelObject = [:] self.topLevelObject["qlog_version"] = "draft-01" @@ -739,7 +739,7 @@ final class QLog { packetsInFlight: UInt64 = UInt64.max, timestamp: NetworkClock.Instant? = nil ) { - let timestamp = timestamp ?? scheduler.now + let timestamp = timestamp ?? context.now metricsUpdated( minRTT: .zero, smoothedRTT: .zero, @@ -806,7 +806,7 @@ final class QLog { trigger: QLogCongestionTrigger?, timestamp: NetworkClock.Instant? = nil ) { - let timestamp = timestamp ?? scheduler.now + let timestamp = timestamp ?? context.now let congestionEvent = EventCongestionStateUpdated( oldCongestionState: oldState, newCongestionState: newState, diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index fa979257..f7de4ab0 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -269,14 +269,14 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } else if let currentSendTimestamp { return currentSendTimestamp } else { - return context.scheduler.now + return context.now } } @_optimize(speed) @inline(always) var nowAbsolute: NetworkClock.Instant { - currentAbsoluteTimestamp ?? context.scheduler.nowAbsolute + currentAbsoluteTimestamp ?? context.nowAbsolute } var lastPacketReceivedTimestamp: NetworkClock.Instant = .zero @@ -604,7 +604,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } // Only setup qlog if the directory is set if let qlogConfiguration { - self.qLog = QLog(configuration: qlogConfiguration, scheduler: context.scheduler) + self.qLog = QLog(configuration: qlogConfiguration, context: context) log.info("qlog setup with configuration: \(qlogConfiguration)") } #endif @@ -1575,8 +1575,8 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, let inboundInterval = QUICSignpost.inboundStarting(id: signpostID) // Save a timestamp to avoid calculating `now` again during processing - currentInboundReceiveTimestamp = context.scheduler.now - currentAbsoluteTimestamp = context.scheduler.nowAbsolute + currentInboundReceiveTimestamp = context.now + currentAbsoluteTimestamp = context.nowAbsolute // Start anew with pendingItems for applicationPendingItems // Detect if any received packet contains a QUIC Frame that unblocks @@ -2426,8 +2426,8 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, let outboundInterval = QUICSignpost.outboundStarting(id: signpostID) // Save a timestamp to avoid calculating `now` again during processing - currentSendTimestamp = context.scheduler.now - currentAbsoluteTimestamp = context.scheduler.nowAbsolute + currentSendTimestamp = context.now + currentAbsoluteTimestamp = context.nowAbsolute defer { // Always reset QUICSignpost.outboundStopping(outboundInterval) @@ -3343,7 +3343,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, // Deliberately the live clock rather than `self.now`: under a batch `self.now` is // pinned, and `startSendingTimestamp` came from it, so comparing the two would // always give zero and the cap could never be reached. - if startSendingTimestamp.duration(to: context.scheduler.now) + if startSendingTimestamp.duration(to: context.now) >= Constants.maxPacketBurstDuration { // The maximum burst time has been reached diff --git a/Tests/QUICTests/QLogTests.swift b/Tests/QUICTests/QLogTests.swift index ae24ce73..8236b51f 100644 --- a/Tests/QUICTests/QLogTests.swift +++ b/Tests/QUICTests/QLogTests.swift @@ -26,10 +26,10 @@ import XCTest let qlogTestsLogPrefixer = LogPrefixer("[QLogTests]") final class QLogTests: XCTestCase { - var qlog: QLog = QLog() + var qlog: QLog = QLog(context: NetworkContext(identifier: "QLogTests")) override func setUp() { - qlog = QLog() + qlog = QLog(context: NetworkContext(identifier: "QLogTests")) } func assertStringJSONContent( From d6430f3a5685ed628f66745894bc8955392163b3 Mon Sep 17 00:00:00 2001 From: Rick Newton-Rogers Date: Tue, 15 Sep 2026 15:47:23 -0400 Subject: [PATCH 6/6] add tests, tweak some wording --- .../SwiftNetwork/QUIC/QUICConnection.swift | 6 +- .../SwiftNetwork/Utilities/NetworkClock.swift | 17 ++-- .../SwiftNetworkContextTests.swift | 81 ++++++++++++++++++- 3 files changed, 90 insertions(+), 14 deletions(-) diff --git a/Sources/SwiftNetwork/QUIC/QUICConnection.swift b/Sources/SwiftNetwork/QUIC/QUICConnection.swift index f7de4ab0..3c8cd75c 100644 --- a/Sources/SwiftNetwork/QUIC/QUICConnection.swift +++ b/Sources/SwiftNetwork/QUIC/QUICConnection.swift @@ -1574,7 +1574,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, public func serviceReceivedDatagrams(path pathID: MultiplexingPathIdentifier) { let inboundInterval = QUICSignpost.inboundStarting(id: signpostID) - // Save a timestamp to avoid calculating `now` again during processing + // Save the timestamps to avoid calculating `now` again during processing currentInboundReceiveTimestamp = context.now currentAbsoluteTimestamp = context.nowAbsolute @@ -2425,7 +2425,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, let outboundInterval = QUICSignpost.outboundStarting(id: signpostID) - // Save a timestamp to avoid calculating `now` again during processing + // Save the timestamps to avoid calculating `now` again during processing currentSendTimestamp = context.now currentAbsoluteTimestamp = context.nowAbsolute defer { @@ -3340,7 +3340,7 @@ public final class QUICConnection: ManyToManyApplicationStreamProtocol, } else if packetBurst >= Constants.packetBurstCount { // The packet burst count has been reached, check the time // - // Deliberately the live clock rather than `self.now`: under a batch `self.now` is + // Deliberately the context's clock rather than `self.now`: under a batch `self.now` is // pinned, and `startSendingTimestamp` came from it, so comparing the two would // always give zero and the cap could never be reached. if startSendingTimestamp.duration(to: context.now) diff --git a/Sources/SwiftNetwork/Utilities/NetworkClock.swift b/Sources/SwiftNetwork/Utilities/NetworkClock.swift index c92b0b78..c05ed6e7 100644 --- a/Sources/SwiftNetwork/Utilities/NetworkClock.swift +++ b/Sources/SwiftNetwork/Utilities/NetworkClock.swift @@ -241,15 +241,14 @@ public struct NetworkDuration: DurationProtocol, Hashable, Equatable, CustomStri } } -/// A continuous clock with a compact representation that tests can advance manually. +/// A continuous clock with a compact representation. /// -/// Mimics `Swift.ContinuousClock`, with two differences: -/// 1. It uses `NetworkDuration` internally so its size is 8 bytes. -/// 2. Tests can replace the OS clock with one they advance by hand, -/// which makes time-dependent behaviour deterministic. +/// Mimics `Swift.ContinuousClock`, except that it uses `NetworkDuration` internally so its size is +/// 8 bytes. A test makes time deterministic by supplying a scheduler that reports instants of its +/// own, not by replacing this clock. #if !NETWORK_EMBEDDED @_spi(Essentials) -// Availability due to `SwiftNetwork`'s `System.Time` (used by `Instant.now`) +// Availability due to `SwiftNetwork`'s `System.Time` (used by `Instant.systemNow`) @available(Network 0.1.0, *) #endif public struct NetworkClock: Clock { @@ -298,9 +297,9 @@ public struct NetworkClock: Clock { /// The system continuous clock, which no test can control. /// - /// Reach for the scheduler's `now` instead, or the instant a caller already holds. This is - /// the raw reader that a scheduler exposes, and `check-mockable-clock.sh` fails the build - /// on any use of it outside that allowlist. + /// Reach for the context's `now` instead, or an instant the caller already holds. + /// `NetworkContext.DefaultScheduler` is the one reader of this property in the library, so a + /// test that supplies an external scheduler reports a time of its own. package static var systemNow: Instant { Instant(microseconds: Int64(System.Time.now())) } diff --git a/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift index 4af7697c..ae8d74b1 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift @@ -99,6 +99,83 @@ final class SwiftNetworkContextTests: NetTestCase { XCTAssertEqual(scheduler.unscheduledReferences, [timerReference]) } + /// The context must report the external scheduler's time, not the system's. + func testContextReportsTheExternalSchedulersTime() { + let scheduler = AdvancingScheduler() + let context = NetworkContext(identifier: "test", externalScheduler: scheduler) + + XCTAssertEqual(context.now, scheduler.now) + XCTAssertEqual(context.nowAbsolute, scheduler.nowAbsolute) + + let start = context.now + scheduler.advance(by: .milliseconds(250)) + + XCTAssertEqual(context.now, start.advanced(by: .milliseconds(250))) + } + + func testLongAdvanceDurationIsHonored() { + let scheduler = AdvancingScheduler() + let context = NetworkContext(identifier: "test", externalScheduler: scheduler) + + let start = context.now + scheduler.advance(by: .days(5)) + + XCTAssertEqual(start.duration(to: context.now), .days(5)) + } + + func testShortAdvanceIsExact() { + let scheduler = AdvancingScheduler() + let context = NetworkContext(identifier: "test", externalScheduler: scheduler) + + let start = context.now + // `System.Time.now()` divides down to microseconds, + // so a 500 ns advance is a duration it cannot represent at all + scheduler.advance(by: .nanoseconds(500)) + + XCTAssertEqual(start.duration(to: context.now), .nanoseconds(500)) + } + + func testBothClocksAdvanceTogether() { + let scheduler = AdvancingScheduler() + let context = NetworkContext(identifier: "test", externalScheduler: scheduler) + + let offsetBefore = context.now.duration(to: context.nowAbsolute) + scheduler.advance(by: .seconds(2)) + let offsetAfter = context.now.duration(to: context.nowAbsolute) + + XCTAssertEqual( + offsetAfter, + offsetBefore, + "the clocks drifted by \(offsetAfter.nanoseconds - offsetBefore.nanoseconds) ns" + ) + } + + /// A scheduler whose clock a test moves by hand. + /// **NOTE:** Arms nothing: the tests that use it assert on the time the context reports, not on anything firing. + private final class AdvancingScheduler: NetworkContext.Scheduler { + /// The two clocks start apart, so a context that reports one in place of the other fails + /// the equality check instead of matching by coincidence. + private(set) var now = NetworkClock.Instant(milliseconds: 1000) + private(set) var nowAbsolute = NetworkClock.Instant(milliseconds: 5000) + + func advance(by duration: NetworkDuration) { + now = now.advanced(by: duration) + nowAbsolute = nowAbsolute.advanced(by: duration) + } + + func runImmediate(_ task: @escaping (() -> Void)) { + task() + } + + func schedule(_ task: @escaping (() -> Void), after delay: NetworkDuration, reference: TimerReference) { + } + + func unschedule(reference: TimerReference) { + } + + var runningInScheduler: Bool { true } + } + /// Records what it was asked to schedule instead of arming anything, so a test can assert on /// the delay a caller asked for rather than on time passing. private final class RecordingScheduler: NetworkContext.Scheduler { @@ -119,8 +196,8 @@ final class SwiftNetworkContextTests: NetTestCase { var runningInScheduler: Bool { true } - /// Fixed, because this scheduler never runs anything: a test asserting on the delay it was - /// handed does not depend on what time the scheduler thinks it is. + /// A fixed instant. Nothing here fires on a deadline, so no assertion depends on the time + /// this scheduler reports. var now: NetworkClock.Instant { NetworkClock.Instant(milliseconds: 1000) } var nowAbsolute: NetworkClock.Instant { now } }