Repository navigation
Take turns with host maintenance through a per-Job lock - #1187
Merged
epompeii merged 1 commit intoOct 10, 2026
Merged
Conversation
The runner takes /run/bencher/job.lock, non-blocking, before each Ready and holds it through the poll and any Job, releasing it on NoJob, Update, a receive timeout, a lost connection, a Running that fails to send, and when the Job ends. While another process holds the lock, or a marker named maintenance or maintenance.<name> exists, it sends Paused and leaves the lock free. runner run waits for the lock, and warns but runs through a RAID sync or a marker. The directory is now root's alone (0700), and the lock files the runner creates in it are 0600. The directory, lock, and marker names and the marker check are public in bencher_runner::maintenance, outside the plus feature, so the host tooling shares them.
epompeii
added this pull request to stack #1188
October 9, 2026 22:34
epompeii
marked this pull request as ready for review
October 9, 2026 23:01
Contributor
|
| Project | Bencher |
| Branch | u/ep/runner-protocol/job-lock |
| Testbed | intel-v1 |
Click to view all benchmark results
| Benchmark | Latency | Benchmark Result microseconds (µs) (Result Δ%) | Upper Boundary microseconds (µs) (Limit %) |
|---|---|---|---|
| Adapter::Json | 📈 view plot 🚷 view threshold | 5.21 µs(+0.49%)Baseline: 5.18 µs | 5.93 µs (87.78%) |
| Adapter::Magic (JSON) | 📈 view plot 🚷 view threshold | 5.02 µs(+0.53%)Baseline: 4.99 µs | 5.62 µs (89.39%) |
| Adapter::Magic (Rust) | 📈 view plot 🚷 view threshold | 24.19 µs(-9.80%)Baseline: 26.82 µs | 31.37 µs (77.12%) |
| Adapter::Rust | 📈 view plot 🚷 view threshold | 4.48 µs(-1.95%)Baseline: 4.57 µs | 6.05 µs (73.95%) |
| Adapter::RustBench | 📈 view plot 🚷 view threshold | 4.46 µs(-2.20%)Baseline: 4.56 µs | 6.05 µs (73.70%) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
Make the runner and its host's maintenance take turns through a lock and markers in
/run/bencher, so maintenance never runs during a Job, and the runner takes no Job while maintenance waits or runs./run/bencher/job.lock. Before eachready, the probe takes it with a nonblockingflockon a close-on-exec descriptor, and the runner holds it through the poll and any Job the poll brings. It is released onno_job,update, a receive timeout, a lost connection, arunningthat fails to send, and when the Job ends, before its result goes out./run/benchernamedmaintenanceormaintenance.<name>. Maintenance creates its own before it waits for the lock and removes it when done.pausedwith amaintenancereason andmarkertrue, and leaves the lock free;maintenancereason withlocktrue;maintenancemarker drains a runner. It finishes its current poll and any Job that poll brings, then takes no Job until the marker is gone. A unit uses a marker of its own, so it never removes the drain.runner runwaits for the job lock, trying it every 250 ms and loggingWaiting for host maintenance to finishonce, and a stop ends the wait. It warns but runs through a RAID sync (Running during a RAID sync) or a marker (Running although host maintenance is due)./run/bencheris root's alone: the runner now creates it 0700 rather than 0755, for the runner lock and the job lock alike, and each lock file 0600. A directory an older runner created keeps its mode until a reboot or a drop-in'sinstall -d -m 0700.runner upwarns once at startup that maintenance may run during a Job. A root runner that cannot open or take the lock pauses withlocktrue rather than run unguarded.bencher_runner::maintenance, public and outside theplusfeature, holds the directory, lock, and marker names andmarker_present, which the probe andrunner runboth use, so host tooling can share them.fstrimdrop-in, and how to drain a Runner. The changelog gains a line.Across versions:
paused, and the runner reconnects no faster than once per poll.Why
Package upgrades,
fstrim, and the monthly RAID check run on timers that know nothing about Jobs, so any of them can land during a Job and move its measurement.The runner takes the lock before
readyrather than when a Job arrives. Taken when a Job arrives, the lock could already be maintenance's, which would hold a Job the server has handed out while that Job's clock ran. Taken beforeready, maintenance waits at most one poll plus one Job.A waiting
flockholds nothing the runner can see, and on each release the runner's own nonblocking try can win the lock back. The marker tells the runner that maintenance waits, so it steps aside and the waiter gets the lock at the next release. Markers of their own let two units overlap, such as a scrub and the morning upgrade: with one shared marker, the first to finish would remove it while the second still waits, and any unit would remove an operator's drain.flock(2)locks whatever mode a file was opened in, so any user who can open the lock file can hold it and keep the runner paused. A root-only directory and 0600 files keep that to root. The descriptor is close-on-exec, so the jailer, the VMM, and every other child never keep the lock past the Job.runner runis started by hand at a moment someone chose, so it warns about a sync or a marker and runs, but it never runs beside maintenance that holds the lock. Trying the lock in a loop, rather than blocking inflock(2), lets a stop end the wait at once.Verification
flocktreats as two holders even in one process. Each new test fails under a mutation of the behavior it guards:a_free_lock_is_the_runners_turnanda_lock_held_elsewhere_pauses_for_maintenance: a turn that never takes the lock, and a held lock taken as free.a_marker_pauses_without_taking_the_lock, withmaintenanceandmaintenance.fstrim.service: a marker ignored, the lock kept past a marker (which starves waiting maintenance), and only the plain marker counted.only_the_maintenance_names_are_markers: any file, or any name startingmaintenance, read as a marker.dropping_the_turn_releases_the_lockanda_child_never_holds_the_lock: a leaked descriptor, and one that is not close-on-exec.the_lock_and_its_directory_are_root_only:job.lockat 0644 or 0640, and/run/bencherat 0755 or 0750.a_runner_that_is_not_root_takes_its_turn_without_the_lock: a runner that is not root failing closed, and a root runner running on without the lock.runner_run_waits_for_maintenance_then_takes_the_lockanda_stop_ends_the_wait_for_maintenance: arunner runthat does not wait, gives up once maintenance holds the lock, or keeps waiting past a stop.the_job_lock_is_held_from_ready_until_the_job_finishesandthe_job_lock_is_released_whenever_no_job_follows: a release when the Job arrives, none when it ends, and none onno_job,update, a receive timeout, a lost connection, or a failedrunningsend.the_job_lock_is_kept_only_by_a_clear_probe: the lock kept through a pause, or dropped by a clear probe.the_driver_releases_the_job_lock_when_told: a lock the probe took must be free to a second open after the driver runs the release. Kills a driver that ignores it, which would hold maintenance off for a whole server outage, since no probe runs until a connect succeeds.runner upat a 5 s poll ran against a local API, with/run/bencherabsent as after a boot:/run/bencher0700 andjob.lock0600.markerandlocktrue. A second Job, submitted during the first, was claimed after the maintenance.systemctl kill -s KILLof such a unit, while it waited and while it ran, left the unitfailedand its marker gone, and the runner's next probe ended its pause.touch /run/bencher/maintenancepaused the runner once its current poll ended, withmarkertrue andlockfalse. A Job submitted then readpendingfor 30 s and was claimed within 2 s of therm.runner runbehind a held lock, in the same VM, sandboxed with Firecracker, while another process heldjob.lockfor 20 s:Waiting for host maintenance to finishonce, started no VM until the release, then ran its benchmark.job.lockwas open only in the runner, and/proc/locksshowed the runner's pid holding it.runner_run_takes_its_turn_at_the_job_lockputs both halves ofrunner run's turn in CI, in about 7 s:job.lockand starts a sandboxedrunner run. The scenario requires oneWaiting for host maintenance to finish, then no jail for the 2 s until the release, then the Job. Once the VM is up, neither Firecracker nor the jailer it came from may hold the lock./run/bencher/maintenanceand runs the image on the host. The scenario requires oneRunning although host maintenance is due, no wait, and a successful Job whose benchmark, the runner's own child, lists no descriptor ofjob.lock. The host run carries the close-on-exec check, since the jailer closes every descriptor it inherits.runner runnever waits, when its descriptor is not close-on-exec, and when a marker makes it refuse, wait, or run without the warning. It fails on the parent, which has no job lock, and passes 20 of 20 as root.runner_opstests pass, 86 of 86,cargo test-runner scenarios --build-onlypasses,Cargo.lockis unchanged, and clippy passes with-D warningsfor Linux x86_64, Linux aarch64, and macOS on the runner, its CLI,test_runner, andrunner_ops. The scenario suite passes as root, 100 of 100, and clippy passes for the whole workspace as CI runs it, on the top of this stack.