You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
An ephemeral runner (v2.337.0, ARC gha-runner-scale-set 0.14.2) receives a job, logs Acknowledging runner request '<id>', reloads credentials, logs one AAD Correlation ID for this token request: Unknown line, and then does nothing, ever:
No Runner.Worker is spawned and no Worker_*.log is written.
No error or retry is logged. RunServer never logs Catch exception during request or Back off ..., so no exception was raised: the await on acquirejob simply never completes.
Runner.Listener stays alive (Sl, 0 % CPU) holding one ESTABLISHED, idle HTTPS connection. It does not resume long-polling the broker.
Exactly 10 minutes later GitHub fails the job with "The self-hosted runner lost communication with the server...". No step ever started.
The pod stays Running indefinitely (5.5 h for the oldest when we found it). ARC still counts it as a live replica, so each hung runner permanently removes one slot of capacity (see ARC impact below).
We hit this four times on 2026-09-02, in two pairs a few minutes apart within each pair. Dozens of other jobs on the same scale set ran normally before, between and after.
This is distinct from the existing "ghost ephemeral runner" reports, all of which get an HTTP error back from acquirejob and log it:
Here nothing comes back at all. Reading the client code (below), the request has no deadline on the response body and no TCP keepalive, so a connection that dies after headers but before the body completes can block forever. The same day, another ARC 0.14.2 user reported 25 runners wedged with the same last log line during ~40 min of packet loss to the Actions broker (actions/actions-runner-controller#4396, comment of 2026-09-02), which is consistent with a network event on the path being the trigger.
Normal job load. Two runners that acknowledge a request within a few minutes of each other hang at the same point; ~80 minutes later, two more.
GitHub fails the four jobs after 10 minutes; the four pods stay Running for hours.
Expected behavior
acquirejob should complete or fail within a bounded time (Settings.SendTimeout, default 100 s), covering the response body as well as the headers.
An ephemeral runner that has acknowledged a request and then cannot acquire it has no future. It should exit so the orchestrator can replace it, the way Exit ephemeral runners on broker acknowledge job-not-found #4540 already made it exit when the acknowledge itself returns job-not-found.
A Runner.Listener sitting for hours with no worker, no error and no broker polling is a hang, whatever the cause.
Runner Version and Platform
Runner: 2.337.0 (ghcr.io/actions/actions-runner:latest at the time), Linux x64 container. The relevant code is unchanged on main, so this is not specific to 2.337.0.
Pod: dind via native sidecar (docker:dind as initContainer with restartPolicy: Always), runner container privileged: true, ACTIONS_RUNNER_HOOK_JOB_STARTED set, RUNNER_WAIT_FOR_DOCKER_IN_SECONDS=120. The dind sidecar was healthy in every affected pod.
Auth: GitHub App, org-level (githubConfigUrl: https://github.com/<org>), runner group Default.
What's not working?
Occurrences (all 2026-09-02, UTC):
runner pod
acknowledged
GitHub failed job
pod still Running at
runner-c8bqp
14:11:37
14:21:49
19:40+
runner-x5swc
~14:15:37
~14:25
19:40+
runner-sjdqk
15:33:07
15:43:12
19:40+
runner-j69ht
15:39:58
15:50:03
19:40+
GitHub's annotation on all four jobs:
The self-hosted runner lost communication with the server. Verify the machine is running and has a healthy network connection. Anything in your workflow that terminates the runner process, starves it for CPU/Memory, or blocks its network access can cause this error.
ARC recorded JobStarted for each runner (the EphemeralRunner status has jobId, workflowRunId and jobRepositoryName filled in), so the broker did consider the job dispatched.
State inside runner-c8bqp about 5.5 h after the acknowledge:
$ ps -eo pid,etime,stat,args
PID ELAPSED STAT COMMAND
1 05:44:12 Ss /bin/bash /home/runner/run.sh
10 05:44:12 S /bin/bash /home/runner/run-helper.sh
45 05:44:12 Sl /home/runner/bin/Runner.Listener run
$ ls /home/runner/_diag
Runner_20260902-135814-utc.log # last write 14:11; no Worker_*.log exists
$ cat /proc/net/tcp6 # one ESTABLISHED connection to an Azure IP :443, nothing else
Impact on ARC
ARC's listener computes target = min(minRunners + TotalAssignedJobs, maxRunners). GitHub correctly stops counting the failed job, but the EphemeralRunnerSet still counts the hung pod as a live replica. With N hung pods, any job that arrives while assigned <= N never gets a runner because the target is "already met". With four hung pods and minRunners: 3 we had one job queued 46 min and another 60 min on a host that was 90 % idle. Both were only served when an unrelated burst pushed the target above the zombie count. Listener log during the second incident, repeating every ~50 s for an hour:
Empty. No step of any of the four jobs started; the only content is the annotation quoted above.
Runner and Worker's Diagnostic Logs
Complete tail of the runner container stdout for runner-c8bqp. Nothing follows for 5.5 h. The _diag/Runner_*.log ends at the same line. There is no Worker_*.log.
[RUNNER 2026-09-02 13:58:18Z INFO Terminal] WRITE LINE: Current runner version: '2.337.0'
[RUNNER 2026-09-02 13:58:18Z INFO Terminal] WRITE LINE: 2026-09-02 13:58:18Z: Listening for Jobs
[RUNNER 2026-09-02 13:58:18Z INFO JobDispatcher] Set runner/worker IPC timeout to 30 seconds.
[RUNNER 2026-09-02 14:11:37Z INFO BrokerMessageListener] Acknowledging runner request '1163817b-8f76-50d5-a595-c971db5244ea'.
[RUNNER 2026-09-02 14:11:41Z INFO ConfigurationStore] HasCredentials()
[RUNNER 2026-09-02 14:11:41Z INFO ConfigurationStore] stored True
[RUNNER 2026-09-02 14:11:41Z INFO CredentialManager] GetCredentialProvider
[RUNNER 2026-09-02 14:11:41Z INFO CredentialManager] Creating type OAuth
[RUNNER 2026-09-02 14:11:41Z INFO CredentialManager] Creating credential type: OAuth
[RUNNER 2026-09-02 14:11:41Z INFO RSAFileKeyManager] Loading RSA key parameters from file /home/runner/.credentials_rsaparams
[RUNNER 2026-09-02 14:11:41Z INFO RSAFileKeyManager] Loading RSA key parameters from file /home/runner/.credentials_rsaparams
[RUNNER 2026-09-02 14:11:41Z INFO RSAFileKeyManager] Loading RSA key parameters from file /home/runner/.credentials_rsaparams
[RUNNER 2026-09-02 14:11:41Z INFO GitHubActionsService] AAD Correlation ID for this token request: Unknown
The other three pods show the same lines with their own request IDs.
We still have live hung pods and can provide a dotnet-dump / createdump of Runner.Listener, the full _diag logs, or run a debug build, on request.
Analysis (actions/runner at tag v2.337.0, commit 397b032; same on main)
The broker message loop handles a run-service job request inline, so a stuck acquire freezes the whole listener: no further broker polling, no exit.
src/Runner.Listener/Runner.cs:
L697 AcknowledgeMessageAsync (5 s timeout) logs Acknowledging runner request. Seen.
L731-733 LoadCredentials(allowAuthUrlV2: true) + runServer.ConnectAsync(...). No HTTP.
L735 runServer.GetJobMessageAsync(...) issues POST .../acquirejob. This never returned.
L738-746 catch 404/409/422 and continue; L747-759 catch anything else, log Caught exception from acquiring job message and continue. Neither ran: nothing was logged.
L762 jobDispatcher.Run(...) never reached, hence no Runner.Worker.
GetJobMessageAsync (src/Runner.Common/RunServer.cs:63-72) wraps the call in RetryRequest (src/Runner.Common/RunnerService.cs:85-108), which logs Catch exception during request and Back off ... on any failure. Nothing was logged, so no exception was raised: the await never completed.
Why the await can never complete: the request timeout covers the headers but not the body.
src/Sdk/WebApi/WebApi/RawHttpClientBase.cs:66: the constructor sets m_client.Timeout = TimeSpan.FromMilliseconds(-1.0) with the comment "Disable their timeout since we handle it ourselves".
RawHttpClientBase.cs:214-217, 245: requests are sent with HttpCompletionOption.ResponseContentRead, so HttpClient.SendAsync buffers the whole body before returning, bounded only by that infinite timeout and the caller's cancellation token.
The timeout "handled ourselves" lives in src/Sdk/Common/Common/RawHttpMessageHandler.cs: L141-143 tokenSource.CancelAfter(Settings.SendTimeout) (100 s by default, src/Runner.Sdk/Util/VssUtil.cs:79-86) applies to m_tokenProvider.GetTokenAsync (L159, which emitted the final AAD Correlation ID log line) and to m_messageInvoker.SendAsync(request, tokenSource.Token) (L171). A message handler returns as soon as the response headers arrive; the finally at L231 then disposes the token source and its timer. The body is read afterwards by HttpClient, outside that timer.
The caller's token, messageQueueLoopTokenSource.Token, is only cancelled on shutdown or session restart. So the body read has no deadline at all.
No TCP keepalive is configured on this client. The only keepalive code in the tree (src/Sdk/WebApi/WebApi/VssHttpClientBase.cs:1153-1163) goes through ServicePointManager.SetTcpKeepAlive, which is a no-op for SocketsHttpHandler on .NET 8 (the runner targets net8.0), and RawHttpClientBase, the client used for acquirejob, does not call it anyway.
Put together: if the connection carrying the acquirejob response dies after the status line and headers but before the body (the full job message, often tens to hundreds of KB) is complete, and the peer's RST/FIN never reaches the runner (half-open connection: middlebox or LB state loss, NAT table churn, path outage), the read blocks forever. No timer exists to cancel it and no keepalive probe exists to detect the dead peer, so the ephemeral runner sits with one ESTABLISHED idle socket and never exits. That is exactly the process state we observed. Two runners hanging within minutes of each other, twice in one day, fits a transient network event rather than a per-job trigger. We have not yet confirmed the blocked frame with a dump; happy to do so.
VssHttpClientBase has the same infinite m_client.Timeout at L76, so the broker long-poll client has the same exposure.
Workaround we deployed
A CronJob deletes any EphemeralRunner in phase Running with status.jobId set, pod age > 12 min, and no Runner.Worker process in the runner container on two consecutive checks. Deleting the EphemeralRunner makes ARC deregister the runner and backfill the slot. Others in actions/actions-runner-controller#4307 use activeDeadlineSeconds on the pod or a log-grepping liveness probe; neither catches this variant, since nothing is logged.
Describe the bug
An ephemeral runner (v2.337.0, ARC
gha-runner-scale-set0.14.2) receives a job, logsAcknowledging runner request '<id>', reloads credentials, logs oneAAD Correlation ID for this token request: Unknownline, and then does nothing, ever:Runner.Workeris spawned and noWorker_*.logis written.RunServernever logsCatch exception during requestorBack off ..., so no exception was raised: theawaitonacquirejobsimply never completes.Runner.Listenerstays alive (Sl, 0 % CPU) holding one ESTABLISHED, idle HTTPS connection. It does not resume long-polling the broker.Runningindefinitely (5.5 h for the oldest when we found it). ARC still counts it as a live replica, so each hung runner permanently removes one slot of capacity (see ARC impact below).We hit this four times on 2026-09-02, in two pairs a few minutes apart within each pair. Dozens of other jobs on the same scale set ran normally before, between and after.
This is distinct from the existing "ghost ephemeral runner" reports, all of which get an HTTP error back from
acquirejoband log it:acquirejobreturns 409, runner logsSkipping message Jobandcontinues.acquirejobreturns 404 after a cancellation (job was canceled), samecontinue.Here nothing comes back at all. Reading the client code (below), the request has no deadline on the response body and no TCP keepalive, so a connection that dies after headers but before the body completes can block forever. The same day, another ARC 0.14.2 user reported 25 runners wedged with the same last log line during ~40 min of packet loss to the Actions broker (actions/actions-runner-controller#4396, comment of 2026-09-02), which is consistent with a network event on the path being the trigger.
To Reproduce
Not deterministic. Observed setup:
minRunners: 3,maxRunners: 14), ephemeral runners, dind sidecar, GitHub App auth.Runningfor hours.Expected behavior
acquirejobshould complete or fail within a bounded time (Settings.SendTimeout, default 100 s), covering the response body as well as the headers.Runner.Listenersitting for hours with no worker, no error and no broker polling is a hang, whatever the cause.Runner Version and Platform
2.337.0(ghcr.io/actions/actions-runner:latestat the time), Linux x64 container. The relevant code is unchanged onmain, so this is not specific to 2.337.0.gha-runner-scale-set0.14.2, controller 0.14.2docker:dindasinitContainerwithrestartPolicy: Always), runner containerprivileged: true,ACTIONS_RUNNER_HOOK_JOB_STARTEDset,RUNNER_WAIT_FOR_DOCKER_IN_SECONDS=120. The dind sidecar was healthy in every affected pod.githubConfigUrl: https://github.com/<org>), runner group Default.What's not working?
Occurrences (all 2026-09-02, UTC):
GitHub's annotation on all four jobs:
ARC recorded
JobStartedfor each runner (the EphemeralRunner status hasjobId,workflowRunIdandjobRepositoryNamefilled in), so the broker did consider the job dispatched.State inside
runner-c8bqpabout 5.5 h after the acknowledge:Impact on ARC
ARC's listener computes
target = min(minRunners + TotalAssignedJobs, maxRunners). GitHub correctly stops counting the failed job, but the EphemeralRunnerSet still counts the hung pod as a live replica. With N hung pods, any job that arrives whileassigned <= Nnever gets a runner because the target is "already met". With four hung pods andminRunners: 3we had one job queued 46 min and another 60 min on a host that was 90 % idle. Both were only served when an unrelated burst pushed the target above the zombie count. Listener log during the second incident, repeating every ~50 s for an hour:Job Log Output
Empty. No step of any of the four jobs started; the only content is the annotation quoted above.
Runner and Worker's Diagnostic Logs
Complete tail of the runner container stdout for
runner-c8bqp. Nothing follows for 5.5 h. The_diag/Runner_*.logends at the same line. There is noWorker_*.log.The other three pods show the same lines with their own request IDs.
We still have live hung pods and can provide a
dotnet-dump/createdumpofRunner.Listener, the full_diaglogs, or run a debug build, on request.Analysis (actions/runner at tag v2.337.0, commit 397b032; same on
main)The broker message loop handles a run-service job request inline, so a stuck acquire freezes the whole listener: no further broker polling, no exit.
src/Runner.Listener/Runner.cs:AcknowledgeMessageAsync(5 s timeout) logsAcknowledging runner request. Seen.LoadCredentials(allowAuthUrlV2: true)+runServer.ConnectAsync(...). No HTTP.runServer.GetJobMessageAsync(...)issuesPOST .../acquirejob. This never returned.continue; L747-759 catch anything else, logCaught exception from acquiring job messageandcontinue. Neither ran: nothing was logged.jobDispatcher.Run(...)never reached, hence noRunner.Worker.GetJobMessageAsync(src/Runner.Common/RunServer.cs:63-72) wraps the call inRetryRequest(src/Runner.Common/RunnerService.cs:85-108), which logsCatch exception during requestandBack off ...on any failure. Nothing was logged, so no exception was raised: theawaitnever completed.Why the await can never complete: the request timeout covers the headers but not the body.
src/Sdk/WebApi/WebApi/RawHttpClientBase.cs:66: the constructor setsm_client.Timeout = TimeSpan.FromMilliseconds(-1.0)with the comment "Disable their timeout since we handle it ourselves".RawHttpClientBase.cs:214-217, 245: requests are sent withHttpCompletionOption.ResponseContentRead, soHttpClient.SendAsyncbuffers the whole body before returning, bounded only by that infinite timeout and the caller's cancellation token.src/Sdk/Common/Common/RawHttpMessageHandler.cs: L141-143tokenSource.CancelAfter(Settings.SendTimeout)(100 s by default,src/Runner.Sdk/Util/VssUtil.cs:79-86) applies tom_tokenProvider.GetTokenAsync(L159, which emitted the finalAAD Correlation IDlog line) and tom_messageInvoker.SendAsync(request, tokenSource.Token)(L171). A message handler returns as soon as the response headers arrive; thefinallyat L231 then disposes the token source and its timer. The body is read afterwards byHttpClient, outside that timer.messageQueueLoopTokenSource.Token, is only cancelled on shutdown or session restart. So the body read has no deadline at all.src/Sdk/WebApi/WebApi/VssHttpClientBase.cs:1153-1163) goes throughServicePointManager.SetTcpKeepAlive, which is a no-op forSocketsHttpHandleron .NET 8 (the runner targetsnet8.0), andRawHttpClientBase, the client used foracquirejob, does not call it anyway.Put together: if the connection carrying the
acquirejobresponse dies after the status line and headers but before the body (the full job message, often tens to hundreds of KB) is complete, and the peer's RST/FIN never reaches the runner (half-open connection: middlebox or LB state loss, NAT table churn, path outage), the read blocks forever. No timer exists to cancel it and no keepalive probe exists to detect the dead peer, so the ephemeral runner sits with one ESTABLISHED idle socket and never exits. That is exactly the process state we observed. Two runners hanging within minutes of each other, twice in one day, fits a transient network event rather than a per-job trigger. We have not yet confirmed the blocked frame with a dump; happy to do so.VssHttpClientBasehas the same infinitem_client.Timeoutat L76, so the broker long-poll client has the same exposure.Workaround we deployed
A CronJob deletes any
EphemeralRunnerin phase Running withstatus.jobIdset, pod age > 12 min, and noRunner.Workerprocess in the runner container on two consecutive checks. Deleting the EphemeralRunner makes ARC deregister the runner and backfill the slot. Others in actions/actions-runner-controller#4307 useactiveDeadlineSecondson the pod or a log-grepping liveness probe; neither catches this variant, since nothing is logged.