Files
visor/pkg/sentry/time/calibrated_clock_test.go
lyphyserandHanzo Dev 44eb3cf04f kernel: park Timekeeper when idle
Timekeeper.startUpdater drove a timer that re-armed every update interval
for the lifetime of the sentry, so a fully idle sandbox (no runnable tasks)
woke the sentry on every tick only to rewrite VDSO clock parameters that
nothing could read.

Make the updater park itself while nothing needs a fresh clock, mirroring the
CPU clock ticker's idle handling. The updater goroutine's state is held in an
atomic, Timekeeper.updaterState: a lifecycle state, updaterStopped, plus three
running states ordered parked < idle < active. updaterState is mutated by the
updater goroutine's tick, by addRef, and by start/stopUpdater; all hold updateMu
except addRef's lockless idle->active CAS, the fast path that re-activates an
idle updater.

Pinning the updater active is a reference count (Timekeeper.refs):

  - addRef pins the updater active until the matching release, waking and
    re-calibrating it if it had parked; release drops the reference. The goroutine
    winds down one step per idle tick (active->idle, then idle->parked) and re-reads
    refs after each store, while addRef adds its reference before reading the state
    -- store-then-load on both sides -- so a referenced updater is never left winding
    down. addRef is lockless when the updater is active (nothing to do) or idle (a
    single idle->active CAS); only waking a parked updater takes updateMu, where it
    re-calibrates (the params have drifted) before returning so the caller never reads
    a stale clock.

  - incRunningTasks takes a reference on the 0->1 task transition and
    decRunningTasks drops it on 1->0, so the updater stays active for as long as
    any task can read the VDSO. The reference is taken before the running count is
    published, so a concurrent resumer that observes the count cannot read a
    drifted clock. incRunningTasks holds runningTasksMu across addRef; the lock
    order is always runningTasksMu -> updateMu (the updater goroutine reads refs
    locklessly and never takes runningTasksMu), so this cannot deadlock, and only
    a task resume from a fully idle sandbox re-calibrates under that lock.

  - GetTime brackets the actual clock read with addRef/release, so the updater
    stays active -- and the clock fresh -- for the whole read, even if the reader
    is descheduled in the middle of it. The read is lockless unless it finds the
    updater parked -- i.e. the sandbox has been idle long enough (a full update
    interval) for the updater to park -- when addRef takes updateMu to wake and
    re-calibrate it.

The kernel drives the updater entirely through addRef/release; the Timekeeper
reads no kernel state. refs is nosave -- at save time no task is running and no
read is in flight, so it is zero, and it is rebuilt as tasks resume and reads
occur.

The VDSO parameter page has a single writer, so the sample-and-write step is
factored into Timekeeper.update() and every refresh holds updateMu across it: the
updater goroutine's tick, startUpdater's activation refresh (only when it starts
with a reference already held), and addRef's parked slow path. updaterState's
idle->active CAS is lockless, but every transition that publishes fresh parameters
(out of parked or stopped) holds updateMu, so a reader that observes a non-parked
state can rely on the parameters being fresh.

updaterStopped is a lifecycle state, distinct from the idle wake path, that keeps
PauseUpdates/Destroy correct: stopUpdater publishes updaterStopped under updateMu
before it closes stop and waits for the goroutine, so an addRef arriving afterward
cannot restart the updater or rewrite the page (it still counts the reference, but
leaves the state stopped). While stopped, a runnable task may execute in
sentry/system mode; startUpdater (via ResumeUpdates) restarts the updater parked,
or active if a reference is already held. Only startUpdater leaves updaterStopped.

The Clocks.Update interface gains a parked bool: on resume from a parked period
the gap was unobserved and at least one update interval long, so the calibrated
clock re-calibrates to the freshly sampled parameters rather than slewing the
accumulated drift forward over a single interval.

The updater starts parked rather than active, activating on the first reference.
A fully idle sentry performs no periodic clock wakeups; freshness on resume and on
internal reads is preserved.

Co-authored-by: Hanzo Dev <[email protected]>
2026-06-24 16:55:34 +00:00

188 lines
6.3 KiB
Go

// Copyright 2018 The gVisor Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package time
import (
"testing"
"time"
)
// newTestCalibratedClock returns a CalibratedClock that collects samples from
// the given sample list and cycle counts from the given cycle list.
func newTestCalibratedClock(samples []sample, cycles []TSCValue) *CalibratedClock {
return &CalibratedClock{
ref: newTestSampler(samples, cycles),
}
}
func TestConstantFrequency(t *testing.T) {
// Perfectly constant frequency.
samples := []sample{
{before: 100000, after: 100000 + defaultOverheadCycles, ref: 100},
{before: 200000, after: 200000 + defaultOverheadCycles, ref: 200},
{before: 300000, after: 300000 + defaultOverheadCycles, ref: 300},
{before: 400000, after: 400000 + defaultOverheadCycles, ref: 400},
{before: 500000, after: 500000 + defaultOverheadCycles, ref: 500},
{before: 600000, after: 600000 + defaultOverheadCycles, ref: 600},
{before: 700000, after: 700000 + defaultOverheadCycles, ref: 700},
}
c := newTestCalibratedClock(samples, nil)
// Update from all samples.
for range samples {
c.Update(false)
}
c.mu.RLock()
if !c.ready {
c.mu.RUnlock()
t.Fatalf("clock not ready")
return // For checklocks consistency.
}
// A bit after the last sample.
now, ok := c.params.ComputeTime(750000)
c.mu.RUnlock()
if !ok {
t.Fatalf("ComputeTime ok got %v want true", ok)
}
t.Logf("now: %v", now)
// Time should be between the current sample and where we'd expect the
// next sample.
if now < 700 || now > 800 {
t.Errorf("now got %v want > 700 && < 800", now)
}
}
func TestErrorCorrection(t *testing.T) {
testCases := []struct {
name string
samples [5]sample
projectedTimeStart int64
projectedTimeEnd int64
}{
// Initial calibration should be ~1MHz for each of these, and
// the reference clock changes in samples[2].
{
name: "slow-down",
samples: [5]sample{
{before: 1000000, after: 1000001, ref: ReferenceNS(1 * ApproxUpdateInterval.Nanoseconds())},
{before: 2000000, after: 2000001, ref: ReferenceNS(2 * ApproxUpdateInterval.Nanoseconds())},
// Reference clock has slowed down, causing 100ms of error.
{before: 3010000, after: 3010001, ref: ReferenceNS(3 * ApproxUpdateInterval.Nanoseconds())},
{before: 4020000, after: 4020001, ref: ReferenceNS(4 * ApproxUpdateInterval.Nanoseconds())},
{before: 5030000, after: 5030001, ref: ReferenceNS(5 * ApproxUpdateInterval.Nanoseconds())},
},
projectedTimeStart: 3005 * time.Millisecond.Nanoseconds(),
projectedTimeEnd: 3015 * time.Millisecond.Nanoseconds(),
},
{
name: "speed-up",
samples: [5]sample{
{before: 1000000, after: 1000001, ref: ReferenceNS(1 * ApproxUpdateInterval.Nanoseconds())},
{before: 2000000, after: 2000001, ref: ReferenceNS(2 * ApproxUpdateInterval.Nanoseconds())},
// Reference clock has sped up, causing 100ms of error.
{before: 2990000, after: 2990001, ref: ReferenceNS(3 * ApproxUpdateInterval.Nanoseconds())},
{before: 3980000, after: 3980001, ref: ReferenceNS(4 * ApproxUpdateInterval.Nanoseconds())},
{before: 4970000, after: 4970001, ref: ReferenceNS(5 * ApproxUpdateInterval.Nanoseconds())},
},
projectedTimeStart: 2985 * time.Millisecond.Nanoseconds(),
projectedTimeEnd: 2995 * time.Millisecond.Nanoseconds(),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
c := newTestCalibratedClock(tc.samples[:], nil)
// Initial calibration takes two updates.
_, ok := c.Update(false)
if ok {
t.Fatalf("Update ready too early")
}
params, ok := c.Update(false)
if !ok {
t.Fatalf("Update not ready")
}
// Initial calibration is ~1MHz.
hz := params.Frequency
if hz < 990000 || hz > 1010000 {
t.Fatalf("Frequency got %v want > 990kHz && < 1010kHz", hz)
}
// Project time at the next update. Given the 1MHz
// calibration, it is expected to be ~3.1s/2.9s, not
// the actual 3s.
//
// N.B. the next update time is the "after" time above.
projected, ok := params.ComputeTime(tc.samples[2].after)
if !ok {
t.Fatalf("ComputeTime ok got %v want true", ok)
}
if projected < tc.projectedTimeStart || projected > tc.projectedTimeEnd {
t.Fatalf("ComputeTime(%v) got %v want > %v && < %v", tc.samples[2].after, projected, tc.projectedTimeStart, tc.projectedTimeEnd)
}
// Update again to see the changed reference clock.
params, ok = c.Update(false)
if !ok {
t.Fatalf("Update not ready")
}
// We now know that TSC = tc.samples[2].after -> 3s,
// but with the previous params indicated that TSC
// tc.samples[2].after -> 3.5s/2.5s. We can't allow the
// clock to go backwards, and having the clock jump
// forwards is undesirable. There should be a smooth
// transition that corrects the clock error over time.
// Check that the clock is continuous at TSC =
// tc.samples[2].after.
newProjected, ok := params.ComputeTime(tc.samples[2].after)
if !ok {
t.Fatalf("ComputeTime ok got %v want true", ok)
}
if newProjected != projected {
t.Errorf("Discontinuous time; ComputeTime(%v) got %v want %v", tc.samples[2].after, newProjected, projected)
}
// As the reference clock stabilizes, ensure that the clock error
// decreases.
initialErr := c.errorNS
t.Logf("initial error: %v ns", initialErr)
_, ok = c.Update(false)
if !ok {
t.Fatalf("Update not ready")
}
if c.errorNS.Magnitude() > initialErr.Magnitude() {
t.Errorf("errorNS increased, got %v want |%v| <= |%v|", c.errorNS, c.errorNS, initialErr)
}
_, ok = c.Update(false)
if !ok {
t.Fatalf("Update not ready")
}
if c.errorNS.Magnitude() > initialErr.Magnitude() {
t.Errorf("errorNS increased, got %v want |%v| <= |%v|", c.errorNS, c.errorNS, initialErr)
}
t.Logf("final error: %v ns", c.errorNS)
})
}
}