Repository navigation
doctor: gate H4 on the guest's 250 ppm budget for the host's clock correction - #1027
Conversation
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>
There was a problem hiding this comment.
🔵 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
|
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
|
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:
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 derivesRATE_TOLERANCE_PPMfrom those two constants. A new unit test readsFREQUENCY_LIMITandRATE_DEVIATION_LIMITfrom nvx-rs, andTSC_TOLERANCE_PPMfrom the OpenVMM gitlink's commit, as the CPU profile tests read OpenVMM's catalog, and checks that H4's limit is their difference.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.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.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 theCLOCK_MONOTONICwindow 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 ctypesstruct timex, and H7's output is unchanged.doctor: report H4's average correction since boot as evidence). On Linux, H4 reports the rate ofCLOCK_MONOTONIC_RAWagainstCLOCK_MONOTONICover 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 indoc/design/3.2-clock-and-time.mdties H4's limit to the budget, anddoc/usage.mdsays 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.pypasses 74 tests. The budget test reads OpenVMM at the pinnedc32c57d12, and mutating either constant fails it.ruff check,ruff format --check,pyright --pythonplatform LinuxandWindows,check_markdown_links.py, andcompileallpass.validate-nvx's list passes except 27 shell-script failures intest_setup_azure.pyandtest_microvm_tests.py. Those fail identically on a cleandevworktree on Windows.Linux (
nvx-mshv-test, Azure Linux 3, Python 3.12):test_time_abi.pypasses, including the budget test with the submodule and the Linux branch of the since-boot test.test_adversarial.py, andtest_hosts.pypass.validate-nvxlist passes once scripts: test the mutants merge against the shards' indexed outcomes #1023 fixeddev's mutants tests, which ci: fix nvx-rs mutants summarize miscounting shards #952 had broken.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, alternatingdev's doctor and this one:devH4 failuresazure-mshv-1(wandering)azure-mshv-3(steady)I also drove the remaining paths on real hardware with synthetic declared rates:
azure-mshv-3, 150 ppm): H4 passes, the detail leads with the warning, and the annotation prints with60%25escaped.azure-mshv-3, 250 ppm): H4 re-read for 24 s until chronyd's range lay wholly above the limit, then failed conclusively.azure-mshv-1, 250 ppm): no re-read in 58 s was conclusive, so H4 failed as inconclusive.prometheus30): chronyd runs withcmdport 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
prometheus32after 63 days. Onazure-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, whichkernel_frequency()reads, changed only at the discipline's polls and held at −0.4 to −1.9 ppm. Over the same seconds, the 1 sCLOCK_MONOTONIC/RAWwindow swung between −13 and −42 ppm with the phase slews (details in the PR comments). Before the discipline synchronized, the kernel reportedTIME_ERRORwithSTA_UNSYNC, whichkernel_frequency()rejects.Notes
agents/issue-1008-h4-guest-budget, an earlier attempt at the same plan.