| 1 | /** |
| 2 | * How a sandbox's Durable Object handles the end of its container without |
| 3 | * failing the invocation it happens in. The containers library calls |
| 4 | * `onStop` from the object's alarm: a stop hook that throws fails that |
| 5 | * alarm, which Cloudflare retries and counts as an error each time, and |
| 6 | * which runs the whole hook again. So the hook's calls to other services |
| 7 | * go through `tellStopped`, which tries twice and logs at error level, and |
| 8 | * never throws. Pure, so it is tested on its own. |
| 9 | */ |
| 10 | |
| 11 | /** How long `tellStopped` waits before its second try. */ |
| 12 | export const RETRY_AFTER_MS = 500; |
| 13 | |
| 14 | /** |
| 15 | * Tells another service that a sandbox stopped: `step` runs, and once more |
| 16 | * if it throws. Never throws: a step that fails twice is logged with |
| 17 | * `log` as `sandbox stop not reported`, with what and why, so it shows in |
| 18 | * the runner's logs at error level and the five-minute sweep picks the |
| 19 | * work up instead. Returns whether it got through. |
| 20 | */ |
| 21 | export async function tellStopped( |
| 22 | what: string, |
| 23 | step: () => Promise<unknown>, |
| 24 | options: { log?: (...data: unknown[]) => void; attempts?: number; waitMs?: number } = {}, |
| 25 | ): Promise<boolean> { |
| 26 | const log = options.log ?? console.error; |
| 27 | const attempts = Math.max(1, options.attempts ?? 2); |
| 28 | let last: unknown = null; |
| 29 | for (let attempt = 1; attempt <= attempts; attempt++) { |
| 30 | try { |
| 31 | await step(); |
| 32 | return true; |
| 33 | } catch (error) { |
| 34 | last = error; |
| 35 | if (attempt < attempts) await new Promise((resolve) => setTimeout(resolve, options.waitMs ?? RETRY_AFTER_MS)); |
| 36 | } |
| 37 | } |
| 38 | log("sandbox stop not reported", what, describeError(last)); |
| 39 | return false; |
| 40 | } |
| 41 | |
| 42 | /** |
| 43 | * A service binding's answer as a step's outcome: a 5xx (or 429) throws, so |
| 44 | * `tellStopped` tries again and logs it. A refusal is not a failure: the |
| 45 | * work already reported its end, and services say so with `ok: false` or a |
| 46 | * 4xx (the deployments service answers 404 for a build that has finished). |
| 47 | */ |
| 48 | export async function answered(what: string, response: Response): Promise<void> { |
| 49 | if (response.status < 500 && response.status !== 429) return; |
| 50 | const body = await response.text().catch(() => ""); |
| 51 | throw new Error(`${what} answered ${response.status}${body ? `: ${body.slice(0, 200)}` : ""}`); |
| 52 | } |
| 53 | |
| 54 | /** |
| 55 | * Whether a sandbox whose `sleepAfter` has passed is still inside its run's |
| 56 | * time cap, and so keeps running: the cap, not inactivity, ends a run (the |
| 57 | * runner never fetches the container, so to the library every sandbox looks |
| 58 | * idle). Without a cap or a start time, `sleepAfter` applies. |
| 59 | */ |
| 60 | export function withinTimeCap( |
| 61 | started: number | null | undefined, |
| 62 | capMinutes: number | null | undefined, |
| 63 | now: number, |
| 64 | graceSeconds: number, |
| 65 | ): boolean { |
| 66 | if (typeof started !== "number" || typeof capMinutes !== "number" || capMinutes <= 0) return false; |
| 67 | return now < started + (capMinutes * 60 + graceSeconds) * 1000; |
| 68 | } |
| 69 | |
| 70 | /** An error as one line, for a log: its message, or what was thrown. */ |
| 71 | export function describeError(error: unknown): string { |
| 72 | if (error instanceof Error) return error.message || error.name; |
| 73 | if (error === undefined) return "no reason given"; |
| 74 | if (typeof error === "string") return error; |
| 75 | try { |
| 76 | return JSON.stringify(error) ?? String(error); |
| 77 | } catch { |
| 78 | return String(error); |
| 79 | } |
| 80 | } |