Skip to content
Merged
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
32 changes: 21 additions & 11 deletions Sources/SwiftNetwork/Context/NetworkContext.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)

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.

I think you marked this with the corresponding label but this will force a change here:
https://github.com/apple/swift-nio-quic/blob/main/Sources/NIOQUIC/SwiftNetwork/QUICChannelEventLoop.swift#L60

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.

Yes, I believe that Tommy either has or is planning API-breaking changes so getting them all in the same release can be good for adopters.

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.

Yeah this will be breaking

/// Unschedules a task with a reference.
func unschedule(reference: TimerReference)
/// A Boolean value that indicates whether the current code is running in the scheduler.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}

Expand All @@ -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)

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.

I am guessing its a wash, but just to make sure can you profile QUICTransfer with CPU trace to make sure that since we are operating on an object now that we did not incur a spike in CPU?

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.

From what I can see the only difference is that the construction of the targetTime now creates two DispatchTimeIntervals, one for the seconds and one for the nanoseconds. We could have the code branch in the case that there are 0 seconds, but I think this difference is in the noise anyway.

}
}
#endif
Expand Down
2 changes: 1 addition & 1 deletion Sources/SwiftNetwork/Protocols/BridgeProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}

Expand Down
10 changes: 5 additions & 5 deletions Sources/SwiftNetwork/Protocols/ProtocolEventManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -786,15 +786,15 @@ extension ProtocolInstanceReference {
}

func scheduleWakeup(
milliseconds: UInt64,
after delay: NetworkDuration,
timerReference: TimerReference
) {
let protocolEventStateIndex = protocolEventStateIndex()!
context.scheduleWakeup(
index: protocolEventStateIndex,
timerReference: timerReference,
referenceToWakeup: self,
milliseconds: milliseconds
after: delay
)
}

Expand Down
4 changes: 2 additions & 2 deletions Sources/SwiftNetwork/Protocols/ProtocolInstance.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
12 changes: 11 additions & 1 deletion Sources/SwiftNetwork/QUIC/Timer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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? {
Expand Down
39 changes: 38 additions & 1 deletion Tests/SwiftNetworkTests/SwiftNetworkContextTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ final class SwiftNetworkContextTests: NetTestCase {

context.resetTimer(
for: timerReference,
to: .milliseconds(2000) {
to: .after(.milliseconds(2000)) {
expectation.fulfill()
}
)
Expand Down Expand Up @@ -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()
Expand Down
Loading