| 1 | /** |
| 2 | * When a job's page asks for more of its log (components/actions.tsx, |
| 3 | * `useJobLog`): about every second while a step is running, every few |
| 4 | * seconds while the job waits between steps or for a machine, and, when |
| 5 | * an answer fails (a 429, a 5xx, the network), again after a wait that |
| 6 | * doubles up to half a minute, or as long as `Retry-After` says. Nothing |
| 7 | * stops it for good but the job finishing or being gone. |
| 8 | */ |
| 9 | |
| 10 | /** While a step is running. */ |
| 11 | export const RUNNING_POLL_MS = 1000; |
| 12 | /** While the job is live but no step is running: queued, starting, between steps. */ |
| 13 | export const IDLE_POLL_MS = 3000; |
| 14 | /** The first wait after a failure, doubled after each one that follows. */ |
| 15 | export const FIRST_RETRY_MS = 2000; |
| 16 | /** The longest wait between tries after failures. */ |
| 17 | export const MAX_RETRY_MS = 30_000; |
| 18 | /** A finished job's last fetch is tried this many times before it gives up. */ |
| 19 | export const FINISHED_TRIES = 5; |
| 20 | |
| 21 | export type PollAnswer = |
| 22 | /** An answer: whether the job is done, and whether the page was full (more is waiting). */ |
| 23 | | { ok: true; done: boolean; full: boolean } |
| 24 | /** No answer: its status, if there was one, and its `Retry-After` in milliseconds. */ |
| 25 | | { ok: false; status?: number; retryAfterMs?: number }; |
| 26 | |
| 27 | export type PollState = { |
| 28 | /** Failures in a row so far. */ |
| 29 | failures: number; |
| 30 | /** Whether the page shows the job as still going. */ |
| 31 | live: boolean; |
| 32 | /** Whether one of its steps is running now. */ |
| 33 | running: boolean; |
| 34 | }; |
| 35 | |
| 36 | /** How long to wait before the next fetch, or null to stop; and the failures in a row after this answer. */ |
| 37 | export function nextPoll(answer: PollAnswer, state: PollState): { delayMs: number | null; failures: number } { |
| 38 | if (answer.ok) { |
| 39 | if (answer.full) return { delayMs: 0, failures: 0 }; |
| 40 | if (answer.done || !state.live) return { delayMs: null, failures: 0 }; |
| 41 | return { delayMs: state.running ? RUNNING_POLL_MS : IDLE_POLL_MS, failures: 0 }; |
| 42 | } |
| 43 | const failures = state.failures + 1; |
| 44 | // The job is not there (deleted, or the repository is gone): nothing to wait for. |
| 45 | if (answer.status === 404) return { delayMs: null, failures }; |
| 46 | if (!state.live && failures >= FINISHED_TRIES) return { delayMs: null, failures }; |
| 47 | const backoff = Math.min(MAX_RETRY_MS, FIRST_RETRY_MS * 2 ** (failures - 1)); |
| 48 | const asked = answer.retryAfterMs !== undefined ? Math.min(answer.retryAfterMs, 2 * MAX_RETRY_MS) : 0; |
| 49 | return { delayMs: Math.max(backoff, asked), failures }; |
| 50 | } |
| 51 | |
| 52 | /** How long a running step may print nothing before its log says how long it has been quiet. */ |
| 53 | export const QUIET_AFTER_MS = 10_000; |
| 54 | |
| 55 | /** `45s`, `2m 10s`, `1h 5m`, as the run's page counts time. */ |
| 56 | export function elapsed(ms: number): string { |
| 57 | const seconds = Math.max(0, Math.round(ms / 1000)); |
| 58 | if (seconds < 60) return `${seconds}s`; |
| 59 | const minutes = Math.floor(seconds / 60); |
| 60 | if (minutes < 60) return `${minutes}m ${seconds % 60}s`; |
| 61 | return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; |
| 62 | } |
| 63 | |
| 64 | /** |
| 65 | * What a running step's log says under its last line: that nothing has |
| 66 | * come yet, and for how long, or, once it has gone quiet for a while |
| 67 | * (a build that prints nothing as it links, a download), for how long it |
| 68 | * has printed nothing. Null while output is flowing. Times in ms. |
| 69 | */ |
| 70 | export function quietWords(started: number | null, lastOutput: number | null, now: number): string | null { |
| 71 | if (lastOutput === null) return started === null ? "Running · no output yet" : `Running · no output yet, ${elapsed(now - started)}`; |
| 72 | if (now - lastOutput < QUIET_AFTER_MS) return null; |
| 73 | return `Running · no output for ${elapsed(now - lastOutput)}`; |
| 74 | } |
| 75 | |
| 76 | /** `Retry-After` in milliseconds: seconds, or an HTTP date. */ |
| 77 | export function retryAfterMs(header: string | null, now = Date.now()): number | undefined { |
| 78 | if (!header) return undefined; |
| 79 | const seconds = Number(header.trim()); |
| 80 | if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000; |
| 81 | const at = Date.parse(header); |
| 82 | return Number.isFinite(at) ? Math.max(0, at - now) : undefined; |
| 83 | } |