fix(scheduler): never destroy a runner that is executing a job - #151
Open
luthermonson wants to merge 1 commit into
Open
fix(scheduler): never destroy a runner that is executing a job#151luthermonson wants to merge 1 commit into
luthermonson wants to merge 1 commit into
Conversation
The orphan sweep could retire a runner mid-build. Observed 2026-08-12:
three same-label `dind-test` jobs dispatched concurrently, and a live
build torn down under it.
"Unbound" was inferred from webhooks and never verified. A runner only
looks busy once `handleInProgress` processes an `in_progress` delivery
naming it; until then it looks idle no matter what it is running. That
is not a dropped-delivery edge case, it is the normal shape of a
same-label burst:
1. Three same-label jobs queue; three JIT runners are dispatched, so
every job is `served` and class demand is 0.
2. GitHub permutes the assignments and all three runners start work.
3. The first `in_progress` processed sets `started[A]` — which is the
discharge signal for the runner dispatched FOR A, not for the
runner GitHub gave A to.
4. That runner is now unbound + discharged + in a zero-demand class:
rule 2, which fires immediately with no grace window.
5. `handleCompleted` sweeps on every completion, so a sweep lands in
that window routinely.
The window is the inter-delivery skew between two `in_progress`
webhooks. Nothing has to be dropped or reordered. Rule 1 kills the same
runner more slowly whenever a delivery genuinely is lost.
Both rules now only NOMINATE. Teardown is gated on a busy check taken at
the moment of reaping, from ground truth rather than event history:
1. Local introspection for the runner's worker process. The
actions-runner forks `Runner.Worker` only while a job is executing
(the listener is always alive, so process-exists is not the
signal). Linux: containerd task PIDs read via /proc. Windows: HCS
`ProcessList` — Hyper-V isolated guests are invisible to the host
process table. macOS VM: pgrep over the existing per-job SSH
channel. Native macOS: pgrep against the runner's process group.
Dispatched into the Linux sidecar VM: explicitly unavailable.
2. GitHub's per-runner `busy` flag, only for nominations the local
probe could not answer. One GET per nomination, off every hot path.
3. Unknown — which means possibly busy, never idle.
The probes run with the scheduler mutex released, so `reapRunnerLocked`
re-validates the ledger entry before unhooking: a runner bound by a
webhook that landed during the probe survives its nomination.
Two bounded escapes keep a wedged runner from squatting a slot forever,
both logged loudly and counted in
`ephemerd_orphan_reap_decisions_total{outcome="escaped"}`: an
undeterminable state escapes at the grace window (exactly the pre-veto
behaviour, so no platform is worse off than before), and a positive busy
verdict escapes at `job_timeout + 30m`. Both are measured in elapsed
time rather than consecutive failed probes — the sweep runs on every
completion, so a probe-count escape would fire in milliseconds during
the very burst this change exists to survive.
The decision is a pure function (`decideReap`) with a table test; the
probes, the provider fallback, the fail-safe unknown paths and the
bind-during-probe race have their own tests.
`orphan_grace` stops being load-bearing: it now only governs how long an
undeterminable runner is held, so the per-pool tuning it accumulated is
no longer needed on any pool whose runners are locally probeable.
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.
The bug
The orphan sweep could destroy a runner that was actively executing a build.
Observed 2026-08-12 on
linux-amd64(coyotes): threedind-testjobs withidentical labels dispatched concurrently, and a live build torn down under it.
"Unbound" was inferred from webhooks and never verified.
runnerBinding.boundflips only when
handleInProgressprocesses anin_progressdelivery naming thatrunner. Until then the runner looks idle in the ledger regardless of what it is
actually running.
That is not a dropped-delivery edge case. It is the normal shape of a same-label
burst, and rule 2 fires immediately, with no grace window:
(intent keys A, B, C). All three intent keys land in
served, so class demand is 0.start executing.
in_progressdeliveries arrive over three separate HTTP requests and areprocessed in whatever order they land. The first one processed (job A, naming rC)
sets
started[A]and binds rC.started[A]is the discharge signal for rA (intent key A), not for the runnerGitHub actually gave A to. rA is now unbound + discharged + zero class demand.
That is rule 2 exactly, and it retires immediately.
handleCompletedcallssweepOrphanRunnerson every completion, so a sweeplands in that window routinely.
rA was executing job B. The window is just the inter-delivery skew between two
in_progresswebhooks of the same burst — hundreds of milliseconds is enough, andnothing has to be dropped, delayed or reordered. Rule 1 kills the same runner more
slowly whenever a delivery genuinely is lost.
This confirms the original analysis and sharpens it: reproducing it needs no missing
webhook at all.
The fix: the rules nominate, a busy check vetoes
The nomination logic is unchanged — rule 1, rule 2, the demand counting, the
observed-assignment teardown keying,
reapRunnerLocked's staleness bail. All of itstays. What changes is that it no longer has authority.
Both rules now produce nominations. Nothing is unhooked until a check taken at the
moment of teardown, from ground truth rather than event history, confirms no job is
executing. Hard invariant: never destroy a runner that is executing a job.
1. Local introspection (primary) —
pkg/runnerbusyThe actions-runner forks a
Runner.Workerchild only while a job is executing. Thelistener is alive for the runner's whole life, so "a runner process exists" is not the
signal; the worker is.
task.Pids()thenargv[0](fallbackcomm) from/prochcsshimProcessList()thenImageNamepkg/metricsalready opens per containerpgrep -x Runner.Workerover the existing per-job SSH channelpgrep -g <pgid> -x Runner.WorkerEvery failure mode resolves to
Unknown: task query failed, empty process list, no listedPID readable under
/proc, no probe on this platform.State's zero value isUnknown,so nothing can accidentally read as idle.
2. GitHub
busyflag (secondary)providers.RunnerBusyReporter, implemented by the GitHub provider overGET .../actions/runners/{id}(org- or repo-scoped, matching how the JIT runner wasregistered). Consulted only when the local probe cannot answer, and only for a
runner already nominated — one GET per nomination, off every hot path. A 404 reads as
not-busy (an ephemeral runner deregisters itself when its job ends); every other error
is an error, which fails safe.
3. Race handling
The probes do I/O, so they run with
s.mureleased. That opens a window for anin_progressdelivery to bind a nominated runner, soreapRunnerLockednowre-validates — same
*runnerBinding, still unbound — before unhooking anything.A runner bound during the probe survives its own nomination. Covered by
TestSweepOrphanRunners_BindingRaceDuringProbe.Escape hatches
A veto that could never be overridden trades one leak for another. Two bounds, both
logged at warn with an
ESCAPE:prefix and counted inephemerd_orphan_reap_decisions_total{outcome="escaped"}:whole grace window, teardown proceeds. This is exactly the pre-veto behaviour, so no
platform is worse off than before, and rule 1 nominations (already past the window)
keep their original timing where no probe exists.
job_timeout + 30m(or GitHub's 6h per-job ceiling + 30m when no job timeout is set).A job exceeding
job_timeouthas already had its context cancelled and its runner torndown by the normal path, so a runner still claiming busy out there is wedged, not working.
Why time and not consecutive failed probes. The sweep runs on every job completion as
well as on a timer. A probe-count escape would fire within milliseconds on a busy node —
during exactly the same-label burst this change exists to survive — and effectively never
on a quiet one. It is backwards: loosest precisely when the risk is highest. Wedged-ness
is a property of duration, so duration is what bounds it. The verdict asymmetry does the
rest: a definite busy answer is held to the strong bound, an undeterminable one only
to the weak one.
orphan_gracecan now be relaxed (recommendation only — fleet config untouched)orphan_graceexisted to paper over exactly this uncertainty: too short killed live work,too long squatted a concurrency slot (90 minutes of one on a
max_concurrent = 1host).It is no longer load-bearing — it now only governs how long an undeterminable runner is
held.
For pools whose runners are locally probeable (every Linux and Windows pool, plus macOS-VM
jobs), the tuned values can be dropped and the 10m default left alone:
pools.linux-arm64.orphan_grace = "2m"— the stopgap for the mac double-claiming arm64.The veto answers that case directly ("is the loser actually busy?" then no, immediately),
so this can be dropped.
pools.mac-arm64.orphan_grace = "15m"— same reasoning for macOS-VM jobs, which areSSH-probeable. Droppable.
Linux-sidecar-VM path and unable to reach the GitHub API, where the fallback bound
applies. Both are off in the current fleet (
[pools.mac-arm64.vm.linux] enabled = false).The fleet config is deliberately not changed here.
Tests
decideReapis a pure function (repo idiom:imagegc.PlanEviction,controlPlaneInputRules) with a full matrix: busy never reaped inside or past thegrace window, verified-idle reaped immediately, unknown treated as busy, each escape
firing only past its own bound.
TestReapPolicypins the bound derivation.TestSweepOrphanRunners_BusyVetoruns the same matrix end-to-end through the real sweep.TestSweepOrphanRunners_BindingRaceDuringProbepins the bind-during-probe race.TestProbeLocalBusy_UnavailablePathsAreUnknownpins that no unavailable path canreturn idle.
TestProbeRunnerBusy_FallsBackToProviderpins the layering and its fail-safe.pkg/runnerbusy:IsWorkerProcessacross Linux argv0 / comm / Windows ImageName, withRunner.Listenerexplicitly not matching; the Linux probe end-to-end against a realprocess named
Runner.Worker; the Windows probe against a faked HCS process list; allthree Unknown failure modes on each.
pkg/github: org/repo scoping, 404-is-not-busy, error-is-not-an-answer.busy probe to verified-idle, because it is testing nomination; without that pin every
row would silently be testing the veto instead.
Conflict surface
Confined to
pkg/schedulerplus the newpkg/runnerbusy, with small additive changes topkg/providers,pkg/github,pkg/nativeandpkg/metrics. No overlap with #150(
pkg/imagegc,pkg/buildkit,pkg/dind,cmd/ephemerd/main.go,.github/workflows/dind-test.yml) — in particularcmd/ephemerd/main.gois untouched:the prober is built from existing scheduler config.
Metal: the failure, reconstructed
Reproduced from
mfl-linux-amd64-100(coyotes)'s own journal — this is the incident, not amodel of it. dind-test run
31651684484, three same-label jobs,docker-build= job94297174551:GitHub annotation on
docker-build: "The self-hosted runner lost communication with theserver."
warm_ridewas the runner GitHub had givendocker-buildto. At 23:39:48 it was unbound(its
in_progresswas still in flight), discharged (started[556]was set 6.4s earlier bya sibling's
in_progress), and its class had zero demand — rule 2 exactly. It wasdestroyed 2.3 seconds before the webhook that would have bound it. No delivery was
dropped, delayed or reordered.
Metal: the fix, proven
A test build of this branch (
v0.1.9-151veto, built on the node itself from the publicbranch tarball) was deployed to the same host, with
orphan_gracetemporarily set to5sso the sweep's rules would nominate aggressively, then driven with a sustained stream of
same-label
dind-testjobs.Three runners were nominated for teardown while executing a job, and every one was
vetoed by the local container probe:
Each is the identical race that killed
warm_ride: nominated a fraction of a second beforethe
in_progressthat would have protected it. All three survived; all three jobs finishedsuccessfully.
The veto is not a blanket. In the same window a genuinely idle runner was still reaped,
because the probe positively observed no worker in it:
Every dind-test run in the exercise passed. The node was then restored to its previous
binary and config, and a further
dind-testonmaincompleted green.One honest limit
sharp_adawas reaped as idle at 01:21:01.433 and GitHub'sin_progressfor it arrived1.37s later — GitHub had assigned it a job, but the runner had not yet forked a worker, so
the probe correctly answered "idle". The self-heal handled it (
abandoning dispatch: job was observed running while this dispatch waited for a concurrency slot) and no job waslost, but it is worth stating plainly: the busy check closes the window to the sub-second
gap between "GitHub assigns" and "the worker spawns", not to zero. Covering that
remainder is what the grace window is for, and it only came into play here because the test
had
orphan_gracecranked down to 5 seconds — three orders of magnitude below the 10mdefault.