Skip to content
80 linesCodeBlameRaw
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. */
12export 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 */
21export 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 */
48export 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 */
60export 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. */
71export 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}