Skip to content

Commit 8c865b8

Browse files
committed
Mock out time itself in the elapsed timer and rate limiter tests
These occasionally fail nondeterministically; remove the property which allows them to do so (timing). This also fixes some "off by one" errors that relying on a mock clock revealed -- there was an implicit assumption that sleep() would take a bit more time than requested, and environments with extreme timing sensitivity sometimes failed to do so.
1 parent 1d1fcb8 commit 8c865b8

File tree

6 files changed

+107
-45
lines changed

6 files changed

+107
-45
lines changed

Sources/SWBBuildService/Messages.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,7 @@ private struct GetIndexingHeaderInfoMsg: MessageHandler {
683683
}
684684

685685
extension MessageHandler {
686-
fileprivate func handleIndexingInfoRequest<T: IndexingInfoRequest>(serializationQueue: ActorLock, request: Request, message: T, _ transformResponse: @escaping @Sendable (T, WorkspaceContext, BuildRequest, BuildRequestContext, BuildDescription, ConfiguredTarget, ElapsedTimer) -> any SWBProtocol.Message) async throws -> VoidResponse {
686+
fileprivate func handleIndexingInfoRequest<T: IndexingInfoRequest>(serializationQueue: ActorLock, request: Request, message: T, _ transformResponse: @escaping @Sendable (T, WorkspaceContext, BuildRequest, BuildRequestContext, BuildDescription, ConfiguredTarget, ElapsedTimer<ContinuousClock>) -> any SWBProtocol.Message) async throws -> VoidResponse {
687687
let elapsedTimer = ElapsedTimer()
688688

689689
// FIXME: Move this to use ActiveBuild.
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
//===----------------------------------------------------------------------===//
2+
//
3+
// This source file is part of the Swift open source project
4+
//
5+
// Copyright (c) 2025 Apple Inc. and the Swift project authors
6+
// Licensed under Apache License v2.0 with Runtime Library Exception
7+
//
8+
// See http://swift.org/LICENSE.txt for license information
9+
// See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors
10+
//
11+
//===----------------------------------------------------------------------===//
12+
13+
import SWBUtil
14+
15+
/// A mock clock whose current time is controllable.
16+
public struct MockClock: Clock {
17+
public typealias Instant = ContinuousClock.Instant
18+
19+
private final class State: Sendable {
20+
let now: SWBMutex<Instant>
21+
22+
init(now: Instant) {
23+
self.now = .init(now)
24+
}
25+
}
26+
27+
private let state: State
28+
29+
public init(now: Instant = .now) {
30+
self.state = .init(now: now)
31+
}
32+
33+
public var now: Instant {
34+
state.now.withLock({ $0 })
35+
}
36+
37+
public var minimumResolution: Duration {
38+
ContinuousClock.continuous.minimumResolution
39+
}
40+
41+
public func sleep(until deadline: Instant, tolerance: Duration?) async throws {
42+
state.now.withLock { now in
43+
if now < deadline {
44+
now = deadline
45+
}
46+
}
47+
}
48+
}

Sources/SWBUtil/ElapsedTimer.swift

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,47 +15,51 @@ public import struct Foundation.TimeInterval
1515
/// Provides a simple nanosecond-precision elapsed timer using a monotonic clock.
1616
///
1717
/// This is a completely immutable object and thus is thread safe.
18-
public struct ElapsedTimer: Sendable {
18+
public struct ElapsedTimer<ClockType: Clock>: Sendable where ClockType.Duration == Duration {
19+
private let clock: ClockType
20+
1921
/// The beginning of the time interval, measured when the `ElapsedTimer` was initialized.
20-
private let start = ContinuousClock.now
22+
private let start: ClockType.Instant
2123

2224
/// Initializes a new timer.
2325
///
2426
/// The `ElapsedTimer` object captures the current time at initialization and uses this as the starting point of an elapsed time measurement.
2527
///
2628
/// - note: The starting point of the timer is fixed once it is initialized and the object provides no facilities to "restart" the clock (simply create a new instance to do so).
27-
public init() {
29+
public init(clock: ClockType = ContinuousClock.continuous) {
30+
self.clock = clock
31+
self.start = clock.now
2832
}
2933

3034
/// Computes the length of the time interval, that is, the length of the time interval between now and the point in time when the `ElapsedTimer` was initialized.
3135
///
3236
/// This value is guaranteed to be positive.
3337
public func elapsedTime() -> ElapsedTimerInterval {
34-
return ElapsedTimerInterval(duration: ContinuousClock.now - start)
38+
return ElapsedTimerInterval(duration: start.duration(to: clock.now))
3539
}
3640

3741
/// Time the given closure, returning the elapsed time and the result.
38-
public static func measure<T>(_ body: () throws -> T) rethrows -> (elapsedTime: ElapsedTimerInterval, result: T) {
39-
let timer = ElapsedTimer()
42+
public static func measure<T>(clock: ClockType = ContinuousClock.continuous, _ body: () throws -> T) rethrows -> (elapsedTime: ElapsedTimerInterval, result: T) {
43+
let timer = ElapsedTimer(clock: clock)
4044
let result = try body()
4145
return (timer.elapsedTime(), result)
4246
}
4347

4448
/// Time the given closure, returning the elapsed time and the result.
45-
public static func measure<T>(_ body: () async throws -> T) async rethrows -> (elapsedTime: ElapsedTimerInterval, result: T) {
46-
let timer = ElapsedTimer()
49+
public static func measure<T>(clock: ClockType = ContinuousClock.continuous, _ body: () async throws -> T) async rethrows -> (elapsedTime: ElapsedTimerInterval, result: T) {
50+
let timer = ElapsedTimer(clock: clock)
4751
let result = try await body()
4852
return (timer.elapsedTime(), result)
4953
}
5054

5155
/// Time the given closure, returning the elapsed time.
52-
public static func measure(_ body: () throws -> Void) rethrows -> ElapsedTimerInterval {
53-
return try measure(body).elapsedTime
56+
public static func measure(clock: ClockType = ContinuousClock.continuous, _ body: () throws -> Void) rethrows -> ElapsedTimerInterval {
57+
return try measure(clock: clock, body).elapsedTime
5458
}
5559

5660
/// Time the given closure, returning the elapsed time.
57-
public static func measure(_ body: () async throws -> Void) async rethrows -> ElapsedTimerInterval {
58-
return try await measure(body).elapsedTime
61+
public static func measure(clock: ClockType = ContinuousClock.continuous, _ body: () async throws -> Void) async rethrows -> ElapsedTimerInterval {
62+
return try await measure(clock: clock, body).elapsedTime
5963
}
6064
}
6165

Sources/SWBUtil/RateLimiter.swift

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,17 @@
1313
/// Provides a simple utility for implementing rate-limiting mechanisms.
1414
///
1515
/// This object is NOT thread-safe.
16-
public struct RateLimiter: ~Copyable {
17-
private let start: ContinuousClock.Instant
18-
private var last: ContinuousClock.Instant
16+
public struct RateLimiter<ClockType: Clock>: ~Copyable {
17+
private let clock: ClockType
18+
private let start: ClockType.Instant
19+
private var last: ClockType.Instant
1920

2021
/// The length of the time interval to which updates are rate-limited.
21-
public let interval: Duration
22+
public let interval: ClockType.Duration
2223

23-
public init(interval: Duration) {
24-
let now = ContinuousClock.now
24+
public init(interval: ClockType.Duration, clock: ClockType = ContinuousClock.continuous) {
25+
self.clock = clock
26+
let now = clock.now
2527
self.interval = interval
2628
self.start = now
2729
self.last = now
@@ -31,8 +33,8 @@ public struct RateLimiter: ~Copyable {
3133
/// time this function returned `true`, is greater than the time interval
3234
/// with which this object was initialized.
3335
public mutating func hasNextIntervalPassed() -> Bool {
34-
let now = ContinuousClock.now
35-
let elapsed = now - last
36+
let now = clock.now
37+
let elapsed = last.duration(to: now)
3638
if elapsed >= interval {
3739
last = now
3840
return true

Tests/SWBUtilTests/ElapsedTimerTests.swift

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,18 @@ import SWBTestSupport
1919
@Test(.skipHostOS(.freebsd, "Currently hangs on FreeBSD"))
2020
func time() async throws {
2121
do {
22-
let delta = try await ElapsedTimer.measure {
23-
try await Task.sleep(for: .microseconds(1000))
22+
let clock = MockClock()
23+
let delta = try await ElapsedTimer.measure(clock: clock) {
24+
try await clock.sleep(for: .microseconds(1001))
2425
return ()
2526
}
2627
#expect(delta.seconds > 1.0 / 1000.0)
2728
}
2829

2930
do {
30-
let (delta, result) = try await ElapsedTimer.measure { () -> Int in
31-
try await Task.sleep(for: .microseconds(1000))
31+
let clock = MockClock()
32+
let (delta, result) = try await ElapsedTimer.measure(clock: clock) { () -> Int in
33+
try await clock.sleep(for: .microseconds(1001))
3234
return 22
3335
}
3436
#expect(delta.seconds > 1.0 / 1000.0)

Tests/SWBUtilTests/RateLimiterTests.swift

Lines changed: 26 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,16 +19,17 @@ import SWBTestSupport
1919
fileprivate struct RateLimiterTests {
2020
@Test
2121
func rateLimiterSeconds() async throws {
22-
let timer = ElapsedTimer()
23-
var limiter = RateLimiter(interval: .seconds(1))
22+
let clock = MockClock()
23+
let timer = ElapsedTimer(clock: clock)
24+
var limiter = RateLimiter(interval: .seconds(1), clock: clock)
2425
#expect(limiter.interval == .nanoseconds(1_000_000_000))
2526

2627
var count = 0
2728
for _ in 0..<3 {
2829
if limiter.hasNextIntervalPassed() {
2930
count += 1
3031
}
31-
try await Task.sleep(for: .seconds(1))
32+
try await clock.sleep(for: .seconds(1))
3233
}
3334

3435
#expect(count > 1)
@@ -37,16 +38,17 @@ fileprivate struct RateLimiterTests {
3738

3839
@Test
3940
func rateLimiterTwoSeconds() async throws {
40-
let timer = ElapsedTimer()
41-
var limiter = RateLimiter(interval: .seconds(2))
41+
let clock = MockClock()
42+
let timer = ElapsedTimer(clock: clock)
43+
var limiter = RateLimiter(interval: .seconds(2), clock: clock)
4244
#expect(limiter.interval == .nanoseconds(2_000_000_000))
4345

4446
var count = 0
4547
for _ in 0..<3 {
4648
if limiter.hasNextIntervalPassed() {
4749
count += 1
4850
}
49-
try await Task.sleep(for: .seconds(1))
51+
try await clock.sleep(for: .seconds(1))
5052
}
5153

5254
#expect(count > 0)
@@ -55,16 +57,17 @@ fileprivate struct RateLimiterTests {
5557

5658
@Test
5759
func rateLimiterMilliseconds() async throws {
58-
let timer = ElapsedTimer()
59-
var limiter = RateLimiter(interval: .milliseconds(100))
60+
let clock = MockClock()
61+
let timer = ElapsedTimer(clock: clock)
62+
var limiter = RateLimiter(interval: .milliseconds(100), clock: clock)
6063
#expect(limiter.interval == .nanoseconds(100_000_000))
6164

6265
var count = 0
63-
for _ in 0..<100 {
66+
for _ in 0..<101 {
6467
if limiter.hasNextIntervalPassed() {
6568
count += 1
6669
}
67-
try await Task.sleep(for: .microseconds(2001))
70+
try await clock.sleep(for: .microseconds(2001))
6871
}
6972

7073
#expect(count > 1)
@@ -73,16 +76,17 @@ fileprivate struct RateLimiterTests {
7376

7477
@Test
7578
func rateLimiterMicroseconds() async throws {
76-
let timer = ElapsedTimer()
77-
var limiter = RateLimiter(interval: .microseconds(100000))
79+
let clock = MockClock()
80+
let timer = ElapsedTimer(clock: clock)
81+
var limiter = RateLimiter(interval: .microseconds(100000), clock: clock)
7882
#expect(limiter.interval == .nanoseconds(100_000_000))
7983

8084
var count = 0
81-
for _ in 0..<100 {
85+
for _ in 0..<101 {
8286
if limiter.hasNextIntervalPassed() {
8387
count += 1
8488
}
85-
try await Task.sleep(for: .microseconds(1001))
89+
try await clock.sleep(for: .microseconds(1001))
8690
}
8791

8892
#expect(count > 0)
@@ -91,16 +95,17 @@ fileprivate struct RateLimiterTests {
9195

9296
@Test
9397
func rateLimiterNanoseconds() async throws {
94-
let timer = ElapsedTimer()
95-
var limiter = RateLimiter(interval: .nanoseconds(100_000_000))
98+
let clock = MockClock()
99+
let timer = ElapsedTimer(clock: clock)
100+
var limiter = RateLimiter(interval: .nanoseconds(100_000_000), clock: clock)
96101
#expect(limiter.interval == .nanoseconds(100_000_000))
97102

98103
var count = 0
99-
for _ in 0..<100 {
104+
for _ in 0..<101 {
100105
if limiter.hasNextIntervalPassed() {
101106
count += 1
102107
}
103-
try await Task.sleep(for: .microseconds(1001))
108+
try await clock.sleep(for: .microseconds(1001))
104109
}
105110

106111
#expect(count > 0)
@@ -109,12 +114,13 @@ fileprivate struct RateLimiterTests {
109114

110115
@Test
111116
func rateLimiterNoCrashNever() async throws {
112-
var limiter = RateLimiter(interval: .nanoseconds(UInt64.max))
117+
let clock = MockClock()
118+
var limiter = RateLimiter(interval: .nanoseconds(UInt64.max), clock: clock)
113119

114120
for _ in 0..<2 {
115121
let nextIntervalPassed = limiter.hasNextIntervalPassed()
116122
#expect(!nextIntervalPassed)
117-
try await Task.sleep(for: .seconds(1))
123+
try await clock.sleep(for: .seconds(1))
118124
}
119125
}
120126
}

0 commit comments

Comments
 (0)