Skip to content

doctor: gate H4 on the guest's 250 ppm budget for the host's clock correction - #1027

Merged
Pedro Henrique Penna (ppenna) merged 5 commits into
devfrom
agents/issue-1008-debug-test-fix-commit
Oct 30, 2026
Merged

Pedro Henrique Penna (ppenna) merged 5 commits into
devfrom
agents/issue-1008-debug-test-fix-commit

Conversation

@ppenna

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

Copy link
Copy Markdown
Contributor

Fixes #1008.

Summary

From 2026-10-28 23:00Z to 2026-10-29 15:00Z, H4 failed 9 of the 75 microVM and platform jobs on azure-mshv-1, and 8 CI runs with them. The runner's TSC keeps its rate, but the Hyper-V PTP clock that chronyd follows wanders. H4 gated the host's current clock correction, the TSC's error plus that wander, against 100 ppm. The time ABI v1 had set that bound for the TSC's error alone, and no short measurement can tell the two apart.

This PR implements the issue's proposed fix, one commit per item:

  1. Gate on the guest's budget (doctor: gate H4 on the guest's 250 ppm clock correction budget). H4's limit is now 250 ppm: Linux's 500 ppm frequency limit less the time ABI's 250 ppm restore tolerance. The doctor derives RATE_TOLERANCE_PPM from those two constants. A new unit test reads FREQUENCY_LIMIT and RATE_DEVIATION_LIMIT from nvx-rs, and TSC_TOLERANCE_PPM from the OpenVMM gitlink's commit, as the CPU profile tests read OpenVMM's catalog, and checks that H4's limit is their difference.
  2. Re-read chronyd before declaring a result inconclusive (doctor: re-read chronyd's estimate before H4 calls a rate inconclusive). When the range within chronyd's skew straddles the limit, H4 re-reads the estimate every 2 s for up to 60 s and uses the first conclusive one, whether it passes or fails, keeping the same samples. Otherwise it fails as inconclusive, as before.
  3. Warn above 100 ppm (doctor: warn when H4's deviation exceeds 100 ppm). A passing deviation above 100 ppm passes with a warning. The warning gives the share of the budget that the host's correction uses and says that one run can't tell the TSC's error from the reference's wander. It leads the H4 detail, the step summary repeats it, and in GitHub Actions a ::warning:: annotation does too.
  4. Use the kernel's correction without chronyd (doctor: read a kernel PLL's frequency in H4 without chronyd). Without chronyd's estimate, H4 corrects the rate by adjtimex's frequency plus its tick's, while the kernel reports the clock synchronized with its PLL on (STA_PLL), as systemd-timesyncd and ntpd run it. Otherwise it measures the CLOCK_MONOTONIC window as before. chronyd keeps the PLL off and slews through the frequency, so H4 never reads it from chronyd. H4 and H7 now share one ctypes struct timex, and H7's output is unchanged.
  5. Report the average correction since boot, as evidence (doctor: report H4's average correction since boot as evidence). On Linux, H4 reports the rate of CLOCK_MONOTONIC_RAW against CLOCK_MONOTONIC over the uptime, and the deviation that it gives. It doesn't gate on it.

doc/ci.md's H4 row describes the limit with its rationale, the re-reads, the warning, the kernel PLL, and the since-boot evidence. The wall-clock discipline in doc/design/3.2-clock-and-time.md ties H4's limit to the budget, and doc/usage.md says how the doctor reports a warning.

These still fail closed as before: the interval checks against CLOCK_MONOTONIC_RAW, the rate's plausibility, an unreadable or unrecognized chronyd report, H6, and the time ABI's restore tolerance.

Validation

Local (Windows):

  • python -m unittest scripts/test_time_abi.py passes 74 tests. The budget test reads OpenVMM at the pinned c32c57d12, and mutating either constant fails it.
  • ruff check, ruff format --check, pyright --pythonplatform Linux and Windows, check_markdown_links.py, and compileall pass.
  • The rest of validate-nvx's list passes except 27 shell-script failures in test_setup_azure.py and test_microvm_tests.py. Those fail identically on a clean dev worktree on Windows.

Linux (nvx-mshv-test, Azure Linux 3, Python 3.12):

Read-only H4 runs on the runners. I ran CI's H4 schedule from /tmp, with each runner's declared rate from its latest H3 line, alternating dev's doctor and this one:

Host Rounds chronyd's estimate (skew) dev H4 failures New H4 failures Since-boot average
azure-mshv-1 (wandering) 24 +57.1 to +97.1 ppm (up to 26.6) 5, inconclusive at 100 ppm 0 +43.39 to +43.46 ppm over 40 h
azure-mshv-3 (steady) 12 +9.9 to +10.1 ppm (≤ 0.45) 0 0 +9.39 ppm over 40 h

I also drove the remaining paths on real hardware with synthetic declared rates:

  • Warning (azure-mshv-3, 150 ppm): H4 passes, the detail leads with the warning, and the annotation prints with 60%25 escaped.
  • Re-read that resolves (azure-mshv-3, 250 ppm): H4 re-read for 24 s until chronyd's range lay wholly above the limit, then failed conclusively.
  • Re-read that stays inconclusive (azure-mshv-1, 250 ppm): no re-read in 58 s was conclusive, so H4 failed as inconclusive.
  • Fallback (prometheus30): chronyd runs with cmdport 0, so unprivileged chronyc can't read it. adjtimex reports the PLL off (status=0x0000), so H4 measures the window and states both reasons as evidence.

Since-boot survey: on the 5 KVM runners and the 4 steady MSHV runners, the average matched chronyd's current estimate within 2.0 ppm after about 40 h, and within 0.2 ppm on prometheus32 after 63 days. On azure-mshv-1, it read +43.4 ppm while chronyd's estimate wandered from +21 to +97 ppm.

No configured host runs a PLL-mode time service; all run chronyd. NVX's own guest, though, drives the kernel PLL as systemd-timesyncd does. In an Alpine microVM on nvx-mshv-test, the PLL's frequency, which kernel_frequency() reads, changed only at the discipline's polls and held at −0.4 to −1.9 ppm. Over the same seconds, the 1 s CLOCK_MONOTONIC/RAW window swung between −13 and −42 ppm with the phase slews (details in the PR comments). Before the discipline synchronized, the kernel reported TIME_ERROR with STA_UNSYNC, which kernel_frequency() rejects.

Notes

From 2026-10-28 23:00Z to 2026-10-29 15:00Z, H4 failed 9 of the 75
microVM and platform jobs on azure-mshv-1, and with them 8 CI runs,
among them a nightly run and three dev pushes (#1008). The runner's TSC
kept its rate: its interval rates against CLOCK_MONOTONIC_RAW agreed
within 0.005 ppm in every job. What moved is the Hyper-V PTP clock that
chronyd follows, whose rate has wandered since about 20:00Z on
2026-10-28, so chronyd's frequency estimate, which H4 gates, read from
+2.5 to +189.3 ppm.

H4 held that estimate, the host's current clock correction, to 100 ppm,
the bound that the time ABI v1 set for the TSC's error alone, to keep
the guest's wall-clock discipline, which steers at most 500 ppm, clear
of that error plus a restore's rate deviation of up to 250 ppm. No short
measurement can tell the TSC's error from the reference's wander, so
the bound failed a host whose guests the time ABI can serve.

H4's limit is now the guest's budget for the host's correction: Linux's
frequency limit of 500 ppm, nvx-rs's FREQUENCY_LIMIT, less the time
ABI's restore tolerance of 250 ppm, which a restore's rate deviation
can use and which nvx-rs's RATE_DEVIATION_LIMIT and OpenVMM's
TSC_TOLERANCE_PPM apply. The doctor derives RATE_TOLERANCE_PPM from the
two, and a new unit test reads nvx-rs's constants from its time
decoders and OpenVMM's from the gitlink's commit, as the CPU profile
tests read OpenVMM's catalog, and checks that the limit is their
difference. 250 ppm is also KVM's TSC rate tolerance, which Firecracker
applies too. H4 still fails a range that lies wholly above the limit
and calls one that straddles it inconclusive; the interval checks, the
plausibility check, the parsing of chronyd's report, and the restore
tolerance stay as they are.

Of the 9 failures, 8 lie wholly within 250 ppm and would have passed.
The ninth, +189.1 ppm with a range of 58.5 to 319.8 ppm, straddles it.

The H4 tests' cases move to the new limit, and a +150 ppm correction now
passes. doc/ci.md's H4 row gives the limit with the rationale that the
architecture guide's rewrite dropped, and the wall-clock discipline
ties the budget to host qualification.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
H4 bounds chronyd's frequency estimate by chronyd's skew, and calls a
rate inconclusive when the rates within that skew straddle the limit.
On azure-mshv-1, whose reference clock wanders, chronyd's estimate and
skew move within seconds (#1008): in the 15 minutes from 02:51Z on
2026-10-29, the estimate read +19.1, +189.3, +117.1, +51.5, +7.2, and
+3.5 ppm, the last with a skew of 0.08 ppm. The job that read
+189.3 ppm with a skew of 130.6 ppm straddles even the new 250 ppm
limit, at 58.5 to 319.8 ppm, while the runner's next two jobs, which
started 50 s and 87 s later, passed conclusively.

When chronyd's range straddles the limit, H4 now re-reads the estimate
every 2 s for up to 60 s, and uses the first whose range lies wholly
within or wholly beyond the limit. Only chronyd's estimate changes, so
the samples against CLOCK_MONOTONIC_RAW stand: H4 corrects them by the
estimate that it uses, which the measured rate, the rate reference, and
the gate all reflect, and the detail names the first estimate as
evidence. A chronyc read that fails during the re-reads counts as
inconclusive. A range that straddles the limit for the whole minute
still fails as inconclusive, and the detail says that no re-read was
conclusive. Without H3's rate there is no range, so H4 never re-reads.

The re-reads keep the check stateless, and add up to a minute only to a
run whose first estimate straddles the limit.

The H4 tests run on a fake clock: an estimate that never moves fails as
inconclusive after 30 re-reads, one that settles is used, even when it
fails, and a run without H3 reads chronyd once. doc/ci.md describes the
re-reads and the time that they can add to runner validation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
H4's limit is now the guest's 250 ppm budget for the host's clock
correction, so a host whose correction exceeds the time ABI v1's
100 ppm passes. Such a correction comes from a TSC far from its declared
rate or from a reference clock that wanders, which no single run can
tell apart (#1008), and either deserves a look before it uses up the
budget.

H4 now passes such a host with a warning that gives the deviation and
the share of the guest's budget that it uses, and says that one run
can't tell the TSC's error from the reference clock's wander. A
deviation that fails or stays inconclusive gets no warning, since its
failure says more.

CheckResult gains a warning field for a concern that doesn't fail the
check. The check's detail leads with it, after "warning:", so that the
NVX-DOCTOR line states it; the job summary repeats it below the table;
and run_checks prints it as a ::warning:: annotation, escaped as
workflow commands require, when it runs in GitHub Actions, so that a CI
run shows it on the run's page. Only H4 warns.

7 of the 8 failures of #1008 that now pass lie above 100 ppm and would
warn; the other runners read at most about +57 ppm and never would.

The H4 tests check the warning's text, that only a passing deviation
above 100 ppm warns, and that 100 ppm itself doesn't; a new test
follows a warning to the check's line, the summary, and the annotation,
which only GitHub Actions gets. doc/ci.md and doc/usage.md describe
warnings, and the H4 row the one that H4 gives.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Without chronyd's frequency estimate, H4 measures the TSC against
CLOCK_MONOTONIC over its sampling window, which also carries the slews
that remove the clock's offset: on azure-mshv-1, a 2 s window read
+946.1 ppm while the TSC ran 44 ppm from its declared rate (#768). A
host that another time service keeps, such as systemd-timesyncd or
ntpd, has no chronyd, but a better estimate (#1008): those services
steer the clock through the kernel's PLL, which keeps its frequency
estimate in adjtimex's frequency and slews offsets apart from it.

When chronyd's estimate is missing and adjtimex reports the clock
synchronized with the PLL on (STA_PLL), H4 now corrects the rate
against CLOCK_MONOTONIC_RAW by the PLL's frequency, adjtimex's freq
plus the frequency that its tick sets, as it corrects it by chronyd's
estimate, and keeps the window's rate as evidence. Without STA_PLL the
frequency is chronyd's to set, and it carries chronyd's slews: on
azure-mshv-1, reads 2 s apart gave +98.5, -33.4, and -14.5 ppm while
chronyd's estimate read +33.4 and then +30.7 ppm, so the frequency
matches minus chronyd's estimate only between slews. H4 falls back to
the window there, as on a clock that adjtimex reports unsynchronized,
and the detail says why neither estimate is available. That is what
H4 does on prometheus30, whose chronyd takes no commands over UDP
(cmdport 0), so chronyc can't read its estimate, while adjtimex reports
the PLL off with chronyd's frequency. Windows keeps
QueryPerformanceCounter.

H4 and H7 now read struct timex through one ctypes structure, as glibc
declares it, instead of reading H7's status at a fixed offset of a raw
buffer; H7's results and messages don't change. The tick matters:
nvx-mshv-test's chronyd held it at 9,999, 100 ppm below nominal.

Every Linux host at hand runs chronyd, so none could take the PLL path.
On azure-mshv-1, the structure's size, 208 bytes, and its status,
frequency, and tick read as expected, and kernel_frequency's unit tests
cover the PLL's states. The H4 tests check the correction, its
evidence, the unsynchronized fallback, and that H4 reads the PLL only
without chronyd's estimate. doc/ci.md describes the PLL's frequency.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
No single run of H4 can tell the TSC's error from the wander of the
host's reference clock (#1008), but the host's clocks keep a long-run
average: CLOCK_MONOTONIC and CLOCK_MONOTONIC_RAW both exclude suspend,
and after a small offset at boot only the time service's gradual
adjustments separate them. CLOCK_MONOTONIC_RAW's rate against
CLOCK_MONOTONIC over the uptime is therefore the time service's
correction averaged since boot, which needs no saved state.

On Linux, H4 now reports that average as evidence: the TSC's rate
against CLOCK_MONOTONIC_RAW corrected by it, the average and the hours
that it covers, and, when H3 ran, that rate's deviation from the
backend's. The job summary lists it. Slews just after boot or a live
migration bias it, so H4 doesn't gate on it until runners show how it
behaves.

Measured read-only on 2026-10-29, after 38 to 42 hours of uptime, the
average matched chronyd's current estimate within 2.0 ppm on the five
KVM runners and the four steady MSHV runners, whose estimates ranged
from -0.3 to +51.8 ppm, and within 0.2 ppm on prometheus32 after
63 days. On azure-mshv-1, whose reference wanders, it read +43.6 ppm
over 38.5 hours while chronyd's estimate moved from +33.4 to
+30.7 ppm, and +43.4 ppm half an hour later, when chronyd's read
+21.3 ppm: close to the runner's long-run rate, which stayed between
+34 and +48 ppm over any 6 hours of jobs. nvx-mshv-test, an MSHV test
VM, read +43.4 ppm against chronyd's +35.2 ppm.

The H4 tests check the evidence with and without H3's rate, and that
Windows, which has no CLOCK_MONOTONIC_RAW, skips it; a new test checks
the average's arithmetic, and that the host's own clocks give one.
doc/ci.md describes it.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Copilot AI balanced review requested due to automatic review settings October 29, 2026 22:06

Copilot AI left a comment

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.

🔵 Needs a closer look

The kernel-PLL qualification path has only mocked coverage and needs validation on a synchronized Linux host with STA_PLL enabled to confirm that its selected frequency excludes phase slews.

No open issues 🎉

What changed in this PR

Updates the doctor’s H4 host qualification check to use the guest’s 250 ppm clock-correction budget, addressing failures caused by wandering reference clocks in #1008 without changing the time ABI or restore tolerance.

Changes:

  • Derives H4’s limit from the guest’s frequency budget and warns on passing deviations above 100 ppm.
  • Retries inconclusive chronyd estimates, adds a synchronized kernel PLL fallback, and reports since-boot correction as evidence.
  • Adds tests and documents the qualification behavior and warning output.
File Description
scripts/​test_time_abi.py Tests budget constants, retries, PLL correction, since-boot evidence, and warnings.
scripts/​nvx_tools/​doctor.py Implements the revised H4 gate, correction sources, retries, and reporting.
doc/​usage.md Explains warnings in doctor output.
doc/​design/​3.2-clock-and-time.md Connects host qualification to the guest’s correction budget.
doc/​ci.md Documents H4’s limits, fallback behavior, evidence, and retry duration.

Review effort: Balanced

@ppenna

Copy link
Copy Markdown
Contributor Author

Following up on the review's note that the kernel PLL path had only mocked coverage: I validated it on a real synchronized kernel PLL.

No configured host runs a PLL-mode time service; all run chronyd, which keeps STA_PLL off. NVX's own guest, though, disciplines its wall clock exactly as systemd-timesyncd does, with ADJ_OFFSET under STA_PLL|STA_NANO (nvx-rs/src/guest/sys/discipline.rs). So I booted an Alpine microVM on nvx-mshv-test (MSHV) with nvx-rs run and ran a static probe there every second for 150 s. The probe printed adjtimex's state and status, the remaining phase offset, and the frequency that kernel_frequency() reads (freq / 2^16 + (tick - 10000) * 100), beside the rate of CLOCK_MONOTONIC against CLOCK_MONOTONIC_RAW over that second.

Seconds after start adjtimex PLL frequency (doctor) Remaining phase offset CLOCK_MONOTONIC vs RAW over 1 s
0 to 16 state=5 status=0x2040 (unsynchronized) — 0 ±0.03 ppm
20 state=0 status=0x2001 (STA_PLL|STA_NANO) +0.000 ppm −0.97 ms −15.4 ppm
50 same −0.972 ppm −2.25 ms −37.0 ppm
70 same −1.659 ppm −2.52 ms −42.3 ppm
120 same −1.659 ppm −1.15 ms −20.2 ppm
150 same −1.919 ppm −3.89 ms −17.2 ppm
  • The PLL's frequency changes only at the discipline's polls, at about 17, 32, 48, 64, and 128 s, and holds between them. Over the same seconds the 1 s window swings by tens of ppm with the phase slew. So adjtimex's frequency excludes the PLL's phase slews, which is the premise of the fallback, while the window that H4 used before would have read up to −42 ppm against a −1.7 ppm frequency.
  • Until the discipline synchronizes, the kernel reports TIME_ERROR with STA_UNSYNC, which kernel_frequency() rejects. That leaves H4 on the window, as designed.
  • The probe's struct timex is 208 bytes in the guest too. On the host, whose chronyd holds tick=9999, the doctor's formula with the tick term gave −72.555 ppm, against a measured −72.558 ppm between slews.

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.

doctor: H4 fails hosts whose reference clock wanders, because it gates their current clock correction at 100 ppm

3 participants