Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 66 additions & 5 deletions Sources/SwiftNetwork/Utilities/NetworkClock.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
Expand All @@ -224,6 +225,55 @@ public struct NetworkClock: Clock {
public struct Instant: InstantProtocol, CustomStringConvertible {
var time: NetworkDuration

#if !DisableDebugLogging

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems a bit odd to tie this to debug logging — I assume this is just intended to compile stuff out on prod / release builds?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah pretty much to avoid the memory cost of adding a class. The CPU cost of the extra check in now() isn't that bad.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we tie it being a debug build or some other trait? I don't think it necessarily makes sense to have this be the logging trait.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We can't tie it to a debug build because we run tests in release mode AFAIK.
I didn't want a trait just for this, that's why I used the debug logging trait which is always enabled during the 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 !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)
}
Expand Down Expand Up @@ -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 {
Expand Down
105 changes: 105 additions & 0 deletions Tests/SwiftNetworkTests/SwiftNetworkClockTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

}
Loading