Skip to content

Add VirtualTimeScheduler to the test harness - #230

Draft
deadcaf3 wants to merge 1 commit into
apple:mainfrom
deadcaf3:feat-virtual-time-scheduler
Draft

deadcaf3 wants to merge 1 commit into
apple:mainfrom
deadcaf3:feat-virtual-time-scheduler

Conversation

@deadcaf3

@deadcaf3 deadcaf3 commented Oct 7, 2026

Copy link
Copy Markdown
Contributor

Part of #229.

Motivation

End-to-end tests run protocol code on the real-time DefaultScheduler, so every timer-driven assertion waits for real time to pass and reads a clock the test does not control. NetworkContext(identifier:externalScheduler:) already lets a test inject a scheduler, but nothing in the test targets implements one that runs timers: the existing doubles in SwiftNetworkContextTests record or advance a clock and arm nothing.

Changes

Adds VirtualTimeScheduler to SwiftNetworkTestHarness, exported under @_spi(TestHarness) like the rest of the harness API. It implements NetworkContext.Scheduler on a clock the test owns:

  • runImmediate queues; nothing runs on the caller's stack, as the protocol's doc comment requires.
  • schedule arms a timer at now plus the delay and replaces an earlier timer under the same reference, mirroring DefaultScheduler's TimerList.insert. Equal deadlines fire in insertion order. A delay too long for the clock saturates at Instant.maximum instead of trapping.
  • now is set to a timer's deadline before its task runs, so a deadline the library just reached looks reached. nowAbsolute stays a fixed 4000 ms ahead, the same split AdvancingScheduler uses so a context that confuses the clocks fails loudly.
  • runningInScheduler is true only inside a task, so EventContext.assert() still catches library state touched from outside one.

Driver API, all on the test's thread: runUntilIdle(), advance(by:), advance(to:), runUntilNextTimer(), nextDeadline, pendingTimerCount. A step limit (default 100 000 tasks per driver call, settable in init) trips a precondition failure when a call has run that many tasks and more are still waiting, so a self-rescheduling loop fails instead of never ending. Calling a driver method from inside a task is a precondition failure.

Harness only: nothing under Sources/SwiftNetwork changes and no dependency is added.

Tests

Tests/SwiftNetworkTests/VirtualTimeSchedulerTests.swift, 15 cases: immediate ordering and never running on the caller's stack, deadline order with now equal to the deadline, timers armed mid-advance, queued work running before each timer, unschedule and re-arm under one reference, a seeded random run of 5000 re-arms and unschedules, runningInScheduler inside and outside tasks, the stall guard, a delay beyond the clock, both clocks moving together, and an end-to-end case that arms a NetworkContext timer from inside a context task and sees it fire at the virtual deadline.

Measurements

Setup: Apple M1, 8 cores, 16 GB, macOS 26.6.2, Swift 6.4, debug build, swift test.

This PR:

Measured Result
The 15 new tests 0.02 s in total
A context timer inside a 10 s virtual advance fires at its deadline in under 1 ms of real time
Existing tests unchanged, since none use the scheduler yet

What it enables. These numbers come from a local experiment that routes the QUICTestHarness waits through the scheduler. It is not part of this PR and was measured at c8ecea0:

Tests Real clock Virtual time
17 QUIC loss and delay tests 27.0 s 0.2 s
123 of the 124 QUIC tests in SwiftNetworkTests 67.0 s 9.8 s
Whole suite, 1292 tests, all passing both ways 89.0 s 27.7 s

Two virtual runs give identical timelines for every harness test. The details and the open questions for that follow-up are in the issue.

End-to-end tests run protocol code on the real-time DefaultScheduler, so
every timer-driven assertion waits for real time to pass and reads a
clock the test does not control. NetworkContext already takes an
external scheduler, but nothing in the test targets implements one that
runs timers.

VirtualTimeScheduler implements NetworkContext.Scheduler on a clock the
test owns. Immediates queue until a driver method drains them, timers
fire in deadline order with `now` set to each deadline before its task
runs, and runningInScheduler is true only inside a task so the context's
own assertion still catches state touched from outside. Timers are kept
sorted and found by binary search, so arming or cancelling thousands of
them stays fast, and a delay too long for the clock saturates the way
DefaultScheduler clamps one. A step limit stops a driver call that still
has work after that many tasks, and the stall handler is replaceable so
the guard itself can be tested.

Harness only: nothing under Sources/SwiftNetwork changes and no
dependency is added.
@tfpauly
tfpauly requested a review from rnro October 7, 2026 14:38
@tfpauly

tfpauly commented Oct 7, 2026

Copy link
Copy Markdown
Collaborator

We already have an open PR, #191, in the repository that adds mocked time, and additionally it looks like this change is directly taking code from that (identical comments in some places). Please wait for that to go in.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants