Skip to content

[DO NOT REVIEW] vmcore: map VM times beyond the host clock to its latest time - #4820

Open
Pedro Henrique Penna (ppenna) wants to merge 2 commits into
feature/microvm/devfrom
copilot/vmtime-far-timeouts
Open

Pedro Henrique Penna (ppenna) wants to merge 2 commits into
feature/microvm/devfrom
copilot/vmtime-far-timeouts

Conversation

@ppenna

@ppenna Pedro Henrique Penna (ppenna) commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

Fixes the VM time keeper's panic of microsoft/nvx#1038, and a spin of the keeper next to it. A timeout that VM time reaches beyond what the host clock can represent, more than about 584 years ahead, now maps to the host clock's latest time instead of panicking the task that arms it. So does a timeout nearly half the ring, about 29,000 years, ahead of now, which mapped to a host time in the past and made the keeper spin.

Based on feature/microvm/dev. This branch is two commits directly on bcfc47cff, the head of feature/microvm/dev since #4818 merged, which microsoft/nvx's dev pins. microsoft/nvx#1042 pins this branch's head.

Why

VmTime counts 100 ns units in 64 bits and orders them circularly: a time less than half the ring after another follows it. TimerState::timestamp maps a VM time to the host time at which VM time reaches it, by adding the time's circular distance from the last start to the host time of that start. set_next arms the keeper's host timer with it, and VmTimeAccess::host_time returns it. Two kinds of time broke it:

  • Beyond the host clock. pal_async's Instant counts nanoseconds in 64 bits, about 584 years, and its Add panics on overflow, so a timeout more than about 584 years after the start panicked the task that armed it. In vmtime: armed timeout + restore to a large time panics the keeper task on start() nvx#1038's sequence, a timeout is armed while VM time is stopped, and VM time is restored to a time that the timeout follows by 2.66e18 units, so start() panics the time source's task with "duration too large".
  • Nearly half the ring ahead. Once VM time has run since the start, a time nearly half the ring after now is more than half the ring after the start, which the circular order puts before it, so timestamp mapped it to the host time of the start, in the past. The waiters compare with now, though, and keep waiting: the host timer fired at once, wake re-armed the same deadline, and TimerState::poll looped forever with the timer state's write lock held.

A guest can choose both kinds where OpenVMM emulates the Hyper-V synthetic timers, as WHP does when it does not offload the enlightenments: a one-shot timer expires at the absolute reference time that the guest writes, up to half the ring after the current one.

The issue suggests clamping the distance at a day, after TimerState::poll's default deadline. That stops the panic but makes the keeper spin, as above, once VM time has run for a day since the start, since the clamped deadline is then in the past: a TimerState started two days ago with a timeout 1,000 years ahead never returns from poll with it.

Saturation (342887c99)

timestamp adds the distance with Instant::saturating_add, so a time beyond the host clock maps to its latest time, which the host clock never reaches. Every timer backend already accepts that deadline: a timeout just short of overflowing reached it before, and the epoll, kqueue, IOCP, thread pool, io_uring, and local backends saturate their own conversions of it.

test_timeout_beyond_host_clock arms a timeout 1,000 years ahead of a running keeper and checks that host_time returns the host clock's latest time. test_timeout_beyond_host_clock_after_restore follows the issue's sequence. Both check that the timeout keeps waiting and that an earlier timeout still fires, and both panic with "duration too large" without the fix.

Times nearly half the ring ahead (5e0ab772e)

timestamp maps a time that the circular order puts before the start, but after the current time, to the host clock's latest time too. It reads the current time only for a time before the start, so the common case costs nothing more. A time before the start and not after now is in the past, and still maps to the host time of the start.

test_timeout_nearly_half_the_ring_ahead sets a timeout at the latest time that follows now, after VM time has run for 10 ms, and checks that a time in the past before the start still maps to the start. Without the fix, host_time returns the host time of the start, and without that check, the wait spins until nextest kills the test.

Validation

  • On Windows and on Linux (Azure Linux 3), at each commit: cargo nextest run --profile agent -p vmcore (32 and 33 tests passed) and cargo doc --no-deps -p vmcore. On Windows, also cargo clippy --all-targets -p vmcore -- -D warnings and cargo xtask fmt --fix. All clean.
  • At bcfc47cff, on both, the new tests fail as described: the two overflow tests panic in Instant::add, the issue's with its backtrace (timestamp ← set_next ← wake ← TimerState::start ← SecondaryKeeper::run), and the wait for a timeout nearly half the ring ahead spends 9.99 s of user CPU time in 10 s until it is killed.
  • End to end, with this branch pinned by openvmm: map VM times beyond the host clock to its latest time nvx#1042, NVX's default microVM suite, 41 scenarios with 1, 2, 4, and 8 vCPUs, passed on Linux/KVM and Linux/MSHV test VMs, and openvmm: map VM times beyond the host clock to its latest time nvx#1042's CI passed.
  • CI: run 41011644930 passed on its second attempt. Its first failed two Windows vmm-tests jobs on failures outside this change, which their rerun passed. In x64-windows-amd-snp, two SNP tests panicked in openhcl_boot, which does not depend on vmcore, with "Imported regions hash mismatch", as that job did in at least seven other pull requests' runs of the past day. In x64-windows-intel, hibernate_token failed with os error 10022 in petri's harness before OpenVMM started.
  • Every commit is signed.

VmTime is a wrapping 64-bit count of 100 ns units, which is_before and
checked_sub order circularly: a time less than half the ring, 2^63 units
or about 29,000 years, after another follows it. The host clock,
pal_async's Instant, counts nanoseconds in 64 bits, about 584 years.
TimerState::timestamp maps a VM time to the host time at which VM time
reaches it: it adds the time's circular distance from the last start to
the host time of that start, with Instant's Add, which panics when the
sum overflows the host clock, so for any time more than about 584 years,
less the host's uptime at the start, after the start.

Every timeout passes through timestamp: set_next arms the keeper's host
timer with it whenever a timeout becomes the earliest that is pending,
and VmTimeAccess::host_time returns it. A timeout that far ahead
therefore panics the task that arms it. microsoft/nvx#1038 reaches this
with public calls alone: a timeout at 1.8e16 is armed while VM time is
stopped, and VM time is restored to 1.58e19, which the timeout follows
by 2.66e18 units, so that start() panics the time source's task
(SecondaryKeeper::run) in TimerState::start. A device that sets a
timeout 1,000 years ahead panics its own task. A guest can choose such
a timeout where OpenVMM emulates the Hyper-V synthetic timers, as WHP
does when it does not offload the enlightenments: a one-shot timer
expires at the absolute reference time that the guest writes, which
virt_whp turns into a VM-time timeout.

Saturate the sum at the host clock's latest time, with
Instant::saturating_add, since the host clock never reaches a time that
it cannot represent. Every timer backend already accepts that deadline:
a timeout just short of overflowing reached it before, and the epoll,
kqueue, IOCP, thread pool, io_uring, and local backends saturate their
own conversions of it. A timeout that far ahead thus keeps its waiter
waiting, and an earlier timeout still replaces it. The documentation of
timestamp and host_time states the new bound.

Saturating the distance itself at a day instead, as the issue suggests
after TimerState::poll's default deadline, would stop the panic but
make the keeper spin: once VM time has run for a day since the start,
the deadline of a timeout further ahead is in the past, so the host
timer fires, wake re-arms it with the same past deadline, and poll loops
with the timer state's lock held.

The new tests arm a timeout 1,000 years ahead of a running keeper, and
the issue's sequence of calls. They check that host_time returns the
host clock's latest time, that the timeout keeps waiting, and that an
earlier one still fires. Both panic with "duration too large" without
the fix.

Part of microsoft/nvx#1038.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
TimerState::timestamp maps a VM time to the host time at which VM time
reaches it, from the time's circular distance after the last start, and
maps a time that VM time's circular order puts before the start to the
host time of that start. The keeper's waiters, though, compare their
timeouts with the current time: poll_timeout waits for a timeout that is
not before now, and wake re-arms the host timer for one after now. Once
VM time has run since the start, the two disagree about a time nearly
half the ring, about 29,000 years, after now: it follows now, but it is
more than half the ring after the start, which the circular order puts
before it.

Such a timeout spins the keeper. set_next arms the host timer with the
host time of the start, which has passed, so the timer fires at once.
TimerState::poll then wakes, finds the timeout still after now, re-arms
the same deadline, and loops without returning, with the timer state's
write lock held, so every VmTimeAccess of the time source blocks. A test
that sets such a timeout spends 9.99 s of user CPU time in 10 s, on
Linux, until it is killed. VmTimeAccess::host_time returns the same past
host time, which OpenHCL's VP loop uses as the deadline of its own
timer. As with a timeout beyond the host clock, a guest can choose such
a timeout where OpenVMM emulates the Hyper-V synthetic timers, since a
one-shot timer may expire up to half the ring after the current
reference time.

Map a time that the circular order puts before the start, but after the
current time, to the host clock's latest time instead: it is in the
future, further ahead than the host clock can represent. timestamp reads
the current time only for a time before the start, so the common case
costs nothing more. A time before the start and not after now is in the
past, and still maps to the host time of the start. The documentation
of timestamp and host_time says which times each bound covers.

The new test sets a timeout at the latest time that follows now, after
VM time has run for 10 ms since the start. It checks that host_time
returns the host clock's latest time, that a time in the past before the
start still maps to the start, that the timeout keeps waiting, and that
an earlier one still fires. Without the fix, host_time returns the host
time of the start, and without that check, the wait never ends.

Part of microsoft/nvx#1038.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Pedro Henrique Penna (ppenna) added a commit to microsoft/nvx that referenced this pull request Oct 30, 2026
Record the two commits that the previous commit pins as positions 368
and 369 of the microVM chain, and move the NVX pin to 5e0ab772e.

Positions 368 and 369 make OpenVMM's VM time keeper map a timeout
beyond what the host clock can represent to the host clock's latest
time: one more than about 584 years after the last start no longer
panics the task that arms it, and one nearly half the ring after now,
which VM time's circular order puts before the start, no longer arms the
host timer in the past. They change only vmcore's vmtime.rs, which
upstream main and the fork base have alike, and apply to upstream main
as they are; say so in the table's remarks. The pin is now 212 commits
past the mapped tip.

Position 367, bcfc47cff, is the head of feature/microvm/dev since
microsoft/openvmm#4818 merged positions 366 and 367. Say so in the
scope, and name microsoft/openvmm#4820 as the proposal of positions 368
and 369.

Refs #1038

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

Copy link
Copy Markdown

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.

1 participant