Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

150 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(started: number | null | undefined, capMinutes: number | null | undefined, now: number, graceSeconds: number): boolean {
61 if (typeof started !== "number" || typeof capMinutes !== "number" || capMinutes <= 0) return false;
62 return now < started + (capMinutes * 60 + graceSeconds) * 1000;
63}
64
65/**
66 * Whether the run a sandbox holds now has used its whole time cap, so a
67 * `timeUp` alarm may stop it. A sandbox's Durable Object is named for its
68 * work (`actions:{job}` and the like), and a re-run of that work starts in
69 * the same object; an alarm an earlier attempt left must not stop the
70 * attempt running now, which started later and has its own alarm.
71 */
72export function capReached(started: number | null | undefined, capMinutes: number | null | undefined, now: number): boolean {
73 if (typeof started !== "number" || typeof capMinutes !== "number" || capMinutes <= 0) return false;
74 return now >= started + capMinutes * 60 * 1000;
75}
76
77/**
78 * Whether a sandbox's stop ends its work as a failure. A sandbox g1t
79 * stopped (at its time cap, say) can come back with exit code 0, since the
80 * container library reports a destroyed container as a clean exit; the
81 * reason g1t stopped it, not the exit code, says how it ended.
82 */
83export function stoppedShort(exitCode: number, why: string | null): boolean {
84 return exitCode !== 0 || why !== null;
85}
86
87/** An error as one line, for a log: its message, or what was thrown. */
88export function describeError(error: unknown): string {
89 if (error instanceof Error) return error.message || error.name;
90 if (error === undefined) return "no reason given";
91 if (typeof error === "string") return error;
92 try {
93 return JSON.stringify(error) ?? String(error);
94 } catch {
95 return String(error);
96 }
97}
98
99/**
100 * Sandbox time billing has not confirmed yet, kept in the sandbox's
101 * storage until it does. `usage` is fixed when the sandbox stopped, so a
102 * later try records the same seconds under the same reference, which
103 * billing records once whatever it is sent (`record_sandbox`).
104 */
105export type Owed<U extends { reference: string }> = {
106 usage: U;
107 /** Tries so far that billing did not confirm. */
108 attempts: number;
109 /** When the sandbox stopped, in ms. */
110 since: number;
111};
112
113/** The longest wait between tries: an hour. */
114export const OWED_MAX_DELAY_SECONDS = 60 * 60;
115/** How long sandbox time is tried for before it is logged as lost: a week. */
116export const OWED_GIVE_UP_MS = 7 * 24 * 60 * 60 * 1000;
117
118/** The wait before the next try, after `attempts` failed ones: 30 seconds, doubling, at most an hour. */
119export function owedDelaySeconds(attempts: number): number {
120 return Math.min(OWED_MAX_DELAY_SECONDS, 30 * 2 ** Math.min(16, Math.max(0, attempts - 1)));
121}
122
123/**
124 * Tries to record each owed usage once. Returns what billing confirmed
125 * (to forget), what to keep for another try (with its attempts counted),
126 * what has been tried for too long (to log and forget), and the wait before
127 * the next try, or null when nothing is kept. `record` resolves true only
128 * when billing confirmed it; a throw is a failure like any other.
129 */
130export async function recordOwed<U extends { reference: string }>(
131 owed: Owed<U>[],
132 record: (usage: U) => Promise<boolean>,
133 now: number,
134): Promise<{ recorded: Owed<U>[]; kept: Owed<U>[]; lost: Owed<U>[]; retryInSeconds: number | null }> {
135 const recorded: Owed<U>[] = [];
136 const kept: Owed<U>[] = [];
137 const lost: Owed<U>[] = [];
138 for (const entry of owed) {
139 const ok = await record(entry.usage).catch(() => false);
140 if (ok) {
141 recorded.push(entry);
142 continue;
143 }
144 const next = { ...entry, attempts: entry.attempts + 1 };
145 if (now - entry.since >= OWED_GIVE_UP_MS) lost.push(next);
146 else kept.push(next);
147 }
148 const retryInSeconds = kept.length ? Math.min(...kept.map((entry) => owedDelaySeconds(entry.attempts))) : null;
149 return { recorded, kept, lost, retryInSeconds };
150}