diff --git a/Sources/SwiftNetwork/Context/NetworkContext.swift b/Sources/SwiftNetwork/Context/NetworkContext.swift index e63963d..c4f6de7 100644 --- a/Sources/SwiftNetwork/Context/NetworkContext.swift +++ b/Sources/SwiftNetwork/Context/NetworkContext.swift @@ -83,9 +83,7 @@ public final class NetworkContext: NetworkContextProtocol, @unchecked Sendable { /// Runs an immediate task. No assumptions are made about how the task is run. func runImmediate(_ task: @escaping (() -> Void)) /// Schedules a task to run after a delay, using a reference. - /// - /// The `milliseconds` parameter specifies the delay before the task runs. - func schedule(_ task: @escaping (() -> Void), milliseconds: Int64, reference: TimerReference) + func schedule(_ task: @escaping (() -> Void), after delay: NetworkDuration, reference: TimerReference) /// Unschedules a task with a reference. func unschedule(reference: TimerReference) /// A Boolean value that indicates whether the current code is running in the scheduler. @@ -368,10 +366,18 @@ extension NetworkContext { globals.queue.async(execute: DispatchWorkItem(block: task)) } /// Schedules a task to run after a delay, using a reference. - /// - /// The `milliseconds` parameter specifies the delay before the task runs. - func schedule(_ task: @escaping (() -> Void), milliseconds: Int64, reference: TimerReference) { - let targetTime = DispatchTime.now() + DispatchTimeInterval.milliseconds(Int(milliseconds)) + func schedule(_ task: @escaping (() -> Void), after delay: NetworkDuration, reference: TimerReference) { + let nanoseconds = max(delay.nanoseconds, 0) + let seconds = nanoseconds / 1_000_000_000 + // A sub-second delay is the common case and its nanosecond count fits an `Int` on every + // platform, so it costs one addition. Longer delays are split because + // `DispatchTimeInterval` takes an `Int`, which is 32 bits on 32-bit watchOS. + let targetTime = + seconds == 0 + ? DispatchTime.now() + .nanoseconds(Int(nanoseconds)) + : DispatchTime.now() + + .seconds(Int(clamping: seconds)) + + .nanoseconds(Int(clamping: nanoseconds % 1_000_000_000)) globals.timerList.insert(targetTime: targetTime, reference: reference, task: task) } /// Unschedules a task with a reference. @@ -417,12 +423,16 @@ extension NetworkContext { enum FutureTime { case unschedule - case milliseconds(UInt64, () -> Void) // Milliseconds into the future + /// A delay and the task to run once it elapses. + /// + /// A negative delay runs the task at the first opportunity. The delay resolves nanoseconds, + /// but how finely a scheduler can honour it is the scheduler's own limit. + case after(NetworkDuration, () -> Void) } public func scheduleTimer(duration: NetworkDuration, completion: @escaping () -> Void) -> TimerReference { let newReference = TimerReference() - resetTimer(for: newReference, to: .milliseconds(UInt64(duration.milliseconds), completion)) + resetTimer(for: newReference, to: .after(duration, completion)) return newReference } @@ -435,8 +445,8 @@ extension NetworkContext { switch time { case .unschedule: scheduler.unschedule(reference: reference) - case .milliseconds(let milliseconds, let block): - scheduler.schedule(block, milliseconds: Int64(milliseconds), reference: reference) + case .after(let delay, let block): + scheduler.schedule(block, after: delay, reference: reference) } } #endif diff --git a/Sources/SwiftNetwork/Protocols/BridgeProtocol.swift b/Sources/SwiftNetwork/Protocols/BridgeProtocol.swift index e9e940d..51ba1a6 100644 --- a/Sources/SwiftNetwork/Protocols/BridgeProtocol.swift +++ b/Sources/SwiftNetwork/Protocols/BridgeProtocol.swift @@ -162,7 +162,7 @@ public struct BridgeDatagramProtocol: NetworkProtocol { } else { guard !timerSet else { return } timerSet = true - self.scheduleWakeup(milliseconds: UInt64(linkDelay.milliseconds)) + self.scheduleWakeup(after: linkDelay) } } diff --git a/Sources/SwiftNetwork/Protocols/ProtocolEventManager.swift b/Sources/SwiftNetwork/Protocols/ProtocolEventManager.swift index c6f3a8c..a4746e0 100644 --- a/Sources/SwiftNetwork/Protocols/ProtocolEventManager.swift +++ b/Sources/SwiftNetwork/Protocols/ProtocolEventManager.swift @@ -580,13 +580,13 @@ extension NetworkContext { index: NetworkStateIndex, timerReference: TimerReference, referenceToWakeup: ProtocolInstanceReference, - milliseconds: UInt64 + after delay: NetworkDuration ) { self.softAssert() self.resetTimer( for: timerReference, - to: .milliseconds( - milliseconds, + to: .after( + delay, { self.assert() self.protocolEventStates[index].startTimerWakeupCall() @@ -786,7 +786,7 @@ extension ProtocolInstanceReference { } func scheduleWakeup( - milliseconds: UInt64, + after delay: NetworkDuration, timerReference: TimerReference ) { let protocolEventStateIndex = protocolEventStateIndex()! @@ -794,7 +794,7 @@ extension ProtocolInstanceReference { index: protocolEventStateIndex, timerReference: timerReference, referenceToWakeup: self, - milliseconds: milliseconds + after: delay ) } diff --git a/Sources/SwiftNetwork/Protocols/ProtocolInstance.swift b/Sources/SwiftNetwork/Protocols/ProtocolInstance.swift index 7ef4cd2..9245a39 100644 --- a/Sources/SwiftNetwork/Protocols/ProtocolInstance.swift +++ b/Sources/SwiftNetwork/Protocols/ProtocolInstance.swift @@ -90,8 +90,8 @@ public protocol TimerSchedulable: ~Copyable, ProtocolInstance { @available(Network 0.1.0, *) extension TimerSchedulable { - public func scheduleWakeup(milliseconds: UInt64) { - reference.scheduleWakeup(milliseconds: milliseconds, timerReference: timerReference) + public func scheduleWakeup(after delay: NetworkDuration) { + reference.scheduleWakeup(after: delay, timerReference: timerReference) } public func unscheduleWakeup() { diff --git a/Sources/SwiftNetwork/QUIC/Timer.swift b/Sources/SwiftNetwork/QUIC/Timer.swift index af26a63..338fd5a 100644 --- a/Sources/SwiftNetwork/QUIC/Timer.swift +++ b/Sources/SwiftNetwork/QUIC/Timer.swift @@ -97,6 +97,16 @@ final class Timer: PrefixedLoggable { case armed(NetworkClock.Instant) } + /// How far a deadline may move before the pending wakeup is re-armed. + /// + /// A deadline that shifts from e.g. 5ms out to 4.5ms out keeps the wakeup it already has, so this + /// bounds re-arm precision independently of what the scheduler can express: the scheduler takes + /// a `NetworkDuration` and so resolves nanoseconds, but a *revision* smaller than this is still + /// ignored. Tightening it trades re-arms for precision and wants a benchmark behind it. + /// + /// It also decides whether that coalescing is attempted at all. A deadline nearer than this + /// always re-arms, because tolerating up to a millisecond of error would dominate it: half a + /// millisecond out, a coalesced wakeup could land after the deadline had already passed. static let timerThreshold = NetworkDuration.milliseconds(1) init(reference: ProtocolInstanceReference, timerReference: TimerReference, logPrefixer: LogPrefixer) { @@ -218,7 +228,7 @@ final class Timer: PrefixedLoggable { log.datapath( "arming timer for the next \(delta) (now \(now)), new deadline \(nextDeadline) old deadline \(oldDeadline)" ) - reference?.scheduleWakeup(milliseconds: UInt64(delta.milliseconds), timerReference: timerReference) + reference?.scheduleWakeup(after: delta, timerReference: timerReference) } private func find(_ identifier: TimerID) -> Int? { diff --git a/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift b/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift index 83d709b..7e53a94 100644 --- a/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift +++ b/Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift @@ -41,7 +41,7 @@ final class SwiftNetworkContextTests: NetTestCase { context.resetTimer( for: timerReference, - to: .milliseconds(2000) { + to: .after(.milliseconds(2000)) { expectation.fulfill() } ) @@ -83,6 +83,43 @@ final class SwiftNetworkContextTests: NetTestCase { context.unscheduleTimer(timerReference1) } + /// A delay under a millisecond must reach the scheduler intact; otherwise it arrives as no + /// delay at all and the wakeup cannot reach the deadline it was armed for. + func testContextTimerKeepsASubMillisecondDelay() { + let scheduler = RecordingScheduler() + let context = NetworkContext(identifier: "test", externalScheduler: scheduler) + + let timerReference = context.scheduleTimer(duration: .microseconds(625)) { + XCTFail("The recording scheduler arms nothing, so the task must not run") + } + + XCTAssertEqual(scheduler.scheduledDelays, [.microseconds(625)]) + + context.unscheduleTimer(timerReference) + XCTAssertEqual(scheduler.unscheduledReferences, [timerReference]) + } + + /// 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 { + var scheduledDelays: [NetworkDuration] = [] + var unscheduledReferences: [TimerReference] = [] + + func runImmediate(_ task: @escaping (() -> Void)) { + task() + } + + func schedule(_ task: @escaping (() -> Void), after delay: NetworkDuration, reference: TimerReference) { + scheduledDelays.append(delay) + } + + func unschedule(reference: TimerReference) { + unscheduledReferences.append(reference) + } + + var runningInScheduler: Bool { true } + } + func testContextTimerReferences() { // Ensure timer references are unique let timerReference1 = TimerReference()