diff --git a/Sources/SwiftNetwork/Utilities/NetworkClock.swift b/Sources/SwiftNetwork/Utilities/NetworkClock.swift index 22ce470..039f924 100644 --- a/Sources/SwiftNetwork/Utilities/NetworkClock.swift +++ b/Sources/SwiftNetwork/Utilities/NetworkClock.swift @@ -210,11 +210,12 @@ public struct NetworkDuration: DurationProtocol, Hashable, Equatable, CustomStri } } -/// A continuous clock with a compact representation and configurable initial value. +/// A continuous clock with a compact representation that tests can advance manually. /// /// Mimics `Swift.ContinuousClock`, with two differences: /// 1. It uses `NetworkDuration` internally so its size is 8 bytes. -/// 2. You can create a clock with any value, which is useful for unit tests. +/// 2. Tests can replace the OS clock with one they advance by hand, +/// which makes time-dependent behaviour deterministic. #if !NETWORK_EMBEDDED @_spi(Essentials) // Availability due to `SwiftNetwork`'s `System.Time` (used by `Instant.now`) @@ -224,6 +225,55 @@ public struct NetworkClock: Clock { public struct Instant: InstantProtocol, CustomStringConvertible { var time: NetworkDuration + #if !DisableDebugLogging + // 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 !DisableDebugLogging + manualTime.continuous = .zero + manualTime.absolute = .zero + #endif + } + + internal static func useManualTime( + _ continuous: Instant, + absolute: Instant? = nil + ) { + #if !DisableDebugLogging + 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("Manual Clock not available") + #endif + } + + internal static func advanceManualTime(by duration: NetworkDuration) { + #if !DisableDebugLogging + 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("Manual Clock not available") + #endif + } + public func advanced(by duration: NetworkDuration) -> Self { NetworkClock.Instant(self.time + duration) } @@ -265,12 +315,23 @@ public struct NetworkClock: Clock { } public static var now: Instant { - // TODO: this should probably call ContinuousClock.now instead - Instant(microseconds: Int64(System.Time.now())) + #if !DisableDebugLogging + let manual = manualTime.continuous + if _slowPath(manual != .zero) { + return manual + } + #endif + return Instant(microseconds: Int64(System.Time.now())) } public static var nowAbsolute: Instant { - Instant(nanoseconds: Int64(System.Time.nowAbsoluteNanoseconds())) + #if !DisableDebugLogging + let manual = manualTime.absolute + if _slowPath(manual != .zero) { + return manual + } + #endif + return Instant(nanoseconds: Int64(System.Time.nowAbsoluteNanoseconds())) } public static var zero: Instant { diff --git a/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift index 683978b..a9f9cdd 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift @@ -159,3 +159,108 @@ final class SwiftNetworkClockTests: NetTestCase { } } + +/// Tests for the manually advanced clock behind `NetworkClock.Instant.now`. +@available(Network 0.1.0, *) +final class SwiftNetworkManualClockTests: NetTestCase { + private let base = NetworkClock.Instant(milliseconds: 1000) + + override func tearDown() { + NetworkClock.Instant.useSystemTime() + } + + func testSystemClockIsUsedByDefault() { + let first = NetworkClock.Instant.now + XCTAssertNotEqual(first, .zero) + usleep(1) + let second = NetworkClock.Instant.now + XCTAssertGreaterThan(second, first) + } + + func testUseManualTimeFreezesTheClock() { + NetworkClock.Instant.useManualTime(base) + XCTAssertEqual(NetworkClock.Instant.now, 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) + } + + func testUseManualTimeDefaultsAbsoluteToContinuous() { + NetworkClock.Instant.useManualTime(base) + XCTAssertEqual(NetworkClock.Instant.nowAbsolute, 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) + } + + 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) + } + + 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))) + } + + func testAdvanceManualTimeAccumulates() { + NetworkClock.Instant.useManualTime(base) + for _ in 0..<3 { + NetworkClock.Instant.advanceManualTime(by: .milliseconds(100)) + } + XCTAssertEqual(NetworkClock.Instant.now, base.advanced(by: .milliseconds(300))) + } + + func testAdvanceManualTimeByZeroLeavesTheClockAlone() { + NetworkClock.Instant.useManualTime(base) + NetworkClock.Instant.advanceManualTime(by: .zero) + XCTAssertEqual(NetworkClock.Instant.now, 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.now.time, .nanoseconds(2)) + } + + func testDurationIsMeasuredAcrossManualAdvances() { + NetworkClock.Instant.useManualTime(base) + let start = NetworkClock.Instant.now + + 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)) + } + + func testUseSystemTimeRestoresTheSystemClock() { + NetworkClock.Instant.useManualTime(base) + XCTAssertEqual(NetworkClock.Instant.now, 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 + XCTAssertNotEqual(restored, base) + usleep(1) + let next = NetworkClock.Instant.now + XCTAssertGreaterThan(next, restored) + } + +}