A cancelled job runs its always() and cancelled() steps before it ends, like GitHub
Cancelling a run stopped a hosted job's container and ended the job at once, so steps with `if: always()` or `if: cancelled()` and post steps never ran. The Live test's cleanup step is `if: always()`, so two cancelled Live test runs left their throwaway repositories behind, with a workflow that runs every 5 minutes. Now the runner stops the step under way and runs the steps whose `if:` is still true for up to 5 minutes (after a job timeout too); a second cancel stops them. The server sends SIGINT, waits for the runner's report, and ends the job itself 7 minutes after the cancel if none came. Jobs Diffinery ends itself (timeouts past the deadline, fail-fast, concurrency) stop their container outright, since their result is already recorded. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155wm8JcL2djVkP8kBaRGmo
- CI / Typecheck and bundlesuccess
- CI / Unit tests (1/2)success
- CI / Unit tests (2/2)Failed
- End-to-end / End-to-end (agent reviews, tasks, Today)success
- End-to-end / End-to-end (git, PRs, Actions)success
- End-to-end / End-to-end (large pushes)success
- End-to-end / End-to-end (passkeys, registry, email)success
- End-to-end / End-to-end (real histories)success
- End-to-end / End-to-end (runner Docker)success
- End-to-end / End-to-end (security)success
9 files changed+313−40
Files (9)
- apps/diffinery/docs/actions/expressions.md+3−1
- apps/diffinery/docs/actions/overview.md+1−1
- apps/diffinery/src/repo/actions.ts+53−9
- apps/diffinery/src/repo/schema.ts+3−0
- apps/diffinery/src/runner/containers.ts+10−4
- apps/diffinery/test/unit/actions-runtime.test.ts+102−2
- containers/runner/src/agent.ts+6−2
- containers/runner/src/job.ts+68−15
- containers/runner/test/job.test.ts+67−6
modifiedapps/diffinery/docs/actions/expressions.md+3−1View file
| @@ -145,10 +145,12 @@ | ||
| 145 | 145 | | Function | True when | |
| 146 | 146 | | --- | --- | |
| 147 | 147 | | `success()` | No earlier step of the job failed (steps with `continue-on-error: true` don't count) and the job isn't being cancelled or timing out. | |
| 148 | -| `failure()` | An earlier step failed. | | |
| 148 | +| `failure()` | An earlier step failed, and the job isn't being cancelled or timing out. | | |
| 149 | 149 | | `cancelled()` | The job is being cancelled or has hit its timeout. | |
| 150 | 150 | | `always()` | Always. | |
| 151 | 151 | |
| 152 | +When a job on a Diffinery-hosted runner is cancelled or hits its `timeout-minutes`, the step that is running stops, and then, as on GitHub, the remaining steps whose `if:` is true still run: `if: always()`, `if: cancelled()` and post steps such as the cache save with `save-always`. They have 5 minutes together. Use them to clean up what the job created, for example a test environment or a temporary repository. Cancelling the run a second time stops them at once. The job still ends as `cancelled` (or failed, after a timeout). | |
| 153 | + | |
| 152 | 154 | An `if:` that doesn't call any of these is treated as `success() && (<condition>)`. So `if: github.ref == 'refs/heads/main'` is skipped after a failure, while `if: always() && github.ref == 'refs/heads/main'` still runs. Inside a composite action, the functions look at the action's own steps. |
| 153 | 155 | |
| 154 | 156 | ```yaml |
modifiedapps/diffinery/docs/actions/overview.md+1−1View file
| @@ -164,7 +164,7 @@ | ||
| 164 | 164 | - The run's **Status** and duration, what **Triggered** it and who, the **Commit** and ref (and pull request), and the **Workflow file** at that commit. |
| 165 | 165 | - One box per job id, listing the jobs it produced (a matrix job says how many, for example `4 jobs (matrix)`), each with its status, duration and, for a failed job, the first line of the failure reason. |
| 166 | 166 | - **Artifacts**, when jobs uploaded any that haven't expired, with their size and age. Click a name to download it: a `.zip` file, or the file itself for an artifact uploaded with `archive: false`. See [Caching and artifacts](/docs/actions/caching-and-artifacts). |
| 167 | -- **Cancel run** while the run is not completed (you confirm with **Cancel this run?**), and **Re-run all jobs** once it has completed. Both need write access. | |
| 167 | +- **Cancel run** while the run is not completed (you confirm with **Cancel this run?**), and **Re-run all jobs** once it has completed. Both need write access. A cancelled job on a Diffinery-hosted runner first runs its `if: always()` and `if: cancelled()` steps, for up to 5 minutes (see [Status check functions](/docs/actions/expressions#status-check-functions)); cancelling again stops them. | |
| 168 | 168 | |
| 169 | 169 | ### Job page and logs |
| 170 | 170 |
modifiedapps/diffinery/src/repo/actions.ts+53−9View file
| @@ -46,11 +46,19 @@ | ||
| 46 | 46 | /** |
| 47 | 47 | * How long past its timeout-minutes (from when its runner fetched it) a running job may go without |
| 48 | 48 | * reporting before Diffinery ends it itself. The runner stops a job at its timeout and reports within |
| 49 | - * seconds (it cancels the steps and skips post steps), so this only ends jobs whose runner agent froze | |
| 50 | - * or vanished, which would otherwise hold their container, quota slot and minutes for up to 6 hours. | |
| 49 | + * seconds, or within CANCEL_GRACE_MS when steps run after the timeout (`if: always()`), so this only ends | |
| 50 | + * jobs whose runner agent froze or vanished, which would otherwise hold their container, quota slot and | |
| 51 | + * minutes for up to 6 hours. | |
| 51 | 52 | */ |
| 52 | 53 | export const JOB_DEADLINE_GRACE_MS = 10 * 60_000; |
| 53 | 54 | /** |
| 55 | + * A cancelled hosted job's runner stops the step under way and then, like GitHub's, runs the steps that | |
| 56 | + * run after a cancel (`if: always()`, `if: cancelled()`, post steps) for up to 5 minutes, so cleanup such | |
| 57 | + * as deleting what the job created still happens. Diffinery waits this long for it to report (its 5 | |
| 58 | + * minutes, and time for the logs and the result), then stops its container and ends the job itself. | |
| 59 | + */ | |
| 60 | +export const CANCEL_STOP_MS = 7 * 60_000; | |
| 61 | +/** | |
| 54 | 62 | * A run finishes when its workflow (runner/workflow.ts) has had every job's completion event. An event |
| 55 | 63 | * lost while the platform updates (completeJob's sendEvent failing during a deploy) leaves the workflow |
| 56 | 64 | * waiting up to 6 hours, and a workflow instance that failed never finishes the run: either way the run |
| @@ -261,6 +269,9 @@ | ||
| 261 | 269 | runner_info: string | null; |
| 262 | 270 | /** Set on a job a partial re-run copied from an earlier attempt: the job that actually ran. */ |
| 263 | 271 | carried_from?: string | null; |
| 272 | + /** When a running hosted job was cancelled (ms since the epoch) and why, while its runner finishes (CANCEL_STOP_MS). */ | |
| 273 | + cancel_at?: number | null; | |
| 274 | + cancel_reason?: string | null; | |
| 264 | 275 | restarts: number; |
| 265 | 276 | /** Hosted runner size runs-on asked for (runner/sizes.ts); null when it is chosen from the job's earlier runs. */ |
| 266 | 277 | runner_size?: string | null; |
| @@ -1751,6 +1762,10 @@ | ||
| 1751 | 1762 | await this.completeJob(jobId, { conclusion: "failure", outputs: {}, failure: { message: `The job exceeded its timeout of ${j.timeout_minutes} minutes.` } }, "timed_out"); |
| 1752 | 1763 | } |
| 1753 | 1764 | |
| 1765 | + private earlierDeadline(a: number | null, b: number | null): number | null { | |
| 1766 | + return a === null ? b : b === null ? a : Math.min(a, b); | |
| 1767 | + } | |
| 1768 | + | |
| 1754 | 1769 | /** When a running job's runner has had its timeout and JOB_DEADLINE_GRACE_MS to report, in ms since the epoch. */ |
| 1755 | 1770 | private jobDeadline(j: Pick<JobRow, "started_at" | "timeout_minutes">): number | null { |
| 1756 | 1771 | const from = Date.parse(j.started_at ?? ""); |
| @@ -1762,8 +1777,8 @@ | ||
| 1762 | 1777 | /** The earliest deadline of a running job (RepoDO's alarm wakes up for it), or null. */ |
| 1763 | 1778 | nextJobDeadline(): number | null { |
| 1764 | 1779 | let next: number | null = null; |
| 1765 | - for (const j of this.sql.exec<JobRow>("SELECT started_at, timeout_minutes FROM jobs WHERE status = 'in_progress' AND started_at IS NOT NULL").toArray()) { | |
| 1766 | - const d = this.jobDeadline(j); | |
| 1780 | + for (const j of this.sql.exec<JobRow>("SELECT started_at, timeout_minutes, cancel_at FROM jobs WHERE status = 'in_progress' AND started_at IS NOT NULL").toArray()) { | |
| 1781 | + const d = this.earlierDeadline(this.jobDeadline(j), j.cancel_at ? j.cancel_at + CANCEL_STOP_MS : null); | |
| 1767 | 1782 | if (d !== null && (next === null || d < next)) next = d; |
| 1768 | 1783 | } |
| 1769 | 1784 | return next; |
| @@ -1851,6 +1866,12 @@ | ||
| 1851 | 1866 | |
| 1852 | 1867 | async enforceJobDeadlines(at = Date.now()): Promise<void> { |
| 1853 | 1868 | for (const j of this.sql.exec<JobRow>("SELECT * FROM jobs WHERE status = 'in_progress' AND started_at IS NOT NULL ORDER BY rowid").toArray()) { |
| 1869 | + if (j.cancel_at && j.cancel_at + CANCEL_STOP_MS <= at) { | |
| 1870 | + await this.stopContainer(j); | |
| 1871 | + const message = `${j.cancel_reason ?? "Cancelled"}. Its runner didn't report within ${CANCEL_STOP_MS / 60_000} minutes of the cancel, so Diffinery stopped it.`; | |
| 1872 | + await this.completeJob(j.id, { conclusion: "cancelled", outputs: {}, failure: { message } }); | |
| 1873 | + continue; | |
| 1874 | + } | |
| 1854 | 1875 | const d = this.jobDeadline(j); |
| 1855 | 1876 | if (d === null || d > at) continue; |
| 1856 | 1877 | const minutes = Number(j.timeout_minutes) > 0 ? Number(j.timeout_minutes) : DEFAULT_TIMEOUT_MINUTES; |
| @@ -1868,6 +1889,8 @@ | ||
| 1868 | 1889 | if (!j || j.status === "completed") return; |
| 1869 | 1890 | // A container the job no longer runs in (it was replaced, and the job restarted in another). |
| 1870 | 1891 | if (container && j.container !== container) return; |
| 1892 | + // Cancelled, and its container stopped before the runner reported: the job ends as cancelled. | |
| 1893 | + if (j.cancel_at) return this.completeJob(jobId, { conclusion: "cancelled", outputs: {}, failure: { message: j.cancel_reason ?? "Cancelled" } }); | |
| 1871 | 1894 | // Already back in line (placementFailed): a container given up on, which started after all, |
| 1872 | 1895 | // finds its token revoked and exits. The job's next placement starts a new one. |
| 1873 | 1896 | if (j.status === "queued") return; |
| @@ -1975,11 +1998,15 @@ | ||
| 1975 | 1998 | if (!this.sql.exec("DELETE FROM runners WHERE id = ? RETURNING id", id).toArray().length) throw new HttpError(404, "runner not found"); |
| 1976 | 1999 | } |
| 1977 | 2000 | |
| 1978 | - private async stopContainer(j: JobRow): Promise<void> { | |
| 2001 | + /** | |
| 2002 | + * Stops a hosted job's container, for a job Diffinery ends itself. `graceful` instead asks its runner to | |
| 2003 | + * cancel the job, running the steps that run after a cancel, and report (cancelInternal). | |
| 2004 | + */ | |
| 2005 | + private async stopContainer(j: JobRow, graceful = false): Promise<void> { | |
| 1979 | 2006 | if (!j.container || j.container.startsWith("self-hosted:")) return; |
| 1980 | 2007 | try { |
| 1981 | 2008 | const ns = this.runnerNamespace(j.container); |
| 1982 | - await ns.get(ns.idFromName(j.container)).stopJob(); | |
| 2009 | + await ns.get(ns.idFromName(j.container)).stopJob(graceful); | |
| 1983 | 2010 | } catch (e) { |
| 1984 | 2011 | console.warn("stop container failed", e); |
| 1985 | 2012 | } |
| @@ -2079,19 +2106,31 @@ | ||
| 2079 | 2106 | |
| 2080 | 2107 | // ---------------------------------------------------------------- cancellation and re-runs |
| 2081 | 2108 | |
| 2082 | - private async cancelInternal(runId: string, reason: string): Promise<void> { | |
| 2109 | + /** | |
| 2110 | + * Cancels a run's unfinished jobs. With `graceful` (someone cancelled the run), a running hosted job's | |
| 2111 | + * runner gets to run the steps that run after a cancel first, as on GitHub (CANCEL_STOP_MS); the job | |
| 2112 | + * ends when it reports. Otherwise, or when it was already cancelled that way, every job ends at once. | |
| 2113 | + */ | |
| 2114 | + private async cancelInternal(runId: string, reason: string, graceful = false): Promise<void> { | |
| 2083 | 2115 | const run = this.runRow(runId); |
| 2084 | 2116 | if (run.status === "completed") return; |
| 2085 | 2117 | this.sql.exec("UPDATE runs SET conclusion = 'cancelled', updated_at = ? WHERE id = ?", now(), run.id); |
| 2086 | 2118 | for (const j of this.sql.exec<JobRow>("SELECT * FROM jobs WHERE run_id = ? AND status != 'completed'", run.id).toArray()) { |
| 2119 | + if (graceful && !j.cancel_at && j.status === "in_progress" && !j.labels && j.container && !j.container.startsWith("self-hosted:")) { | |
| 2120 | + const at = Date.now(); | |
| 2121 | + this.sql.exec("UPDATE jobs SET cancel_at = ?, cancel_reason = ? WHERE id = ?", at, reason, j.id); | |
| 2122 | + await this.stopContainer(j, true); | |
| 2123 | + this.repo.alarmBy(at + CANCEL_STOP_MS); | |
| 2124 | + continue; | |
| 2125 | + } | |
| 2087 | 2126 | await this.stopContainer(j); |
| 2088 | - await this.completeJob(j.id, { conclusion: "cancelled", outputs: {}, failure: { message: reason } }); | |
| 2127 | + await this.completeJob(j.id, { conclusion: "cancelled", outputs: {}, failure: { message: j.cancel_reason ?? reason } }); | |
| 2089 | 2128 | } |
| 2090 | 2129 | } |
| 2091 | 2130 | |
| 2092 | 2131 | async cancel(runId: string, caller: Caller): Promise<void> { |
| 2093 | 2132 | if (!atLeast(caller.role, "write")) throw new HttpError(403, "permission denied"); |
| 2094 | - await this.cancelInternal(runId, `Cancelled by ${caller.login}`); | |
| 2133 | + await this.cancelInternal(runId, `Cancelled by ${caller.login}`, true); | |
| 2095 | 2134 | } |
| 2096 | 2135 | |
| 2097 | 2136 | /** |
| @@ -2425,6 +2464,11 @@ | ||
| 2425 | 2464 | |
| 2426 | 2465 | async runnerComplete(jobId: string, token: string, completion: JobCompletion, deployed: string | null = null): Promise<void> { |
| 2427 | 2466 | const job = await this.authJob(jobId, token); |
| 2467 | + // Cancelled while it ran: it ends here whatever stopped it, never starting again, and says who cancelled it. | |
| 2468 | + if (job.cancel_at) { | |
| 2469 | + const { replaced: _, ...rest } = completion; | |
| 2470 | + return this.completeJob(jobId, rest.conclusion === "cancelled" ? { ...rest, failure: { message: job.cancel_reason ?? "Cancelled" } } : rest); | |
| 2471 | + } | |
| 2428 | 2472 | if (completion.replaced && completion.conclusion !== "success" && !job.labels) { |
| 2429 | 2473 | // The runner can't tell a rollout's SIGTERM from the platform's own; its image can: on the |
| 2430 | 2474 | // deployed one, no deploy replaced it (the platform stopped or moved its container). |
modifiedapps/diffinery/src/repo/schema.ts+3−0View file
| @@ -216,6 +216,9 @@ | ||
| 216 | 216 | ["jobs", "runner_size", "TEXT"], |
| 217 | 217 | ["jobs", "size_note", "TEXT"], |
| 218 | 218 | ["jobs", "usage", "TEXT"], |
| 219 | + // When a running hosted job was cancelled and why, while its runner runs the steps that run after a cancel (repo/actions.ts CANCEL_STOP_MS). | |
| 220 | + ["jobs", "cancel_at", "INTEGER"], | |
| 221 | + ["jobs", "cancel_reason", "TEXT"], | |
| 219 | 222 | // The GitHub import that wrote this row (null for everything else); a retry may only overwrite its own rows. |
| 220 | 223 | ["issues", "imported", "TEXT"], |
| 221 | 224 | ["pulls", "imported", "TEXT"], |
modifiedapps/diffinery/src/runner/containers.ts+10−4View file
| @@ -75,13 +75,19 @@ | ||
| 75 | 75 | ); |
| 76 | 76 | } |
| 77 | 77 | |
| 78 | - async stopJob(): Promise<void> { | |
| 78 | + /** | |
| 79 | + * Diffinery ended the job (a timeout, fail-fast, a newer run in its concurrency group): the container | |
| 80 | + * goes at once. `graceful` (someone cancelled the run) sends SIGINT instead: the runner stops the step | |
| 81 | + * under way, runs the steps that run after a cancel and reports. The agent reads SIGTERM as a platform | |
| 82 | + * rollout and keeps the job running for up to 14 minutes, which a cancelled job must not do. | |
| 83 | + */ | |
| 84 | + async stopJob(graceful = false): Promise<void> { | |
| 79 | 85 | // Noted first, so onStop can tell Diffinery's own stop (a cancel, a timeout) from the platform's. |
| 80 | 86 | const job = await this.ctx.storage.get<RunnerJob>("job"); |
| 81 | 87 | if (job) await this.ctx.storage.put("job", { ...job, stopRequested: true }); |
| 82 | - // SIGINT cancels the job at once. The agent reads SIGTERM as a platform rollout and | |
| 83 | - // keeps the job running for up to 14 minutes, which a cancelled job must not do. | |
| 84 | - if (this.ctx.container?.running) await this.stop("SIGINT"); | |
| 88 | + if (!this.ctx.container?.running) return; | |
| 89 | + if (graceful) await this.stop("SIGINT"); | |
| 90 | + else await this.destroy(); | |
| 85 | 91 | } |
| 86 | 92 | |
| 87 | 93 | override async onStop(params: StopParams): Promise<void> { |
modifiedapps/diffinery/test/unit/actions-runtime.test.ts+102−2View file
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from "vitest"; |
| 2 | 2 | import { BodyTooLarge, STEP_OPS, batchParts, blockKey, chunkKey, compose, finalKey, listStaged, parseBlockList, parseRange, planCompose, putStream, runComposeStep, stagingPrefix, type BlobBucket, type ComposeLimits, type ComposeStep } from "../../src/lib/blob-store.js"; |
| 3 | 3 | import { DatabaseSync } from "node:sqlite"; |
| 4 | -import { Actions, JOB_DEADLINE_GRACE_MS, MAX_ACTIVE_RUNS, MAX_PLACEMENT_TRIES, MAX_RUNNER_RESTARTS, parseRunnerInfo, placementDelay, QUICK_RETRY_SECONDS, STALE_IMAGE_RETRY_SECONDS, STALE_IMAGE_TRIES } from "../../src/repo/actions.js"; | |
| 4 | +import { Actions, CANCEL_STOP_MS, JOB_DEADLINE_GRACE_MS, MAX_ACTIVE_RUNS, MAX_PLACEMENT_TRIES, MAX_RUNNER_RESTARTS, parseRunnerInfo, placementDelay, QUICK_RETRY_SECONDS, STALE_IMAGE_RETRY_SECONDS, STALE_IMAGE_TRIES } from "../../src/repo/actions.js"; | |
| 5 | 5 | import { fleetLimits } from "../../src/lib/quotas.js"; |
| 6 | 6 | import { testD1 } from "./stubs/d1.js"; |
| 7 | 7 | import { DEFAULT_ARTIFACT_QUOTA, Runtime, artifactFileName, artifactObjectKeys } from "../../src/repo/runtime.js"; |
| @@ -10,7 +10,7 @@ | ||
| 10 | 10 | import type { Env } from "../../src/env.js"; |
| 11 | 11 | import { archiveKey, deletePrefixes, legacyArtifactKeys, legacyCacheKeys, logKeys, transientPrefixes } from "../../src/lib/storage-keys.js"; |
| 12 | 12 | import { signBlobUrl, signRuntimeToken, verifyBlobUrl, verifyRuntimeToken } from "../../src/lib/runtime-auth.js"; |
| 13 | -import { b64, fromB64url } from "../../src/lib/util.js"; | |
| 13 | +import { b64, fromB64url, sha256 } from "../../src/lib/util.js"; | |
| 14 | 14 | import { completePayload, issuePayload, releasePayload } from "../../src/repo/event-payloads.js"; |
| 15 | 15 | import { TokenPolicy } from "../../src/repo/token-policy.js"; |
| 16 | 16 | import type { Caller, RepoMeta } from "../../src/repo/types.js"; |
| @@ -1029,6 +1029,106 @@ | ||
| 1029 | 1029 | expect(actions.nextJobDeadline()).toBeNull(); |
| 1030 | 1030 | }); |
| 1031 | 1031 | |
| 1032 | + it("cancelling a run lets running hosted jobs run their steps that run after a cancel, and ends them after CANCEL_STOP_MS", async () => { | |
| 1033 | + const sql = sqlStorage(); | |
| 1034 | + sql.exec(REPO_SCHEMA); | |
| 1035 | + migrateRepoSchema(sql as unknown as SqlStorage); | |
| 1036 | + const events: { type: string; payload: Record<string, unknown> }[] = []; | |
| 1037 | + const stopped: [string, boolean][] = []; | |
| 1038 | + const alarms: number[] = []; | |
| 1039 | + const repo = { | |
| 1040 | + sql, | |
| 1041 | + meta: { repoId: "r", owner: "acme", ownerId: "acme", name: "app", defaultBranch: "main" }, | |
| 1042 | + upsertCheck: () => {}, | |
| 1043 | + broadcast: () => {}, | |
| 1044 | + deploys: { jobCompleted: () => {} }, | |
| 1045 | + runtime: { sweepJob: async () => {}, grantFor: () => ({ role: "read", permissions: {} }) }, | |
| 1046 | + alarmBy: (at: number) => void alarms.push(at), | |
| 1047 | + } as unknown as RepoDO; | |
| 1048 | + const env = { | |
| 1049 | + PUBLIC_URL: "https://d.test", | |
| 1050 | + BUCKET: memoryBucket().bucket, | |
| 1051 | + RUNNER: { idFromName: (n: string) => n, get: (n: string) => ({ startJob: async () => {}, stopJob: async (graceful = false) => void stopped.push([n, graceful]) }) }, | |
| 1052 | + RUN_WORKFLOW: { get: async () => ({ sendEvent: async (e: { type: string; payload: Record<string, unknown> }) => void events.push(e) }) }, | |
| 1053 | + } as unknown as Env; | |
| 1054 | + const actions = new Actions(repo, env); | |
| 1055 | + const t = new Date().toISOString(); | |
| 1056 | + sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES ('run1', 1, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'in_progress', ?, ?, '{}')", t, t); | |
| 1057 | + // a and b run on hosted runners (a reports after its cleanup steps, b's runner never does); s runs | |
| 1058 | + // on a self-hosted runner; w's container hasn't fetched the job yet; q waits in line. | |
| 1059 | + const token = (id: string) => `djt_r_${id}_secret`; | |
| 1060 | + const add = async (id: string, status: string, container: string | null) => | |
| 1061 | + sql.exec( | |
| 1062 | + "INSERT INTO jobs (id, run_id, job_key, name, status, timeout_minutes, container, started_at, token_hash, token_at, strategy) VALUES (?, 'run1', ?, ?, ?, 60, ?, ?, ?, ?, '{\"fail-fast\":false}')", | |
| 1063 | + id, id, id, status, container, status === "in_progress" ? t : null, await sha256(token(id)), t, | |
| 1064 | + ); | |
| 1065 | + await add("a", "in_progress", "job-a"); | |
| 1066 | + await add("b", "in_progress", "job-b"); | |
| 1067 | + await add("s", "in_progress", "self-hosted:box"); | |
| 1068 | + await add("w", "waiting", "job-w"); | |
| 1069 | + await add("q", "queued", null); | |
| 1070 | + const job = (id: string) => sql.exec<{ status: string; conclusion: string | null; failure: string | null }>("SELECT status, conclusion, failure FROM jobs WHERE id = ?", id).one(); | |
| 1071 | + const before = Date.now(); | |
| 1072 | + await actions.cancel("run1", { kind: "user", login: "octo", role: "write" } as unknown as Caller); | |
| 1073 | + // a and b get SIGINT and keep running; the rest end at once, w's container stopped outright. | |
| 1074 | + expect(stopped).toEqual([["job-a", true], ["job-b", true], ["job-w", false]]); | |
| 1075 | + expect([job("a").status, job("b").status]).toEqual(["in_progress", "in_progress"]); | |
| 1076 | + for (const id of ["s", "w", "q"]) expect(job(id)).toMatchObject({ status: "completed", conclusion: "cancelled", failure: JSON.stringify({ message: "Cancelled by octo" }) }); | |
| 1077 | + expect(alarms.at(-1)).toBeGreaterThanOrEqual(before + CANCEL_STOP_MS); | |
| 1078 | + const deadline = actions.nextJobDeadline()!; | |
| 1079 | + expect(deadline - before).toBeGreaterThanOrEqual(CANCEL_STOP_MS); | |
| 1080 | + expect(deadline - Date.now()).toBeLessThanOrEqual(CANCEL_STOP_MS); | |
| 1081 | + // a's runner reports once its cleanup steps ran: cancelled, saying who cancelled it, never started again. | |
| 1082 | + await actions.runnerComplete("a", token("a"), { conclusion: "cancelled", outputs: {}, failure: { message: "The job was cancelled." }, replaced: true }); | |
| 1083 | + expect(job("a")).toMatchObject({ status: "completed", conclusion: "cancelled", failure: JSON.stringify({ message: "Cancelled by octo" }) }); | |
| 1084 | + | |
| 1085 | + // b's runner never reports: at CANCEL_STOP_MS its container is stopped and the job ends. | |
| 1086 | + await actions.enforceJobDeadlines(deadline - 1); | |
| 1087 | + expect(job("b").status).toBe("in_progress"); | |
| 1088 | + await actions.enforceJobDeadlines(deadline); | |
| 1089 | + expect(stopped.at(-1)).toEqual(["job-b", false]); | |
| 1090 | + expect(job("b").conclusion).toBe("cancelled"); | |
| 1091 | + expect(JSON.parse(job("b").failure!).message).toBe(`Cancelled by octo. Its runner didn't report within ${CANCEL_STOP_MS / 60_000} minutes of the cancel, so Diffinery stopped it.`); | |
| 1092 | + expect(actions.nextJobDeadline()).toBeNull(); | |
| 1093 | + }); | |
| 1094 | + | |
| 1095 | + it("cancelling a cancelled run again stops its jobs at once, and a cancelled job whose container stops before its runner reports ends as cancelled", async () => { | |
| 1096 | + const sql = sqlStorage(); | |
| 1097 | + sql.exec(REPO_SCHEMA); | |
| 1098 | + migrateRepoSchema(sql as unknown as SqlStorage); | |
| 1099 | + const repo = { | |
| 1100 | + sql, | |
| 1101 | + meta: { repoId: "r", owner: "acme", ownerId: "acme", name: "app", defaultBranch: "main" }, | |
| 1102 | + upsertCheck: () => {}, | |
| 1103 | + broadcast: () => {}, | |
| 1104 | + deploys: { jobCompleted: () => {} }, | |
| 1105 | + runtime: { sweepJob: async () => {} }, | |
| 1106 | + alarmBy: () => {}, | |
| 1107 | + } as unknown as RepoDO; | |
| 1108 | + const env = { | |
| 1109 | + PUBLIC_URL: "https://d.test", | |
| 1110 | + BUCKET: memoryBucket().bucket, | |
| 1111 | + RUNNER_IMAGE: "diffinery-runner:new", | |
| 1112 | + RUNNER: { idFromName: (n: string) => n, get: (n: string) => ({ startJob: async () => {}, stopJob: async (graceful = false) => void stopped.push([n, graceful]) }) }, | |
| 1113 | + RUN_WORKFLOW: { get: async () => ({ sendEvent: async () => {} }) }, | |
| 1114 | + } as unknown as Env; | |
| 1115 | + const stopped: [string, boolean][] = []; | |
| 1116 | + const actions = new Actions(repo, env); | |
| 1117 | + const t = new Date().toISOString(); | |
| 1118 | + const job = (id: string) => sql.exec("SELECT status, conclusion, failure FROM jobs WHERE id = ?", id).one(); | |
| 1119 | + for (const run of ["run1", "run2"]) sql.exec("INSERT INTO runs (id, run_number, workflow_path, workflow_name, event, ref, sha, actor, status, created_at, updated_at, event_payload) VALUES (?, 1, 'w.yml', 'w', 'push', 'refs/heads/main', 'abc', '{}', 'in_progress', ?, ?, '{}')", run, t, t); | |
| 1120 | + // On an older runner image than the deployed one, which would otherwise read as a rollout and start the job again. | |
| 1121 | + for (const [id, run] of [["j1", "run1"], ["j2", "run2"]]) sql.exec("INSERT INTO jobs (id, run_id, job_key, name, status, container, started_at, runner_info, strategy) VALUES (?, ?, ?, ?, 'in_progress', ?, ?, '{\"image\":\"diffinery-runner:old\"}', '{}')", id, run, id, id, `job-${id}`, t); | |
| 1122 | + const octo = { kind: "user", login: "octo", role: "write" } as unknown as Caller; | |
| 1123 | + await actions.cancel("run1", octo); | |
| 1124 | + await actions.cancel("run1", { kind: "user", login: "hubot", role: "write" } as unknown as Caller); | |
| 1125 | + expect(stopped).toEqual([["job-j1", true], ["job-j1", false]]); | |
| 1126 | + expect(job("j1")).toMatchObject({ status: "completed", conclusion: "cancelled", failure: JSON.stringify({ message: "Cancelled by octo" }) }); | |
| 1127 | + await actions.cancel("run2", octo); | |
| 1128 | + await actions.containerStopped("j2", null, "job-j2", true); | |
| 1129 | + expect(job("j2")).toMatchObject({ status: "completed", conclusion: "cancelled", failure: JSON.stringify({ message: "Cancelled by octo" }) }); | |
| 1130 | + }); | |
| 1131 | + | |
| 1032 | 1132 | it("a job whose runner a rollout replaced starts again on a new runner before its steps ran or when it can only read, and fails otherwise", async () => { |
| 1033 | 1133 | const sql = sqlStorage(); |
| 1034 | 1134 | sql.exec(REPO_SCHEMA); |
modifiedcontainers/runner/src/agent.ts+6−2View file
| @@ -9,7 +9,10 @@ | ||
| 9 | 9 | workRoot: string; |
| 10 | 10 | toolCache?: string; |
| 11 | 11 | echoLogs?: boolean; |
| 12 | - /** Install a SIGTERM/SIGINT handler that cancels the job. */ | |
| 12 | + /** | |
| 13 | + * Install a SIGTERM/SIGINT handler that cancels the job: the step under way stops and the steps that | |
| 14 | + * still run after a cancel (`if: always()`, `if: cancelled()`, post steps) run. A second signal stops them too. | |
| 15 | + */ | |
| 13 | 16 | handleSignals?: boolean; |
| 14 | 17 | /** |
| 15 | 18 | * Hosted runners: on SIGTERM (a platform rollout replacing this container), keep |
| @@ -50,7 +53,8 @@ | ||
| 50 | 53 | o.onRunner?.(runner); |
| 51 | 54 | const onSignal = (sig: string) => { |
| 52 | 55 | runner.logger.line(-1, `##[error]Received ${sig}; cancelling the job.`); |
| 53 | - runner.cancel("cancelled"); | |
| 56 | + // While the platform replaces this runner, its container stops soon: no steps run after the cancel. | |
| 57 | + runner.cancel("cancelled", runner.replacing ? 0 : undefined); | |
| 54 | 58 | }; |
| 55 | 59 | let draining: NodeJS.Timeout | null = null; |
| 56 | 60 | const sigterm = () => { |
modifiedcontainers/runner/src/job.ts+68−15View file
| @@ -19,6 +19,12 @@ | ||
| 19 | 19 | |
| 20 | 20 | export type Outcome = "success" | "failure" | "cancelled" | "skipped"; |
| 21 | 21 | |
| 22 | +/** | |
| 23 | + * After a cancel or a job timeout, how long the steps that still run (`if: always()`, `if: cancelled()`, | |
| 24 | + * post steps) get before they are stopped too: GitHub's runner gives them 5 minutes. | |
| 25 | + */ | |
| 26 | +export const CANCEL_GRACE_MS = 5 * 60_000; | |
| 27 | + | |
| 22 | 28 | export interface StepContextEntry { |
| 23 | 29 | outputs: Record<string, string>; |
| 24 | 30 | outcome: Outcome; |
| @@ -102,8 +108,11 @@ | ||
| 102 | 108 | readonly summaries: string[] = []; |
| 103 | 109 | readonly posts: PostStep[] = []; |
| 104 | 110 | readonly children = new Set<ChildProcess>(); |
| 105 | - /** Aborted when the job is cancelled or times out, for work the runner does itself (downloads). */ | |
| 106 | - private readonly aborter = new AbortController(); | |
| 111 | + /** | |
| 112 | + * Aborted when the job is cancelled or times out, for work the runner does itself (downloads). A new | |
| 113 | + * one takes its place for the steps that still run after a cancel. | |
| 114 | + */ | |
| 115 | + private aborter = new AbortController(); | |
| 107 | 116 | readonly stepErrors = new Map<number, Annotation[]>(); |
| 108 | 117 | readonly stepReasons = new Map<number, string>(); |
| 109 | 118 | /** Job and service containers, and Docker actions (self-hosted runners with Docker). */ |
| @@ -112,7 +121,15 @@ | ||
| 112 | 121 | /** The Actions runtime services client (null when the server sent none). */ |
| 113 | 122 | readonly runtime: RuntimeClient | null; |
| 114 | 123 | |
| 124 | + /** Why the job is ending early: the job's status for status functions and its conclusion. */ | |
| 115 | 125 | private stopping: null | "cancelled" | "timeout" = null; |
| 126 | + /** | |
| 127 | + * Whether the work under way must stop now: set by a cancel, cleared once the steps that still | |
| 128 | + * run after it begin (resumeAfterCancel), and set for good when their grace runs out (`forced`). | |
| 129 | + */ | |
| 130 | + private halting = false; | |
| 131 | + private forced = false; | |
| 132 | + private graceTimer: NodeJS.Timeout | undefined; | |
| 116 | 133 | /** Set when the platform signals that it is replacing this runner (a container rollout). */ |
| 117 | 134 | replacing = false; |
| 118 | 135 | private firstFailure: { step?: number; message: string } | null = null; |
| @@ -165,18 +182,48 @@ | ||
| 165 | 182 | return this.aborter.signal; |
| 166 | 183 | } |
| 167 | 184 | |
| 185 | + /** Whether the step under way must stop (the job was cancelled or timed out while it ran). */ | |
| 168 | 186 | get isStopping(): boolean { |
| 169 | - return this.stopping !== null; | |
| 187 | + return this.halting; | |
| 170 | 188 | } |
| 171 | 189 | |
| 172 | - /** SIGTERM / external cancellation. */ | |
| 173 | - cancel(reason: "cancelled" | "timeout" = "cancelled"): void { | |
| 174 | - if (this.stopping) return; | |
| 190 | + /** | |
| 191 | + * SIGINT / SIGTERM / external cancellation, or the job's timeout. Like GitHub, the step under way | |
| 192 | + * stops, and the steps that still run after a cancel (`if: always()`, `if: cancelled()`, post steps) | |
| 193 | + * get `graceMs` before they are stopped too. A second cancel stops them at once. | |
| 194 | + */ | |
| 195 | + cancel(reason: "cancelled" | "timeout" = "cancelled", graceMs = CANCEL_GRACE_MS): void { | |
| 196 | + if (this.stopping) return this.force(); | |
| 175 | 197 | this.stopping = reason; |
| 198 | + this.halt(); | |
| 199 | + if (graceMs <= 0) return this.force(); | |
| 200 | + this.graceTimer = setTimeout(() => { | |
| 201 | + this.logger.line(-1, `##[error]The steps that run after a cancel took longer than ${graceMs / 60_000} minutes; stopping them.`); | |
| 202 | + this.force(); | |
| 203 | + }, graceMs); | |
| 204 | + this.graceTimer.unref(); | |
| 205 | + } | |
| 206 | + | |
| 207 | + private force(): void { | |
| 208 | + if (this.forced) return; | |
| 209 | + this.forced = true; | |
| 210 | + if (this.graceTimer) clearTimeout(this.graceTimer); | |
| 211 | + this.halt(); | |
| 212 | + } | |
| 213 | + | |
| 214 | + private halt(): void { | |
| 215 | + this.halting = true; | |
| 176 | 216 | this.aborter.abort(); |
| 177 | 217 | for (const c of this.children) killTree(c); |
| 178 | 218 | } |
| 179 | 219 | |
| 220 | + /** Once the cancelled step has stopped: let the steps that still run after a cancel do their work. */ | |
| 221 | + private resumeAfterCancel(): void { | |
| 222 | + if (!this.halting || this.forced) return; | |
| 223 | + this.halting = false; | |
| 224 | + this.aborter = new AbortController(); | |
| 225 | + } | |
| 226 | + | |
| 180 | 227 | async run(): Promise<JobCompletion> { |
| 181 | 228 | const spec = this.spec; |
| 182 | 229 | let jobTimer: NodeJS.Timeout | undefined; |
| @@ -196,10 +243,11 @@ | ||
| 196 | 243 | for (let i = 0; i < spec.steps.length; i++) { |
| 197 | 244 | const step = spec.steps[i]!; |
| 198 | 245 | const fallbackName = defaultStepName(step); |
| 199 | - if (this.stopping) { | |
| 246 | + if (this.forced) { | |
| 200 | 247 | await this.report(i, { status: "completed", conclusion: "cancelled", name: safeName(step, fallbackName), completedAt: nowIso() }); |
| 201 | 248 | continue; |
| 202 | 249 | } |
| 250 | + this.resumeAfterCancel(); | |
| 203 | 251 | const startedAt = nowIso(); |
| 204 | 252 | let started = false; |
| 205 | 253 | const res = await this.executeStep(step, top, i, async (name) => { |
| @@ -208,13 +256,14 @@ | ||
| 208 | 256 | }); |
| 209 | 257 | await this.report(i, { |
| 210 | 258 | status: "completed", |
| 211 | - conclusion: res.conclusion, | |
| 259 | + // After a cancel, a step whose condition kept it from running shows as cancelled, as before. | |
| 260 | + conclusion: this.stopping && res.conclusion === "skipped" ? "cancelled" : res.conclusion, | |
| 212 | 261 | name: res.name ?? fallbackName, |
| 213 | 262 | ...(started ? { startedAt } : {}), |
| 214 | 263 | completedAt: nowIso(), |
| 215 | 264 | }); |
| 216 | 265 | } |
| 217 | - if (!this.stopping) await this.runPosts(top); | |
| 266 | + if (!this.forced) await this.runPosts(top); | |
| 218 | 267 | } catch (e) { |
| 219 | 268 | const msg = e instanceof Error ? e.message : String(e); |
| 220 | 269 | this.logger.line(-1, `##[error]${msg}`); |
| @@ -222,6 +271,7 @@ | ||
| 222 | 271 | this.firstFailure ??= { message: msg }; |
| 223 | 272 | } finally { |
| 224 | 273 | if (jobTimer) clearTimeout(jobTimer); |
| 274 | + if (this.graceTimer) clearTimeout(this.graceTimer); | |
| 225 | 275 | await this.prefetch.stop(); |
| 226 | 276 | try { |
| 227 | 277 | await this.docker.cleanup({ logIndex: -1, outputs: {}, state: {} }); |
| @@ -422,7 +472,8 @@ | ||
| 422 | 472 | statusFns(scope: Scope): StatusFns { |
| 423 | 473 | return { |
| 424 | 474 | success: () => !scope.failed && this.stopping === null, |
| 425 | - failure: () => scope.failed, | |
| 475 | + // GitHub: once the job is cancelled its status is cancelled, so failure() is false. | |
| 476 | + failure: () => scope.failed && this.stopping === null, | |
| 426 | 477 | cancelled: () => this.stopping !== null, |
| 427 | 478 | always: () => true, |
| 428 | 479 | }; |
| @@ -564,9 +615,9 @@ | ||
| 564 | 615 | } else { |
| 565 | 616 | throw new StepFailure("Step has neither `run` nor `uses`."); |
| 566 | 617 | } |
| 567 | - if (this.stopping) outcome = "cancelled"; | |
| 618 | + if (this.halting) outcome = "cancelled"; | |
| 568 | 619 | } catch (e) { |
| 569 | - if (this.stopping) { | |
| 620 | + if (this.halting) { | |
| 570 | 621 | outcome = "cancelled"; |
| 571 | 622 | } else { |
| 572 | 623 | outcome = "failure"; |
| @@ -685,7 +736,8 @@ | ||
| 685 | 736 | const msg = "The platform stopped this step's processes while stopping the runner."; |
| 686 | 737 | this.logger.line(o.io.logIndex, `##[error]${msg}`); |
| 687 | 738 | this.addAnnotation(o.io.logIndex, { level: "error", message: msg }); |
| 688 | - this.cancel("cancelled"); | |
| 739 | + // No steps run after this cancel: the platform is stopping the container, and the job may start again elsewhere. | |
| 740 | + this.cancel("cancelled", 0); | |
| 689 | 741 | } |
| 690 | 742 | if (res.timedOut) { |
| 691 | 743 | const mins = (o.timeoutMs ?? 0) / 60000; |
| @@ -773,7 +825,8 @@ | ||
| 773 | 825 | let index = this.spec.steps.length; |
| 774 | 826 | for (const p of posts) { |
| 775 | 827 | const logIndex = index++; |
| 776 | - if (this.stopping) break; | |
| 828 | + if (this.forced) break; | |
| 829 | + this.resumeAfterCancel(); | |
| 777 | 830 | let ok: boolean; |
| 778 | 831 | try { |
| 779 | 832 | ok = evaluateIf(p.condition, this.contexts(top, this.baseEnvContext(top)), this.statusFns(top), this.extra()); |
| @@ -796,7 +849,7 @@ | ||
| 796 | 849 | top.failed = true; |
| 797 | 850 | this.noteFailure(logIndex, msg); |
| 798 | 851 | } |
| 799 | - if (this.stopping) conclusion = "cancelled"; | |
| 852 | + if (this.halting) conclusion = "cancelled"; | |
| 800 | 853 | await this.report(logIndex, { status: "completed", conclusion, name: p.name, startedAt, completedAt: nowIso() }); |
| 801 | 854 | } |
| 802 | 855 | } |
modifiedcontainers/runner/test/job.test.ts+67−6View file
| @@ -478,22 +478,83 @@ | ||
| 478 | 478 | expect(api.logText(1)).toContain("after-timeout"); |
| 479 | 479 | }); |
| 480 | 480 | |
| 481 | - it("cancels on request: kills the process group and reports cancelled", async () => { | |
| 481 | + const cancelOnce = async (steps: StepSpec[], cancel: (r: JobRunner) => void) => { | |
| 482 | 482 | let runner: JobRunner | undefined; |
| 483 | - const spec = makeSpec(api, [{ run: "echo started; (sleep 60 & wait)" }, { run: "echo never" }, { if: "always()", run: "echo never2" }]); | |
| 484 | - const p = run(spec, { onRunner: (r) => (runner = r) }); | |
| 483 | + const p = run(makeSpec(api, steps), { onRunner: (r) => (runner = r) }); | |
| 485 | 484 | const t0 = Date.now(); |
| 486 | 485 | while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50)); |
| 487 | - runner!.cancel(); | |
| 488 | - const c = await p; | |
| 486 | + cancel(runner!); | |
| 487 | + return { c: await p, ms: Date.now() - t0 }; | |
| 488 | + }; | |
| 489 | + | |
| 490 | + it("cancels on request: kills the process group, then runs only the steps that run after a cancel", async () => { | |
| 491 | + const { c, ms } = await cancelOnce( | |
| 492 | + [ | |
| 493 | + { run: "echo started; (sleep 60 & wait)" }, | |
| 494 | + { run: "echo never" }, | |
| 495 | + { if: "failure()", run: "echo never-failure" }, | |
| 496 | + { if: "always()", run: "echo always-ran" }, | |
| 497 | + { if: "${{ cancelled() }}", run: 'echo "cancelled-ran $JOB_STATUS"', env: { JOB_STATUS: "${{ job.status }}" } }, | |
| 498 | + { if: "!cancelled()", run: "echo never-not-cancelled" }, | |
| 499 | + ], | |
| 500 | + (r) => r.cancel(), | |
| 501 | + ); | |
| 489 | 502 | expect(c.conclusion).toBe("cancelled"); |
| 503 | + expect(c.failure?.message).toBe("The job was cancelled."); | |
| 490 | 504 | expect(api.lastReport(0)?.conclusion).toBe("cancelled"); |
| 491 | 505 | expect(api.lastReport(1)?.conclusion).toBe("cancelled"); |
| 492 | 506 | expect(api.lastReport(2)?.conclusion).toBe("cancelled"); |
| 507 | + expect(api.lastReport(3)?.conclusion).toBe("success"); | |
| 508 | + expect(api.lastReport(4)?.conclusion).toBe("success"); | |
| 509 | + expect(api.lastReport(5)?.conclusion).toBe("cancelled"); | |
| 510 | + expect(api.logText(3)).toContain("always-ran"); | |
| 511 | + expect(api.logText(4)).toContain("cancelled-ran cancelled"); | |
| 512 | + expect(api.logText()).not.toContain("never"); | |
| 493 | 513 | expect(api.completion?.conclusion).toBe("cancelled"); |
| 494 | - expect(Date.now() - t0).toBeLessThan(10_000); | |
| 514 | + expect(ms).toBeLessThan(10_000); | |
| 495 | 515 | }); |
| 496 | 516 | |
| 517 | + it("a second cancel stops the steps that run after a cancel", async () => { | |
| 518 | + const steps: StepSpec[] = [{ run: "echo started; sleep 60" }, { if: "always()", run: "echo cleanup-started; sleep 60" }, { if: "always()", run: "echo never" }]; | |
| 519 | + let runner: JobRunner | undefined; | |
| 520 | + const p = run(makeSpec(api, steps), { onRunner: (r) => (runner = r) }); | |
| 521 | + const t0 = Date.now(); | |
| 522 | + while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50)); | |
| 523 | + runner!.cancel(); | |
| 524 | + while (!api.logText(1).includes("cleanup-started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50)); | |
| 525 | + runner!.cancel(); | |
| 526 | + const c = await p; | |
| 527 | + expect(c.conclusion).toBe("cancelled"); | |
| 528 | + expect(api.lastReport(1)?.conclusion).toBe("cancelled"); | |
| 529 | + expect(api.lastReport(2)?.conclusion).toBe("cancelled"); | |
| 530 | + expect(api.logText(2)).not.toContain("never"); | |
| 531 | + expect(Date.now() - t0).toBeLessThan(15_000); | |
| 532 | + }); | |
| 533 | + | |
| 534 | + it("stops the steps that run after a cancel once their grace runs out", async () => { | |
| 535 | + let runner: JobRunner | undefined; | |
| 536 | + const p = run(makeSpec(api, [{ run: "echo started; sleep 60" }, { if: "always()", run: "echo cleanup-started; sleep 60" }, { if: "always()", run: "echo never" }]), { onRunner: (r) => (runner = r) }); | |
| 537 | + const t0 = Date.now(); | |
| 538 | + while (!api.logText(0).includes("started") && Date.now() - t0 < 10_000) await new Promise((r) => setTimeout(r, 50)); | |
| 539 | + runner!.cancel("cancelled", 1500); | |
| 540 | + const c = await p; | |
| 541 | + expect(c.conclusion).toBe("cancelled"); | |
| 542 | + expect(api.logText(1)).toContain("cleanup-started"); | |
| 543 | + expect(api.lastReport(1)?.conclusion).toBe("cancelled"); | |
| 544 | + expect(api.lastReport(2)?.conclusion).toBe("cancelled"); | |
| 545 | + expect(api.logText()).toContain("took longer than"); | |
| 546 | + expect(Date.now() - t0).toBeLessThan(15_000); | |
| 547 | + }); | |
| 548 | + | |
| 549 | + it("runs always() steps and post steps after the job's timeout", async () => { | |
| 550 | + const c = await run(makeSpec(api, [{ run: "echo started; sleep 60" }, { run: "echo never" }, { if: "always()", run: "echo after-job-timeout" }], { timeoutMinutes: 0.03 })); | |
| 551 | + expect(c.conclusion).toBe("failure"); | |
| 552 | + expect(c.failure?.message).toContain("maximum execution time"); | |
| 553 | + expect(api.lastReport(1)?.conclusion).toBe("cancelled"); | |
| 554 | + expect(api.lastReport(2)?.conclusion).toBe("success"); | |
| 555 | + expect(api.logText(2)).toContain("after-job-timeout"); | |
| 556 | + }); | |
| 557 | + | |
| 497 | 558 | it("never loses log lines when the log endpoint flakes", async () => { |
| 498 | 559 | api.failLogs = 2; |
| 499 | 560 | const c = await run(makeSpec(api, [{ run: "for i in $(seq 1 500); do echo \"n$i\"; done" }])); |