Skip to content

Commit bb0b10f

Browse files
committed
Measure isolate reuse and the evaluated module closure directly
1 parent 4817fa1 commit bb0b10f

3 files changed

Lines changed: 197 additions & 1 deletion

File tree

.github/workflows/ci.yml

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ jobs:
1818
outputs:
1919
desktop_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.desktop_smoke == 'true' }}
2020
selfhost_docker_smoke: ${{ github.event_name != 'pull_request' || steps.filter.outputs.selfhost_docker_smoke == 'true' }}
21+
cloud_closure: ${{ github.event_name != 'pull_request' || steps.filter.outputs.cloud_closure == 'true' }}
2122
steps:
2223
- uses: actions/checkout@v4
2324

@@ -41,6 +42,14 @@ jobs:
4142
- "packages/kernel/runtime-quickjs/**"
4243
- "packages/plugins/**"
4344
- "packages/react/**"
45+
cloud_closure:
46+
- ".github/workflows/**"
47+
- "bun.lock"
48+
- "package.json"
49+
- "turbo.json"
50+
- "apps/cloud/**"
51+
- "packages/**"
52+
4453
selfhost_docker_smoke:
4554
- ".github/workflows/**"
4655
- ".dockerignore"
@@ -302,6 +311,50 @@ jobs:
302311
path: e2e/runs/
303312
retention-days: 7
304313

314+
cloud-closure:
315+
name: Cloud evaluated closure
316+
needs: changes
317+
if: needs.changes.outputs.cloud_closure == 'true'
318+
runs-on: blacksmith-4vcpu-ubuntu-2404
319+
timeout-minutes: 20
320+
env:
321+
TURBO_API: ${{ vars.TURBO_API }}
322+
TURBO_TEAM: ${{ vars.TURBO_TEAM }}
323+
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
324+
TURBO_REMOTE_CACHE_SIGNATURE_KEY: ${{ secrets.TURBO_REMOTE_CACHE_SIGNATURE_KEY }}
325+
# Every cold isolate evaluates this closure before it can answer a
326+
# request, and it only ever grows by accident - a barrel export or a new
327+
# module-scope import quietly pulls megabytes into the server graph. The
328+
# budget is a ratchet just above today's size, not a discovered limit:
329+
# when it trips, make the new dependency lazy rather than raising it.
330+
START_CLOSURE_BUDGET_MB: 13.5
331+
steps:
332+
- uses: actions/checkout@v4
333+
334+
- uses: oven-sh/setup-bun@v2
335+
with:
336+
bun-version: 1.3.11
337+
338+
- name: Cache Bun package cache
339+
uses: actions/cache@v4
340+
with:
341+
path: ~/.bun/install/cache
342+
key: ${{ runner.os }}-bun-1.3.11-${{ hashFiles('bun.lock') }}
343+
restore-keys: |
344+
${{ runner.os }}-bun-1.3.11-
345+
346+
- uses: actions/setup-node@v4
347+
with:
348+
node-version: 24
349+
350+
- run: bun install --frozen-lockfile
351+
352+
- run: bun run build
353+
working-directory: apps/cloud
354+
355+
- run: node scripts/start-closure.mjs
356+
working-directory: apps/cloud
357+
305358
desktop-smoke:
306359
name: Desktop smoke build
307360
needs: changes
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
// ---------------------------------------------------------------------------
2+
// Measure the Worker's *evaluated* module closures from the build output.
3+
// ---------------------------------------------------------------------------
4+
//
5+
// Upload size does not predict cold-isolate cost: a Worker can ship megabytes
6+
// that never load, and the bytes that matter are the ones an isolate must
7+
// evaluate before it can answer. Rollup already separates static from dynamic
8+
// edges, so we can compute that split directly instead of reading totals.
9+
//
10+
// Two closures matter:
11+
// startup - statically reachable from the Worker entry. Evaluated on every
12+
// cold isolate before any request is served.
13+
// start - the TanStack Start server graph, reached through the lazy
14+
// `loadEntries` dynamic imports. Evaluated on the first request
15+
// that enters the app.
16+
//
17+
// Anything reachable only through a dynamic import is not counted: making a
18+
// heavy dependency lazy is exactly the outcome this rewards.
19+
//
20+
// Scope note: this measures bytes, which is a proxy for cold-start cost, not a
21+
// proven cause of any particular regression. The Aug 2026 MCP incident was
22+
// NOT explained by this number - reverting the offending packages moved the
23+
// evaluated closure by 0.02 MB while restoring production latency, so
24+
// module-scope execution cost, not size, drove that one. Treat a budget breach
25+
// as "this will make cold starts worse", not as "this is why the site is slow".
26+
import { readFileSync, readdirSync, statSync } from "node:fs";
27+
import { dirname, join, relative, resolve } from "node:path";
28+
29+
const DIST = resolve(process.argv[2] ?? "dist/server");
30+
const ENTRY = join(DIST, "index.js");
31+
32+
// Rollup emits `from "./x.js"`, bare `import "./x.js"`, `export ... from "./x.js"`
33+
// (all static) and `import("./x.js")` (dynamic). Matching the emitted output
34+
// rather than source means we see the graph as the runtime sees it.
35+
const STATIC_RE = /(?:from|import)\s*["'](\.[^"']+)["']/g;
36+
const DYNAMIC_RE = /import\(\s*["'](\.[^"']+)["']\s*\)/g;
37+
38+
const listChunks = (dir) =>
39+
readdirSync(dir, { withFileTypes: true }).flatMap((e) =>
40+
e.isDirectory()
41+
? listChunks(join(dir, e.name))
42+
: e.name.endsWith(".js")
43+
? [join(dir, e.name)]
44+
: [],
45+
);
46+
47+
const graph = new Map();
48+
for (const file of listChunks(DIST)) {
49+
const code = readFileSync(file, "utf8");
50+
const dynamic = new Set([...code.matchAll(DYNAMIC_RE)].map((m) => resolve(dirname(file), m[1])));
51+
// A specifier inside `import(...)` also matches STATIC_RE's `import` branch,
52+
// so subtract the dynamic set rather than trusting the static matches alone.
53+
const staticDeps = new Set(
54+
[...code.matchAll(STATIC_RE)]
55+
.map((m) => resolve(dirname(file), m[1]))
56+
.filter((p) => !dynamic.has(p)),
57+
);
58+
graph.set(file, { size: statSync(file).size, static: staticDeps, dynamic });
59+
}
60+
61+
/** Bytes evaluated when `roots` are loaded, following static edges only. */
62+
const closure = (roots) => {
63+
const seen = new Set();
64+
const queue = [...roots];
65+
while (queue.length > 0) {
66+
const file = queue.pop();
67+
if (seen.has(file) || !graph.has(file)) continue;
68+
seen.add(file);
69+
queue.push(...graph.get(file).static);
70+
}
71+
return seen;
72+
};
73+
74+
const bytes = (files) => [...files].reduce((sum, f) => sum + (graph.get(f)?.size ?? 0), 0);
75+
const mb = (n) => `${(n / 1024 / 1024).toFixed(2)} MB`;
76+
const name = (f) => relative(DIST, f);
77+
78+
const startup = closure([ENTRY]);
79+
// The lazy server-graph entries Start pulls on first request.
80+
const startRoots = [...graph.get(ENTRY).dynamic].filter((f) =>
81+
/(start|router|tanstack)/.test(name(f)),
82+
);
83+
const start = closure(startRoots);
84+
const evaluated = new Set([...startup, ...start]);
85+
86+
const report = (label, files) => {
87+
console.log(`\n${label}: ${mb(bytes(files))} (${files.size} chunks)`);
88+
const own = [...files].filter((f) => !startup.has(f) || label === "startup");
89+
for (const f of own.sort((a, b) => graph.get(b).size - graph.get(a).size).slice(0, 12)) {
90+
console.log(` ${(graph.get(f).size / 1024).toFixed(0).padStart(6)} KB ${name(f)}`);
91+
}
92+
};
93+
94+
report("startup", startup);
95+
report("start", start);
96+
console.log(`\ntotal evaluated on a warm-path request: ${mb(bytes(evaluated))}`);
97+
const lazyOnly = [...graph.keys()].filter((f) => !evaluated.has(f));
98+
console.log(
99+
`deferred behind dynamic import: ${mb(bytes(lazyOnly))} (${lazyOnly.length} chunks)`,
100+
);
101+
102+
const budget = Number(process.env.START_CLOSURE_BUDGET_MB ?? 0);
103+
if (budget > 0) {
104+
const actual = bytes(evaluated) / 1024 / 1024;
105+
console.log(`\nbudget ${budget} MB — actual ${actual.toFixed(2)} MB`);
106+
if (actual > budget) {
107+
console.error(
108+
`\nFAIL: evaluated closure ${actual.toFixed(2)} MB exceeds the ${budget} MB budget.\n` +
109+
`Every cold isolate pays to evaluate this closure before it can answer. Move the\n` +
110+
`new weight behind a dynamic import rather than raising the budget; run this\n` +
111+
`script with no budget set to see the biggest members and what is already lazy.`,
112+
);
113+
process.exit(1);
114+
}
115+
}

apps/cloud/src/server.ts

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -211,9 +211,34 @@ const mcpAgentHandler = makeCloudMcpAgentHandler();
211211
// That startup is invisible to every span we emit, and it sits on top of the
212212
// ~3.1s `loadEntries` import — together the 3-5s a signed-in page costs.
213213
//
214-
// Both counters are cheap: two increments and no I/O.
214+
// executor.isolate.id - identifies the isolate itself, so reuse can
215+
// be counted directly instead of inferred.
216+
// executor.isolate.age_ms - ms since this isolate served its first
217+
// request.
218+
//
219+
// The last two exist because the Aug 2026 hunt inferred "isolates stopped being
220+
// reused" from a latency cutoff (requests slower than 1s were called cold) and
221+
// then built a size-based theory on top of that proxy. The theory was wrong:
222+
// reverting the offending packages restored production while moving the
223+
// evaluated module closure by 0.02 MB (see scripts/start-closure.mjs). Grouping
224+
// by isolate id answers "how many requests did this isolate serve, and were the
225+
// slow ones its first?" directly, which no latency threshold can.
226+
//
227+
// All of it is cheap: two increments, one lazy uuid, and no I/O.
215228
let isolateRequestSeq = 0;
216229
let startGraphEntered = false;
230+
// Minted on first request rather than at module scope: Workers reject random
231+
// number generation during global-scope evaluation.
232+
let isolateId: string | undefined;
233+
let isolateFirstSeenAt = 0;
234+
235+
const identifyIsolate = (): { readonly id: string; readonly ageMs: number } => {
236+
if (isolateId === undefined) {
237+
isolateId = crypto.randomUUID();
238+
isolateFirstSeenAt = Date.now();
239+
}
240+
return { id: isolateId, ageMs: Date.now() - isolateFirstSeenAt };
241+
};
217242

218243
const markStartGraphEntered = (): void => {
219244
startGraphEntered = true;
@@ -300,8 +325,11 @@ const cloudflareHandler: ExportedHandler<Env> = {
300325
async (span) => {
301326
span.setAttribute(ATTR_HTTP_REQUEST_METHOD, request.method);
302327
span.setAttribute(ATTR_URL_PATH, url.pathname);
328+
const isolate = identifyIsolate();
303329
span.setAttribute("executor.isolate.request_seq", isolateRequestSeq);
304330
span.setAttribute("executor.start_graph.entered", startGraphEntered);
331+
span.setAttribute("executor.isolate.id", isolate.id);
332+
span.setAttribute("executor.isolate.age_ms", isolate.ageMs);
305333
// oxlint-disable-next-line executor/no-try-catch-or-throw -- adapter boundary; observe response/error for span status, keep the flush alive past the response
306334
try {
307335
const response = await fetchHandler(

0 commit comments

Comments
 (0)