Skip to content

Scale-set message session emits JobCompleted for a job whose execution lease is still valid → ephemeral runner deleted mid-job #4492

Description

@jzillmann

We drive the github.com/actions/scaleset listener directly from a small Go service that manages ephemeral GCE VMs (no ARC controller, no Kubernetes). This is a fresh report against the standalone listener module, not the ARC chart.

Relation to #3748: same symptom ("runner registration deleted in the middle of a running job"), but a different codebase. #3748 was an ARC controller-side scale-down race, fixed in the gha-runner-scale-set Helm chart 0.12.1 (July 2025). We don't run that controller; our scale-down path is separate and already guards busy runners via JobStarted. Filing fresh per @nikola-jokic's standing invitation on #3748 ("if you experience this again, please open a new one … with the fresh log").

Edit (after deeper forensics): I originally framed this as "our listener deleting the VM is what killed the runner." I'm downgrading that mechanism claim — see What I can and cannot prove below. The solid, source-independent fact is unchanged and is the point of this report: the scale-set service emitted JobCompleted for a job whose own job-execution lease GitHub had just renewed 10 minutes into the future, while the worker was still streaming console output.

Listener / version

  • github.com/actions/scaleset v0.4.0 — latest release of the standalone listener Go module (v0.1.0 2026-02-09 → v0.4.0 2026-05-05). Separate artifact from the gha-runner-scale-set ARC chart; their version numbers are unrelated, so "0.4.0 vs 0.12.1" is not a meaningful pre/post-fix comparison.
  • Ephemeral GCE VMs, one job per VM, on-demand c3-standard-8 (NOT spot/preemptible, OnHostMaintenance=MIGRATE). One listener per runs-on label.

Symptom

A long-running job (~40–50 min, full E2E corpus, single scale-set runner) is killed mid-job on ~5 of 6 runs: the runner logs The runner has received a shutdown signal and the job ends exit 143 / "The operation was canceled." Short jobs are never affected.

The core fact — GitHub completed a job that was provably still running

Captured the runner's _diag (listener + worker) live to a death-surviving step log, correlated with our scale-manager's message log and GCE audit. One occurrence — runner runner-7ab674d98c0cbdee, job f8f508a5-7a9c-5cfc-99af-f684f6a5e93d:

Job-execution channel — lease renewed every 60s, monotonically, to the end:

[Runner _diag, JobDispatcher]   (all timestamps VM clock, UTC)
15:30:57Z  Successfully renew job f8f508a5-…  valid till 15:40:57
15:31:57Z  Successfully renew job f8f508a5-…  valid till 15:41:57
15:32:57Z  Successfully renew job f8f508a5-…  valid till 15:42:57   ← 10 min of headroom
[Worker _diag, JobServerQueue]
15:33:19Z  append web console lines … success 1/1   ← worker streaming test 664/1058
15:33:20.4Z ##[error] The runner has received a shutdown signal. … Terminated. The operation was canceled.

Scale-set message channel — reports the SAME job complete:

[our scale-manager, decoding the RunnerScaleSetJobMessages queue]   (GCP clock, UTC)
15:33:26.18Z  JobCompleted  jobId=f8f508a5-…  runner=runner-7ab674d98c0cbdee
15:33:26.48Z  compute.instances.delete submitted   (GCE audit: 15:33:26.23Z, manager SA)

So the message session delivered JobCompleted for f8f508a5 while the job-execution service had, 29 s earlier, renewed that same job's lease to 15:42:57 and the worker was still appending console output. The scale-set control plane reported a job complete that its own job-execution service still held under a valid, freshly-renewed lease. That is the bug we're reporting.

What I can and cannot prove (mechanism)

  • Proven: lease valid to 15:42:57; worker alive and streaming at 15:33:19; memory healthy (canary: 5.6 GB used / 26 GB free of 32 GB, 0 swap, flat all run — not OOM); zero GCE system_event (not preemption/maintenance); job timeout is 120 min (not a timeout); no GitHub job-cancellation message in the runner _diag (no "Job cancellation request received", no hosted-runner-shutdown, no Ctrl-C — just a raw process SIGTERM); no concurrency/matrix cancel in the workflow; no scale-down/deregister of this runner in our logs.
  • NOT proven — the kill mechanism / ordering. The runner's shutdown signal is at 15:33:20.4 (VM clock); our queue-driven compute.instances.delete is at 15:33:26 (GCP clock) — i.e. the runner was signalled ~6 s before our delete. Our delete can only be the cause if the VM clock lagged GCP by ≥6 s (GCE NTP is normally sub-second, so that would be abnormal; the VM is gone, so I can't measure the offset). The alternative is that GitHub signalled the runner directly (~concurrent with the queue's JobCompleted), making our VM delete a redundant downstream cleanup of an already-dead runner. I no longer claim which; either way GitHub originated a "this job is done" decision on a live, leased job.

Questions

  1. What can cause the scale-set message session to emit JobCompleted for a job the job-execution service still holds under a valid, recently-renewed lease? Per-session/token timeout, message redelivery after a session refresh, or server-side reaping of long-in-flight jobs that doesn't consult the lease? We only see it on jobs running longer than ~30–40 min — does a long-lived message session cross an internal lifetime boundary?
  2. For such a JobCompleted, what result does the service set (succeeded / canceled / …), and what runnerRequestId / finishTime? I want to confirm whether this is a stale/reassigned message (e.g. an assigned-but-not-acquired/requeued attempt, per the scaleset README) vs a genuine completion of the live request. (We're adding logging of the full JobCompleted payload so the next occurrence is unambiguous.)
  3. Is a listener expected to cross-check the runner's actual busy/lease state before acting on a JobCompleted, or is the message authoritative?

Repro / impact / mitigation

~Every run of a single ~50-min job on one scale-set runner; short jobs never. Mitigation we're deploying: shard the long job into shorter jobs so no single message session lives long enough to hit the divergence.

Happy to provide the full _diag capture, the scale-manager message log, and the GCE audit lines.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions