Skip to content

Commit 11fed05

Browse files
Merge pull request #1206 from deco-cx/fix/sandbox-eager-warm-on-deploy
fix(daemon): robust sandbox cold-start (eager warm + fast-503 + optional warmup)
2 parents 1869ad7 + 9ed25c3 commit 11fed05

3 files changed

Lines changed: 99 additions & 5 deletions

File tree

blocks/loader.bench.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,8 @@
1-
import hash from "https://esm.sh/v135/object-hash@3.0.0";
1+
// `?no-dts` skips esm.sh's bundled type definitions. Upstream `@types/object-hash`
2+
// uses a CommonJS `export =` with no synthetic default, which makes Deno's
3+
// type-check fail with TS1192 ("no default export") on a fresh fetch. The types
4+
// aren't needed in this benchmark, so dropping them keeps CI deterministic.
5+
import hash from "https://esm.sh/v135/object-hash@3.0.0?no-dts";
26

37
const props = {
48
randomObject: {

daemon/main.ts

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ import { register, type TunnelConnection } from "./tunnel.ts";
3838
import {
3939
createWorker,
4040
resetWorkerState,
41+
WARMUP_HEADER,
42+
WARMUP_TOKEN,
4143
worker,
4244
type WorkerOptions,
4345
} from "./worker.ts";
@@ -604,6 +606,41 @@ if (SANDBOX_MODE) {
604606
branch,
605607
});
606608

609+
// Eagerly kick off deps init (git clone, build-cache download, manifest
610+
// + blocks generation) as soon as the env is assigned, instead of
611+
// waiting for the first HTTP request to trigger it lazily. On a freshly
612+
// claimed sandbox this cold-start work can take tens of seconds; paying
613+
// it inline on the first request risks exceeding the CDN origin timeout
614+
// and surfacing to the user as a 504 (the client abort is logged as a
615+
// 499 at the ingress). ensureStarted() is idempotent — the AI-task path
616+
// below still awaits gitReady without re-triggering init.
617+
currentSite.ensureStarted();
618+
619+
// Optional deep warmup: once the repo is cloned, issue a single internal
620+
// request to the site root so the dev server JIT-compiles and renders the
621+
// entry route before the first real user request arrives — otherwise that
622+
// compile + render is paid inline on the user's first hit. Off by default
623+
// because it triggers one synthetic homepage render (and any server-side
624+
// analytics it fires); the x-deco-warmup header (carrying the per-process
625+
// token) marks it as our internal warmup so it bypasses the fast-503 gate
626+
// and the site can opt out of side effects. Enable with
627+
// DECO_SANDBOX_WARMUP=true.
628+
if (Deno.env.get("DECO_SANDBOX_WARMUP") === "true") {
629+
const { app: siteApp, gitReady } = currentSite;
630+
gitReady
631+
.then(() =>
632+
siteApp.fetch(
633+
new Request("http://localhost/", {
634+
headers: { [WARMUP_HEADER]: WARMUP_TOKEN },
635+
}),
636+
)
637+
)
638+
.then(() => console.log("[sandbox] warmup request completed"))
639+
.catch((err) =>
640+
console.error("[sandbox] warmup request failed:", err)
641+
);
642+
}
643+
607644
// Always create AI handlers — OAuth can be used when no API key is set
608645
aiHandlers = createAIHandlers({
609646
cwd: Deno.cwd(),
@@ -630,9 +667,6 @@ if (SANDBOX_MODE) {
630667
Boolean(envs?.ANTHROPIC_PROXY_URL);
631668
if (hasApiKey) {
632669
const handlers = aiHandlers;
633-
// Eagerly trigger deps init (git clone, etc.) so the task doesn't wait
634-
// for the first HTTP request to arrive
635-
currentSite.ensureStarted();
636670
// Wait for git clone to finish before starting the AI task,
637671
// since the task needs a valid repo (git rev-parse HEAD, etc.)
638672
currentSite.gitReady.then(async () => {

daemon/worker.ts

Lines changed: 57 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,23 @@
11
import { Hono } from "@hono/hono";
22
import { broadcast } from "./sse/channel.ts";
3+
import { SANDBOX_MODE } from "./daemon.ts";
4+
import { delay } from "../utils/async.ts";
35
import { DenoRun } from "./workers/denoRun.ts";
46

7+
// How long a request waits for a cold dev server to come up before we stop
8+
// holding the connection open and reply 503 (SANDBOX_MODE only). Kept well
9+
// under the CDN origin timeout so the env ingress can bounce to the activator
10+
// and retry, instead of the request hanging until the CDN returns a 504.
11+
const SANDBOX_READY_GATE_MS = 5_000;
12+
13+
// Header + per-process token used by the daemon's own internal warmup request
14+
// to bypass the fast-503 gate (it must block until the worker is ready so it
15+
// can actually render the entry route). The token is random per process so a
16+
// forged `x-deco-warmup` header on public traffic can't match and is treated
17+
// as a normal request.
18+
export const WARMUP_HEADER = "x-deco-warmup";
19+
export const WARMUP_TOKEN: string = crypto.randomUUID();
20+
521
export interface WorkerOptions {
622
persist: () => void;
723
command: Deno.Command;
@@ -97,7 +113,47 @@ export const createWorker = (optionsProvider: WorkerOptionsProvider) => {
97113
// ensure isolate is up and running
98114
app.use("/*", async (c, next) => {
99115
try {
100-
await worker();
116+
// Warmup requests (and normal, non-sandbox mode) keep the original
117+
// blocking behavior: wait for the worker to be fully ready, then proxy.
118+
// This is what lets the warmup request actually JIT-compile and render
119+
// the entry route — a fast-503 gate would skip the render entirely.
120+
const isWarmup = c.req.header(WARMUP_HEADER) === WARMUP_TOKEN;
121+
if (SANDBOX_MODE && !isWarmup) {
122+
// worker() boots the dev server (idempotent) and resolves once it is
123+
// listening; it rejects if the boot fails (missing dev.ts, crash, ...).
124+
// On a cold sandbox the boot can take a while, so rather than hold the
125+
// request open the whole time — which lets the CDN time out as a 504 —
126+
// we race it against a short gate:
127+
// - still booting at the gate -> 503 (retryable): the env ingress
128+
// (error_page 502 503 -> @admin) bounces to the activator and the
129+
// boot keeps running in the background, so the retry lands warm.
130+
// - boot rejected -> 424 (non-retryable): a real failure
131+
// that won't fix itself, so we don't loop the activator forever.
132+
// Mapping the rejection inline (not throwing) keeps the loser of the
133+
// race from surfacing as an unhandled rejection once we've replied.
134+
const boot = worker().then(
135+
() => "ready" as const,
136+
(err: unknown) => ({ err }),
137+
);
138+
const outcome = await Promise.race([
139+
boot,
140+
delay(SANDBOX_READY_GATE_MS).then(() => "timeout" as const),
141+
]);
142+
if (outcome === "timeout") {
143+
c.res = new Response("Sandbox environment is starting", {
144+
status: 503,
145+
headers: { "retry-after": "2" },
146+
});
147+
return;
148+
}
149+
if (typeof outcome === "object") {
150+
console.error(outcome.err);
151+
c.res = new Response(`Error while starting worker`, { status: 424 });
152+
return;
153+
}
154+
} else {
155+
await worker();
156+
}
101157
await next();
102158
} catch (error) {
103159
console.error(error);

0 commit comments

Comments
 (0)