From c83f25bdd3f475f6a5f800292f7167043230b3bd Mon Sep 17 00:00:00 2001 From: Regan Bell Date: Tue, 4 Aug 2026 11:53:15 -0700 Subject: [PATCH] deploy: show a warming page while a cold-started app wakes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deployed apps on sleeping microVMs hung for the full 20s dial timeout and then showed raw gateway JSON — they looked broken while merely waking. The deployment proxy now tracks when each upstream last answered; a document GET/HEAD to an upstream not seen healthy recently gets a short first-byte window, and on a dial timeout or connection failure the browser receives an auto-refreshing warming page instead of an error blob. Applies to every proxy path (path-prefix, subdomain, and admin routes) since they share the layer. --- src/api/routes/deployments.ts | 115 ++++++++++++++++++++++++++---- test/deploy-warming-page.test.ts | 117 +++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 12 deletions(-) create mode 100644 test/deploy-warming-page.test.ts diff --git a/src/api/routes/deployments.ts b/src/api/routes/deployments.ts index bff8821ef..53273a087 100644 --- a/src/api/routes/deployments.ts +++ b/src/api/routes/deployments.ts @@ -322,7 +322,8 @@ function proxyReachHttp2( return start(true); } if (res.destroyed || res.writableEnded) return; - if (!res.headersSent) sendJson(res, 502, { error: "bad_gateway", message: "deployment unreachable" }); + if (wantsWarmingPage(req, method) && !res.headersSent) sendWarmingPage(res); + else if (!res.headersSent) sendJson(res, 502, { error: "bad_gateway", message: "deployment unreachable" }); else res.destroy(); }; up.once("close", () => { @@ -330,14 +331,20 @@ function proxyReachHttp2( if (!responseStarted || !up.readableEnded || up.rstCode !== http2Constants.NGHTTP2_NO_ERROR) fail(); else if (!failureHandled && !res.destroyed && !res.writableEnded) res.end(); }); - up.setTimeout(deps.deployDialTimeoutMs ?? CONFIG_DEFAULTS.deployDialTimeoutMs, () => { - if (failureHandled) return; - failureHandled = true; - if (!res.headersSent) sendJson(res, 504, { error: "gateway_timeout", message: "deployment did not respond" }); - else res.end(); - up.close(http2Constants.NGHTTP2_CANCEL); - checkDeploymentHttp2Session(connection); - }); + const htmlNav = wantsWarmingPage(req, method); + up.setTimeout( + warmingDialTimeoutMs(`${host}:${port}`, htmlNav, deps.deployDialTimeoutMs ?? CONFIG_DEFAULTS.deployDialTimeoutMs), + () => { + if (failureHandled) return; + failureHandled = true; + if (htmlNav && !res.headersSent) sendWarmingPage(res); + else if (!res.headersSent) + sendJson(res, 504, { error: "gateway_timeout", message: "deployment did not respond" }); + else res.end(); + up.close(http2Constants.NGHTTP2_CANCEL); + checkDeploymentHttp2Session(connection); + }, + ); up.on("response", (responseHeaders) => { if (failureHandled || res.headersSent || res.destroyed || res.writableEnded) { up.close(http2Constants.NGHTTP2_CANCEL); @@ -345,6 +352,7 @@ function proxyReachHttp2( } responseStarted = true; up.setTimeout(0); + markUpstreamUp(`${host}:${port}`); armThrottleShield(`${host}:${port}`, Number(responseHeaders[":status"] ?? 0), up); const status = Number(responseHeaders[":status"] ?? 502); const safeHeaders = gatewaySafeResponseHeaders(responseHeaders); @@ -359,6 +367,80 @@ function proxyReachHttp2( start(false); } +// --- cold-start warming page ------------------------------------------------- +// AWS microVMs auto-resume on first connect, which can take many seconds. During +// that window a browser navigation would otherwise hang for the full dial timeout +// and then land on raw gateway JSON. For document requests we instead answer +// quickly with a small self-refreshing "warming up" page. +const WARM_RECENT_MS = 60_000; +const COLD_FIRST_BYTE_TIMEOUT_MS = 4_000; +const upstreamLastOk = new Map(); + +function markUpstreamUp(upstreamKey: string): void { + if (upstreamLastOk.size > 1000) { + for (const [k, at] of upstreamLastOk) if (Date.now() - at > WARM_RECENT_MS) upstreamLastOk.delete(k); + } + upstreamLastOk.set(upstreamKey, Date.now()); +} + +function wantsWarmingPage(req: BaseCtx["req"], method: string): boolean { + if (method !== "GET" && method !== "HEAD") return false; + const dest = String(req.headers["sec-fetch-dest"] ?? ""); + if (dest && dest !== "document") return false; + return String(req.headers.accept ?? "").includes("text/html"); +} + +function warmingDialTimeoutMs(upstreamKey: string, htmlNav: boolean, configuredMs: number): number { + if (!htmlNav) return configuredMs; + const lastOk = upstreamLastOk.get(upstreamKey) ?? 0; + if (Date.now() - lastOk < WARM_RECENT_MS) return configuredMs; + return Math.min(configuredMs, COLD_FIRST_BYTE_TIMEOUT_MS); +} + +const WARMING_PAGE_HTML = ` + +Starting up… + +

Starting up…

+

+`; + +function sendWarmingPage(res: BaseCtx["res"]): void { + if (res.headersSent || res.destroyed || res.writableEnded) { + res.end(); + return; + } + res.writeHead(503, { + "content-type": "text/html; charset=utf-8", + "cache-control": "no-store", + "retry-after": "2", + }); + res.end(WARMING_PAGE_HTML); +} +// ----------------------------------------------------------------------------- + async function proxyReach( ctx: BaseCtx, reach: Awaited>, @@ -411,21 +493,30 @@ async function proxyReach( proxyReachHttp2(ctx, reach, subPath, headers, bufferedBody); return; } + const htmlNav = wantsWarmingPage(req, method); const up = requestFn({ hostname: host, port, path: subPath + url.search, method, headers }, (upRes) => { up.setTimeout(0); + markUpstreamUp(upstreamKey); upRes.on("error", () => res.destroy()); armThrottleShield(upstreamKey, upRes.statusCode ?? 0, upRes); const headers = gatewaySafeResponseHeaders(upRes.headers); res.writeHead(upRes.statusCode ?? 502, headers); upRes.pipe(res); }); - up.setTimeout(deps.deployDialTimeoutMs ?? CONFIG_DEFAULTS.deployDialTimeoutMs, () => { - if (!res.headersSent) sendJson(res, 504, { error: "gateway_timeout", message: "deployment did not respond" }); + const dialMs = warmingDialTimeoutMs( + upstreamKey, + htmlNav, + deps.deployDialTimeoutMs ?? CONFIG_DEFAULTS.deployDialTimeoutMs, + ); + up.setTimeout(dialMs, () => { + if (htmlNav && !res.headersSent) sendWarmingPage(res); + else if (!res.headersSent) sendJson(res, 504, { error: "gateway_timeout", message: "deployment did not respond" }); else res.end(); up.destroy(); }); up.on("error", () => { - if (!res.headersSent) sendJson(res, 502, { error: "bad_gateway", message: "deployment unreachable" }); + if (htmlNav && !res.headersSent) sendWarmingPage(res); + else if (!res.headersSent) sendJson(res, 502, { error: "bad_gateway", message: "deployment unreachable" }); else res.end(); }); req.on("error", () => up.destroy()); diff --git a/test/deploy-warming-page.test.ts b/test/deploy-warming-page.test.ts new file mode 100644 index 000000000..e6e493403 --- /dev/null +++ b/test/deploy-warming-page.test.ts @@ -0,0 +1,117 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { createServer as createHttpServer } from "node:http"; +import type { AddressInfo } from "node:net"; +import { createInsecureTestServer } from "../src/api/server.ts"; +import type { App } from "../src/api/app.ts"; + +function appWith(endpoint: Record): App { + return { reachDeployment: async () => ({ status: "ok", endpoint }) } as unknown as App; +} + +test("/d/ proxy serves the warming page to a browser navigation when the deployment hangs", async () => { + const held: import("node:net").Socket[] = []; + const upstream = createHttpServer((req) => { + held.push(req.socket); + }); + upstream.listen(0); + const upstreamPort = (upstream.address() as AddressInfo).port; + + const server = createInsecureTestServer(appWith({ host: "127.0.0.1", port: upstreamPort }), { + deployDialTimeoutMs: 200, + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + try { + const res = await fetch(`${base}/d/some-id/`, { + headers: { accept: "text/html,application/xhtml+xml", "sec-fetch-dest": "document" }, + }); + assert.equal(res.status, 503); + assert.match(String(res.headers.get("content-type")), /text\/html/); + assert.equal(res.headers.get("retry-after"), "2"); + const body = await res.text(); + assert.match(body, /starting up/i); + assert.match(body, /location\.reload/); + } finally { + for (const s of held) s.destroy(); + await new Promise((r) => server.close(() => r())); + await new Promise((r) => upstream.close(() => r())); + } +}); + +test("/d/ proxy serves the warming page to a browser navigation when the deployment refuses connections", async () => { + const upstream = createHttpServer(() => {}); + upstream.listen(0); + const upstreamPort = (upstream.address() as AddressInfo).port; + await new Promise((r) => upstream.close(() => r())); + + const server = createInsecureTestServer(appWith({ host: "127.0.0.1", port: upstreamPort })); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + try { + const res = await fetch(`${base}/d/some-id/`, { headers: { accept: "text/html" } }); + assert.equal(res.status, 503); + assert.match(await res.text(), /starting up/i); + } finally { + await new Promise((r) => server.close(() => r())); + } +}); + +test("/d/ proxy keeps JSON gateway errors for non-document requests", async () => { + const held: import("node:net").Socket[] = []; + const upstream = createHttpServer((req) => { + held.push(req.socket); + }); + upstream.listen(0); + const upstreamPort = (upstream.address() as AddressInfo).port; + + const server = createInsecureTestServer(appWith({ host: "127.0.0.1", port: upstreamPort }), { + deployDialTimeoutMs: 200, + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + try { + const apiRes = await fetch(`${base}/d/some-id/api/data`, { headers: { accept: "application/json" } }); + assert.equal(apiRes.status, 504); + assert.equal(((await apiRes.json()) as { error?: string }).error, "gateway_timeout"); + + const postRes = await fetch(`${base}/d/some-id/`, { + method: "POST", + headers: { accept: "text/html", "content-type": "text/plain", "content-length": "2" }, + body: "hi", + }); + assert.equal(postRes.status, 504); + assert.equal(((await postRes.json()) as { error?: string }).error, "gateway_timeout"); + } finally { + for (const s of held) s.destroy(); + await new Promise((r) => server.close(() => r())); + await new Promise((r) => upstream.close(() => r())); + } +}); + +test("a recently-healthy upstream keeps the full dial timeout for slow pages", async () => { + let slow = false; + const upstream = createHttpServer((req, res) => { + if (slow) setTimeout(() => res.end("slow-ok"), 300); + else res.end("fast-ok"); + }); + upstream.listen(0); + const upstreamPort = (upstream.address() as AddressInfo).port; + + const server = createInsecureTestServer(appWith({ host: "127.0.0.1", port: upstreamPort }), { + deployDialTimeoutMs: 1000, + }); + server.listen(0); + const base = `http://localhost:${(server.address() as AddressInfo).port}`; + try { + const first = await fetch(`${base}/d/some-id/`, { headers: { accept: "text/html" } }); + assert.equal(await first.text(), "fast-ok"); + slow = true; + const second = await fetch(`${base}/d/some-id/`, { headers: { accept: "text/html" } }); + assert.equal(second.status, 200); + assert.equal(await second.text(), "slow-ok"); + } finally { + await new Promise((r) => server.close(() => r())); + await new Promise((r) => upstream.close(() => r())); + } +});