Cancelling a running job leaves the ephemeral runner executing it and blocks the queue #192

Closed
opened 2026-10-15 13:43:32 -04:00 by jfraeys · 1 comment
Owner

Found 2026-10-15, live. Cancelling a staging-validate run from the UI while it was running on an ephemeral runner did not stop it. The cancelled task kept executing on the runner VM and held the single runner, so every queued job waited behind it.

Timeline (provisioner journal on services, runner VM ci-runner-staging):

  • 13:34:10 provisioner reuses the VM for job 1191 (task 877, the staging validation); 13:34:18 the one-job runner starts it.
  • ~13:36 the run is cancelled in the UI.
  • 13:37:03 job 1191 finished with status=cancelled -> try_reuse_or_cleanup -> reusing VM for job id=1195. _reuse_vm_for_job deletes the previous runner registration and the cycle moves to registering.
  • From then on the provisioner only logs job 1195 still in progress (status=waiting) every tick. Job 1195 never starts.
  • On the VM (13:41, 433 s after start): the original forgejo-runner one-job process (PID 17173) is still alive with ./setup --workspace linode-staging and ansible-playbook playbooks/services.yml beneath it, and the runner log repeats ReportLog/ReportState error: unauthenticated: unregistered runner once a second.

Cause (from the code and the evidence above): try_reuse_or_cleanup treats Forgejo's job status cancelled as proof that the runner is idle, but a cancel only changes the server-side status; the one-job runner process keeps going. Reuse then deletes that runner's registration, so the process can no longer report or learn about the cancel (hence "unregistered runner"), and starting the one-job unit again is a no-op while the old process is alive, so the new registration is never served. The check that would catch this, runner_service_active(ip), is used for the re-run case in reconcile_running but not before reuse after a cancelled status. Related earlier fixes: the comments in reconcile_running (2026-08-27, 2026-09-19) describe the same family of problem.

Side effects: the workflow's Cleanup on failure step is if: failure(), so a cancelled staging run never destroys its web-staging/services-staging nodes (they were left running, billed); the task keeps deploying to them after the cancel.

Fix ideas:

  • In try_reuse_or_cleanup, when the finished job's status is cancelled (or any time runner_service_active is true), stop the runner unit on the VM (systemctl stop forgejo-runner-ephemeral, killing its process tree) before deleting the registration and reusing the VM; if it cannot be stopped, destroy the VM instead of reusing it.
  • Make the Start ephemeral runner (one-job) task restart rather than start when the unit is already active.
  • Test in roles/ci_ephemeral_provisioner/tests/test_runner_cleanup.py: a cancelled job whose runner is still active must not be reused with a live process.
  • Optionally add if: failure() || cancelled() to the staging workflow's cleanup step so a cancel also destroys the staging nodes (the runner has to survive long enough to run it; the stop above would prevent that, so decide which wins).

Workaround until fixed: do not cancel a running staging run; if you must, restart forgejo-runner-ephemeral.service on the runner VM afterwards, then check the Linode dashboard for leftover -staging nodes and ./infra unlock <id> if the state lock is held.

Related: #148 (runner cleanup), #121 (backlog).

**Found 2026-10-15, live.** Cancelling a staging-validate run from the UI while it was running on an ephemeral runner did not stop it. The cancelled task kept executing on the runner VM and held the single runner, so every queued job waited behind it. **Timeline (provisioner journal on `services`, runner VM `ci-runner-staging`):** - 13:34:10 provisioner reuses the VM for job 1191 (task 877, the staging validation); 13:34:18 the one-job runner starts it. - ~13:36 the run is cancelled in the UI. - 13:37:03 `job 1191 finished with status=cancelled` -> `try_reuse_or_cleanup` -> `reusing VM for job id=1195`. `_reuse_vm_for_job` deletes the previous runner registration and the cycle moves to `registering`. - From then on the provisioner only logs `job 1195 still in progress (status=waiting)` every tick. Job 1195 never starts. - On the VM (13:41, 433 s after start): the original `forgejo-runner one-job` process (PID 17173) is still alive with `./setup --workspace linode-staging` and `ansible-playbook playbooks/services.yml` beneath it, and the runner log repeats `ReportLog/ReportState error: unauthenticated: unregistered runner` once a second. **Cause (from the code and the evidence above):** `try_reuse_or_cleanup` treats Forgejo's job status `cancelled` as proof that the runner is idle, but a cancel only changes the server-side status; the one-job runner process keeps going. Reuse then deletes that runner's registration, so the process can no longer report or learn about the cancel (hence "unregistered runner"), and starting the one-job unit again is a no-op while the old process is alive, so the new registration is never served. The check that would catch this, `runner_service_active(ip)`, is used for the re-run case in `reconcile_running` but not before reuse after a `cancelled` status. Related earlier fixes: the comments in `reconcile_running` (2026-08-27, 2026-09-19) describe the same family of problem. **Side effects:** the workflow's `Cleanup on failure` step is `if: failure()`, so a cancelled staging run never destroys its `web-staging`/`services-staging` nodes (they were left running, billed); the task keeps deploying to them after the cancel. **Fix ideas:** - [ ] In `try_reuse_or_cleanup`, when the finished job's status is `cancelled` (or any time `runner_service_active` is true), stop the runner unit on the VM (`systemctl stop forgejo-runner-ephemeral`, killing its process tree) before deleting the registration and reusing the VM; if it cannot be stopped, destroy the VM instead of reusing it. - [ ] Make the `Start ephemeral runner (one-job)` task restart rather than start when the unit is already active. - [ ] Test in `roles/ci_ephemeral_provisioner/tests/test_runner_cleanup.py`: a cancelled job whose runner is still active must not be reused with a live process. - [ ] Optionally add `if: failure() || cancelled()` to the staging workflow's cleanup step so a cancel also destroys the staging nodes (the runner has to survive long enough to run it; the stop above would prevent that, so decide which wins). **Workaround until fixed:** do not cancel a running staging run; if you must, restart `forgejo-runner-ephemeral.service` on the runner VM afterwards, then check the Linode dashboard for leftover `-staging` nodes and `./infra unlock <id>` if the state lock is held. Related: #148 (runner cleanup), #121 (backlog).
Author
Owner

Fixed in #194 (merged): the provisioner stops a still-running runner before reusing its VM after a cancelled job, and destroys the VM if it cannot. Not yet deployed: ./infra deploy --tags ci_ephemeral_provisioner --limit services. Still open from the fix ideas: whether the staging workflow's cleanup should also run on cancel.

Fixed in #194 (merged): the provisioner stops a still-running runner before reusing its VM after a cancelled job, and destroys the VM if it cannot. Not yet deployed: ./infra deploy --tags ci_ephemeral_provisioner --limit services. Still open from the fix ideas: whether the staging workflow's cleanup should also run on cancel.
Sign in to join this conversation.
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jfraeys/infra#192
No description provided.