Skip to content

test(e2e): capture the sandbox kernel KVM exit counters across the node-join wait - #3886

Merged
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
feat/e2e-nodejoin-kvm-exits
Aug 17, 2026
Merged

test(e2e): capture the sandbox kernel KVM exit counters across the node-join wait#3886
Aleksei Sviridkin (lexfrei) merged 1 commit into
mainfrom
feat/e2e-nodejoin-kvm-exits

Conversation

@lexfrei

@lexfrei Aleksei Sviridkin (lexfrei) commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Every CPU reading this suite takes on a failing node-join describes a subject inside the sandbox: a cgroup, a QEMU thread, a sandbox node's own /proc/stat. All of them can report a guest that was handed its ticks while the guest gets no work done, and none of them can say what a tick cost. That is the state the node-join failures are in now, with quota untouched, throttling near zero, steal at zero and the sandbox host between a third and two thirds idle, while the guest does several times less work per core than the same image does on a run that passes.

The price is paid one layer down. A tenant worker is a guest of a sandbox node, which is itself a guest of the runner, and the runner's kernel absorbs the exits, nested page faults and TLB flushes that the arrangement provokes. That kernel publishes the totals in debugfs, and the suite runs in a container sharing it, so this is a local read rather than a walk over nodes.

Being local is what makes it affordable. The budget guard in hack/run-kubernetes-node-join_test.bats computes both sides of its own inequality, and on this tree the sampling pair can overshoot by 662s against the 680s the derivation allows, which leaves 18 seconds of room. A per-node reading costs two samples times three nodes times the read bound and its kill grace, or 150 seconds at today's knobs, and no transport changes that: the cost sits in the number of per-node calls rather than in the number of counters. Reading the layer the sandbox shares with the runner costs one bounded read instead, and takes nothing away from the collectors already there.

debugfs is mounted in a mount namespace belonging to the call. The suite runs for another twenty minutes after this returns, and a mount left behind would be a diagnostic editing the environment the rest of the run measures.

The pair is taken across the node-join wait rather than inside the diagnostics block. A counter here accumulates over the life of the VM it belongs to, so one reading is an average over that life rather than a rate over anything; taking the first before the wait and the second after it makes the interval a difference divides by the window the guest was failing to boot in, instead of a few seconds after the deadline had already expired. The kernel-wide files add a wrinkle the artifact states beside the numbers: the kernel answers them by summing over the VMs alive at the moment of the read, so a VM that went away between the samples takes its whole contribution with it while the survivors keep accumulating. The difference is then understated by a whole VM life and its sign proves nothing when positive; when it does come out negative, that is a finding about the sandbox rather than a rate.

The second reading is taken on the passing path too, so a green run leaves a baseline to read the failing ones against, and it is taken immediately after the wait rather than at the end of the run. That placement is the measurement: the happy-path tail is another ten to fifteen minutes of a tenant cluster doing storage and LoadBalancer work, so a reading taken after it would price a different window over a different workload while the capture's legend says it priced the join, and the green-versus-red comparison the collector exists for would be gone. A guard in hack/run-kubernetes-node-join_test.bats pins both calls to either side of the wait and fails if either drifts.

What the capture cannot answer is written into the artifact rather than left for a reader to assume. The counters price the sandbox VMs' own execution and not what runs inside them. A per-VM directory is named after a pid in the kernel's initial namespace, which the container cannot map to its own processes, so the split says how evenly the sandbox VMs paid and not which node paid what. Several counters sitting in that same directory are gauges or running maxima rather than cumulative, so a difference of those is a number with no meaning. And there is no ept_violation counter to look for: the nested-paging faults are counted as pf_taken and pf_fixed, under those names whichever vendor the runner turns out to be.

Every branch that can come up short is named where a reader will look for it: no unshare or no mount on PATH, a mount the sandbox refused, a kernel whose debugfs holds no kvm directory at all (which would be a finding about how the sandbox VMs are being run, not a collector that failed), a kvm directory holding nothing readable, a walk the wrapper cut short, and a report directory that could not be created. The pairing instruction rides only on the arm that holds a whole reading, so the artifact never invites a difference against a file that has no counters in it.

A capture holding no counter answers its caller non-zero, the two findings included, so the passing path writes the shortfall into the job log instead of leaving it in a report nobody downloads. A walk cut short after it wrote counters does not: it left a usable partial reading, marked as partial, and telling the caller nothing was collected would contradict the file sitting beside it. That distinction is decided by whether a counter reached the file rather than by whether the file has bytes in it, because the walk writes a heading before it reads anything.

Covered by hack/run-kubernetes-kvm-exits_test.bats, which runs under make unit-tests. The guards in hack/run-kubernetes-node-join_test.bats that enumerate the collectors were updated rather than worked around: the new capture gets a cost arm in the budget guard, an entry in the missing-timeout warning, and an explicit exemption from the spend-order list, because it is not behind the phase gate. It is not behind that gate deliberately: every other collector the phase declines loses only itself, while declining this one would orphan a reading already taken eighteen minutes earlier. Two guards there are new: one pins where the two readings sit rather than what they contain, the other pins that both call sites consume the collector's status, since this is the only collector in that file whose return value is read at all and it is read so a passing run's shortfall reaches the job log.

The collector is also stubbed in that suite, at the shared helper and at each of the five inline stub lists inside its bash -c bodies. Left out of them the node-join unit tests call it for real, which on Linux means the unit suite runs unshare --mount and mount -t debugfs with whatever privileges the host gives it, while on macOS the collector stops at its own command -v unshare precondition and the path is never exercised at all: a unit test whose result is set by the machine rather than by the code. Measured with a recording fake on PATH, the suite makes 21 real invocations with no stub, 9 with the shared helper alone, and none with every site covered. Stubbing costs the block-level read audit nothing, because that audit works by letting real collector bodies reach the kubectl mock and this collector issues no kubectl read.

Context for the measurement is #3513.

Screenshots

Not a UI change.

Downstream repositories

I walked the trigger map in docs/agents/contributing.md against the diff. The diff adds one collector to hack/e2e-chainsaw/_lib/run-kubernetes.sh, adds one BATS suite under hack/, and edits guards in an existing BATS suite. Nothing under hack/ is moved or renamed, no make target changes what it does, and hack/e2e-prepare-cluster.bats is not touched, so neither the ccp trigger on hack/ layout nor the ansible-cozystack and talm triggers on node prerequisites fire.

Release note

test(e2e): capture the runner kernel's KVM exit counters on either side of the tenant node-join wait, so a node-join failure can be told apart from a nested-virtualisation slowdown that no counter inside the sandbox can see

Summary by CodeRabbit

  • New Features

    • Added sandbox KVM-exit diagnostics during Kubernetes node-join operations.
    • Reports counter availability, interpretation, timestamps, warnings, and read status.
    • Captures readings before and after node-join waits, including failure scenarios.
    • Uses bounded collection and isolated debugfs access to limit diagnostic overhead.
  • Bug Fixes

    • Improved timeout warnings with KVM diagnostic status and missing-reading details.
  • Tests

    • Added extensive coverage for counter types, malformed or unavailable data, timeouts, failures, cleanup, and report generation.

…de-join wait

Every CPU reading on this failure path describes a subject inside the
sandbox -- a cgroup, a QEMU thread, a node's own /proc/stat -- and all of
them can report a guest that was handed its ticks while the guest gets no
work done. None of them can say what a tick cost. That price is paid one
layer down: a tenant worker is a guest of a sandbox node, which is itself
a guest of the runner, and the runner's kernel absorbs the exits, nested
page faults and TLB flushes the arrangement provokes.

Read those counters from the debugfs that kernel publishes them in. The
suite runs in a container sharing it, so this is a local read rather than
a walk over nodes, which is what makes it affordable at all: the phase
budget has no room left for a further per-node read. debugfs is mounted
in a mount namespace belonging to the call, so nothing is left behind for
the twenty minutes of suite that follow it.

The pair is taken across the node-join wait rather than inside the
diagnostics block: once before the wait on every run, once after it on
the failing path and on the passing one. The counters are running
totals -- per-VM since that VM was created, kernel-wide summed over the
VMs alive at the read -- so the interval a difference divides by becomes
the window the guest was failing to boot in, and a passing run leaves a
baseline to read the failing ones against.

Assisted-By: Claude <noreply@anthropic.com>
Signed-off-by: Aleksei Sviridkin <3811295@gmail.com>
@lexfrei
Aleksei Sviridkin (lexfrei) merged commit c3247c0 into main Aug 17, 2026
9 of 10 checks passed
@lexfrei
Aleksei Sviridkin (lexfrei) deleted the feat/e2e-nodejoin-kvm-exits branch August 17, 2026 11:19
@coderabbitai

coderabbitai Bot commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 4072f751-3e4a-4c18-b68d-f41fede1ece2

📥 Commits

Reviewing files that changed from the base of the PR and between 420c316 and 827f4e7.

📒 Files selected for processing (3)
  • hack/e2e-chainsaw/_lib/run-kubernetes.sh
  • hack/run-kubernetes-kvm-exits_test.bats
  • hack/run-kubernetes-node-join_test.bats

📝 Walkthrough

Walkthrough

The change adds bounded sandbox KVM-exit collection through debugfs. It records counter metadata, warnings, timestamps, and partial results. Tenant node-join diagnostics now capture samples before and after the wait on success and failure paths.

Changes

Sandbox KVM diagnostics

Layer / File(s) Summary
KVM counter probe
hack/e2e-chainsaw/_lib/run-kubernetes.sh
The generated probe scans debugfs KVM counters, distinguishes global and per-VM values, skips unsupported files, and reports malformed or unreadable files.
Bounded KVM capture
hack/e2e-chainsaw/_lib/run-kubernetes.sh, hack/run-kubernetes-kvm-exits_test.bats
cozy_capture_sandbox_kvm_exits isolates debugfs access, bounds reads, records timestamps and metadata, and reports missing or partial readings. Bats tests cover mounts, counter classifications, timeouts, failures, warnings, and output paths.
Node-join sampling and validation
hack/e2e-chainsaw/_lib/run-kubernetes.sh, hack/run-kubernetes-node-join_test.bats
Node-join diagnostics capture KVM samples around the wait on success and failure paths. Tests validate ordering, warnings, collector stubbing, timeout handling, and budget accounting.

Estimated code review effort: 4 (Complex) | ~60 minutes

Suggested reviewers: kvaps

Sequence Diagram(s)

sequenceDiagram
  participant node_join
  participant cozy_capture_sandbox_kvm_exits
  participant tenant_node
  node_join->>cozy_capture_sandbox_kvm_exits: capture baseline KVM sample
  node_join->>tenant_node: wait for node join
  tenant_node-->>node_join: return success or failure
  node_join->>cozy_capture_sandbox_kvm_exits: capture final KVM sample
  cozy_capture_sandbox_kvm_exits-->>node_join: return reading status
Loading
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/e2e-nodejoin-kvm-exits

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added area/testing Issues or PRs related to testing (e2e, bats, unit tests) size/XXL This PR changes 1000+ lines, ignoring generated files labels Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/testing Issues or PRs related to testing (e2e, bats, unit tests) size/XXL This PR changes 1000+ lines, ignoring generated files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant