Skip to content

Schedule timers with a NetworkDuration instead of whole milliseconds - #115

Open
rnro wants to merge 1 commit into
apple:mainfrom
rnro:sub-millisecond-timer-scheduling
Open

Schedule timers with a NetworkDuration instead of whole milliseconds#115
rnro wants to merge 1 commit into
apple:mainfrom
rnro:sub-millisecond-timer-scheduling

Conversation

@rnro

@rnro rnro commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

NetworkContext.Scheduler.schedule took a count of milliseconds, so every caller computing a deadline at nanosecond resolution had to truncate on the way in. A delay under a millisecond truncated to no delay at all, arming a wakeup that could not reach the deadline it was armed for: the handler ran early, recomputed the same delay and armed again, spinning until enough real time had passed. Timer.recalculate, the bridge's link delay and scheduleTimer(duration:) all fed it.

  • Scheduler.schedule takes an after delay, and FutureTime carries a duration, so the truncation has nowhere left to happen.
  • scheduleWakeup threads the duration through TimerSchedulable and ProtocolInstanceReference to the context, replacing four UInt64(_.milliseconds) conversions.
  • DefaultScheduler builds its deadline from whole seconds plus a remainder, so neither part can overrun the Int that DispatchTimeInterval takes, which is 32 bits on 32-bit watchOS.
  • A recording Scheduler lets testContextTimerKeepsASubMillisecondDelay assert the delay a caller asked for.

`NetworkContext.Scheduler.schedule` took a count of milliseconds, so every caller computing a
deadline at nanosecond resolution had to truncate on the way in. A delay under a millisecond
truncated to no delay at all, arming a wakeup that could not reach the deadline it was armed for:
the handler ran early, recomputed the same delay and armed again, spinning until enough real time
had passed. `Timer.recalculate`, the bridge's link delay and `scheduleTimer(duration:)` all fed it.

* `Scheduler.schedule` takes an `after` delay, and `FutureTime` carries a duration, so the
  truncation has nowhere left to happen.
* `scheduleWakeup` threads the duration through `TimerSchedulable` and `ProtocolInstanceReference`
  to the context, replacing four `UInt64(_.milliseconds)` conversions.
* `DefaultScheduler` builds its deadline from whole seconds plus a remainder, so neither part can
  overrun the `Int` that `DispatchTimeInterval` takes, which is 32 bits on 32-bit watchOS.
* A recording `Scheduler` lets `testContextTimerKeepsASubMillisecondDelay` assert the delay a
  caller asked for, which needs no clock and no waiting.

Source-breaking for anything outside the package conforming to `Scheduler`.
@rnro rnro added the ⚠️ semver/major Breaks existing public API. label Aug 24, 2026
@rnro
rnro requested a review from ekinnear as a code owner August 24, 2026 19:38
let nanoseconds = max(delay.nanoseconds, 0)
let targetTime =
DispatchTime.now()
+ .seconds(Int(clamping: nanoseconds / 1_000_000_000))

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.

Why does this split up the seconds and nanoseconds?

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.

If we put it all in at nanoseconds then we'd easily overrun 32-bit Int max (I think it's ~2.7s). Splitting it up this way means we can express bigger timer durations.

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.

32-bit Int max milliseconds in the old code was probably long enough to not cause an issue in practice.

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.

Ah I see this is because of going through DispatchTimeInterval. Makes sense now!

///
/// 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

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.

let nanoseconds = max(delay.nanoseconds, 0)
let targetTime =
DispatchTime.now()
+ .seconds(Int(clamping: nanoseconds / 1_000_000_000))

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.

Ah I see this is because of going through DispatchTimeInterval. Makes sense now!

///
/// 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.

Yeah this will be breaking

func schedule(_ task: @escaping (() -> Void), after delay: NetworkDuration, reference: TimerReference) {
let nanoseconds = max(delay.nanoseconds, 0)
let targetTime =
DispatchTime.now()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't we be using System.Time.now?

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.

Possibly, it looks fishy here because the deadlines use system time but that is a broader change than this PR. DispatchTime.now() was used in the existing code and timerList.insert(targetTime: requires a DispatchTime.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

⚠️ semver/major Breaks existing public API.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants