Exit ephemeral runner when its job assignment is lost - #4618
Open
LouisCuvelier wants to merge 3 commits into
Open
Exit ephemeral runner when its job assignment is lost#4618LouisCuvelier wants to merge 3 commits into
LouisCuvelier wants to merge 3 commits into
Conversation
An ephemeral runner that failed to acquire an assigned job (404/409/422) returned to its message loop. The service consumes an ephemeral runner's registration when it assigns the job, so the runner stayed alive and deregistered, never to be assigned work again. Only a restart recovered it, and nothing surfaced the failure: the process kept reporting "Listening for Jobs". Mirror the existing handling of a lost acknowledge and exit with success. The finally block then deletes the session and the local config, so the supervising process can register a fresh runner. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The catch handles 404, 409 and 422, so test the ephemeral exit for all three rather than 409 alone, and add the persistent-runner counterpart asserting skip-and-retry still holds on the acquire path. Also stop logging "Skipping message Job." before a branch that exits instead of skipping. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
422 means the job is unprocessable, not that the assignment moved, so exiting on it would delete the local config of a runner that could still serve — and run-helper.sh reads exit 0 as final, so the host would never come back. Exit on 404 and 409 only. Set skipSessionDeletion before returning: the registration is already consumed, so DeleteSessionAsync stalls on a call the service rejects, and its failure escapes the finally whenever runOnce and settings.Ephemeral disagree. Write the reason to the terminal, since a trace-only exit leaves the operator with a bare exit 0. On the tests: park the message-loop mock instead of dequeuing an empty queue, so a regression surfaces as the assertion rather than "Queue empty"; name the host context per theory row, which otherwise overwrite each other's trace file; and cover the continue path for 422, for a persistent runner and for --once, pinning both predicates. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
Fixes #4617
Problem
When
GetJobMessageAsyncfails with 404 / 409 / 422,Runner.cslogsSkipping message Job.andcontinues back into the message loop. That is correct for a persistent runner.For an ephemeral runner it is not, on the two statuses that mean the assignment is gone: the service consumes the registration when it assigns the job, so there is no session left to listen on. The runner stays alive, deregistered, and is never assigned work again.
Nothing surfaces the failure. The process keeps printing
Listening for Jobs, the container reports healthy, and the runner is simply absent fromGET /orgs/{org}/actions/runners. On a pool of ephemeral runners each occurrence permanently removes one, so the pool drains to zero over hours and jobs queue with no error anywhere.Observed on runner
2.334.0(still present onmain):Nothing after that line. The process was still running 12 minutes later.
Change
On 404 and 409 for an ephemeral runner, exit instead of skipping, mirroring the handling #4540 already put in place ~40 lines above for a lost acknowledge. Setting
runOnceJobCompletedlets the existingfinallydelete the local config, so the supervising process (Docker restart policy, systemd, ARC) registers a fresh runner.422 is deliberately excluded.
Unprocessable jobsays the job cannot be processed, not that the assignment moved to another runner. Exiting there would delete.runnerand.credentials(ConfigurationManager.cs:496) on a runner that could still serve, andrun-helper.shreads exit 0 as final — the host would never come back, and could not re-register if it did.skipSessionDeletion = trueis set before returning: the registration is already consumed, soDeleteSessionAsyncwould block up to 30 s (MessageListener.cs:206) on a call the service is bound to reject. This mirrors the registration-gone handler at line 838. It also removes a failure mode —runOnce(line 331) andsettings.Ephemeralare read from different objects and can disagree after a server-pushed config migration, in which case thecatch (…) when (runOnce)at line 878 would not swallow the resulting 401 and the exception would escape thefinally.The exit also writes to the terminal. A trace-only exit shows the operator
Removed .credentials,Removed .runnerand a bare exit 0, indistinguishable from a completed job.Why
settings.Ephemeraland notrunOnce.runOncealso covers--onceandreturnJobResultForHosted.--onceis a client-side flag — the service does not consume the registration, so skip-and-retry stays correct there. #4540 scoped its fix the same way. A test row pins this.Not covered here
Three adjacent gaps found while reviewing, each needing its own change:
IActionsRunServeracquire path (emptyRunServiceUrl,Runner.cs:713-722) has no handling at all; a lost assignment there escapes toProgram.MainAsyncandrun.shrelaunches against a consumed registration.RunServiceHttpClient.cs:100-121only maps the three typed exceptions whenTryParseErrorBodysucceeds; a 409 with an unparsable body still falls through to the generic catch and the old skip.Successon a hosted runner reports 0 rather than a translated job result, so lost assignments raise no failure-code spike. The pre-existing acknowledge path at line 703 has the same shape.Tests
TestEphemeralRunnerJobRequestMessageFromRunServiceExitsOnLostJobAssignment— a[Theory]over 404 and 409. Asserts the runner exitsSuccess, never dispatches a job, never touches the acquire throttler, does not delete the session, and does delete the local config.TestRunnerJobRequestMessageFromRunServiceContinuesOnLostJobAssignment— the counterpart, over four rows: ephemeral + 422, persistent + 404, persistent + 409, and--once+ 409. Each asserts the runner keeps listening, throttles once, drains the message, and keeps its local config.Both are built on a shared arrange helper. Its message-loop mock parks on an empty queue instead of dequeuing, so a regression surfaces as the intended assertion rather than
InvalidOperationException: Queue empty, and each theory row names its ownTestHostContextso rows stop deleting each other's trace file.Verified the tests fail against the regressions they guard:
./dev.sh buildis clean and the full./dev.sh testsuite passes, 1131/1131.🤖 Generated with Claude Code