Skip to content

Commit 04d2fc0

Browse files
authored
Merge branch 'main' into fix/chat-skip-to-turn-complete-reset
2 parents adbf87b + c5c2ea9 commit 04d2fc0

36 files changed

Lines changed: 2744 additions & 70 deletions

.github/VOUCHED.td

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,5 @@ Leafgard
2929
Rohan170603
3030
NERLOE
3131
Jakub-Vacek
32-
gtremper
32+
gtremper
33+
wuweiweiwu

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,6 @@ ailogger-output.log
8787
observability-map.json
8888

8989
.claude/worktrees/
90+
91+
# CPU benchmark artifacts (profiles + summaries)
92+
.bench/
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: improvement
4+
---
5+
6+
Cut webapp CPU usage by about a quarter on the routes that workers call most, freeing headroom at the same request rate. Detailed event-loop blocking traces are no longer recorded by default, because producing them was itself a large part of that cost.

apps/webapp/app/components/primitives/Switch.tsx

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,22 @@ import { type ShortcutDefinition, useShortcutKeys } from "~/hooks/useShortcutKey
88
const small = {
99
container:
1010
"flex items-center h-6 gap-x-1.5 rounded hover:bg-tertiary pr-1 py-[0.1rem] pl-1.5 hover:disabled:bg-background-raised transition focus-custom disabled:opacity-50 text-text-dimmed hover:text-text-bright disabled:hover:cursor-not-allowed hover:cursor-pointer disabled:hover:text-rose-500",
11-
root: "h-3 w-6",
12-
thumb: "size-2.5 data-[state=checked]:translate-x-2.5 data-[state=unchecked]:translate-x-0",
11+
root: "h-3 w-5.5",
12+
thumb: cn(
13+
"h-2.5 w-2.5 data-[state=checked]:translate-x-2 data-[state=unchecked]:translate-x-0",
14+
"group-active:w-3.25 group-active:data-[state=checked]:translate-x-1.25"
15+
),
1316
text: "text-xs text-text-dimmed",
1417
};
1518

1619
const variations = {
1720
large: {
1821
container: "flex items-center gap-x-2 rounded-md hover:bg-tertiary p-2 transition focus-custom",
19-
root: "h-6 w-11",
20-
thumb: "size-5 data-[state=checked]:translate-x-5 data-[state=unchecked]:translate-x-0",
22+
root: "h-6 w-10.5",
23+
thumb: cn(
24+
"h-5 w-5 data-[state=checked]:translate-x-4.5 data-[state=unchecked]:translate-x-0",
25+
"group-active:w-6.5 group-active:data-[state=checked]:translate-x-3"
26+
),
2127
text: "text-sm text-text-dimmed",
2228
},
2329
small,
@@ -48,8 +54,11 @@ const variations = {
4854
medium: {
4955
container:
5056
"flex items-center gap-x-2 rounded-md hover:bg-tertiary py-1.5 px-2 transition focus-custom",
51-
root: "h-4 w-8",
52-
thumb: "size-3.5 data-[state=checked]:translate-x-3.5 data-[state=unchecked]:translate-x-0",
57+
root: "h-4 w-7.5",
58+
thumb: cn(
59+
"h-3.5 w-3.5 data-[state=checked]:translate-x-3 data-[state=unchecked]:translate-x-0",
60+
"group-active:w-4.5 group-active:data-[state=checked]:translate-x-2"
61+
),
5362
text: "text-sm text-text-dimmed",
5463
},
5564
};
@@ -96,7 +105,7 @@ export const Switch = React.forwardRef<React.ElementRef<typeof SwitchPrimitives.
96105
<SwitchPrimitives.Thumb
97106
className={cn(
98107
thumb,
99-
"pointer-events-none block rounded-full bg-white transition dark:bg-charcoal-200 dark:group-data-[state=checked]:bg-text-bright"
108+
"pointer-events-none block rounded-full bg-white transition-[translate,width,background-color] dark:bg-charcoal-200 dark:group-data-[state=checked]:bg-text-bright"
100109
)}
101110
/>
102111
</div>

apps/webapp/app/entry.server.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import type { OperatingSystemPlatform } from "./components/primitives/OperatingS
1818
import { OperatingSystemContextProvider } from "./components/primitives/OperatingSystemProvider";
1919
import { assertRunOpsSplitSentinel, Prisma } from "./db.server";
2020
import { env } from "./env.server";
21-
import { eventLoopMonitor } from "./eventLoopMonitor.server";
21+
import { eventLoopMonitor, eventLoopUtilizationMonitor } from "./eventLoopMonitor.server";
2222
import { logger } from "./services/logger.server";
2323
import { buildImgSrcDirective, parseCspImageOrigins, withImgSrc } from "./utils/cspImageOrigins";
2424
import { singleton } from "./utils/singleton";
@@ -360,6 +360,10 @@ if (env.EVENT_LOOP_MONITOR_ENABLED === "1") {
360360
eventLoopMonitor.enable();
361361
}
362362

363+
if (env.EVENT_LOOP_UTILIZATION_MONITOR_ENABLED === "1") {
364+
eventLoopUtilizationMonitor.enable();
365+
}
366+
363367
if (remoteBuildsEnabled()) {
364368
console.log("🏗️ Remote builds enabled");
365369
} else {

apps/webapp/app/env.server.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -995,7 +995,8 @@ const EnvironmentSchema = z
995995

996996
CENTS_PER_RUN: z.coerce.number().default(0),
997997

998-
EVENT_LOOP_MONITOR_ENABLED: z.string().default("1"),
998+
EVENT_LOOP_MONITOR_ENABLED: z.string().default("0"),
999+
EVENT_LOOP_UTILIZATION_MONITOR_ENABLED: z.string().default("1"),
9991000
MAXIMUM_LIVE_RELOADING_EVENTS: z.coerce.number().int().default(1000),
10001001
MAXIMUM_TRACE_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(25_000),
10011002
MAXIMUM_TRACE_DETAILED_SUMMARY_VIEW_COUNT: z.coerce.number().int().default(10_000),

apps/webapp/app/eventLoopMonitor.server.ts

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -89,25 +89,51 @@ function after(asyncId: number) {
8989
}
9090
}
9191

92+
/**
93+
* Per-async-resource blocked-loop detection. This is the expensive half: the
94+
* hook fires for every async resource the process creates, and enabling any
95+
* async hook also puts V8 on the slow path for promise instrumentation
96+
* process-wide. On a request-heavy instance it costs roughly a seventh of all
97+
* on-CPU time, which is why it is opt-in rather than on by default.
98+
*/
9299
export const eventLoopMonitor = singleton("eventLoopMonitor", () => {
93100
const hook = createHook({ init, before, after, destroy });
94101

95-
let stopEventLoopUtilizationMonitoring: () => void;
96-
97102
return {
98103
enable: () => {
99104
console.log("🥸 Initializing event loop monitor");
100105

101106
hook.enable();
102-
103-
stopEventLoopUtilizationMonitoring = startEventLoopUtilizationMonitoring();
104107
},
105108
disable: () => {
106109
console.log("🥸 Disabling event loop monitor");
107110

108111
hook.disable();
112+
},
113+
};
114+
});
115+
116+
/**
117+
* The cheap half: a single interval timer reading `eventLoopUtilization()`.
118+
* It costs nothing per request, so it stays on by default and is what a
119+
* high-traffic instance should rely on when the async hook is too expensive.
120+
*/
121+
export const eventLoopUtilizationMonitor = singleton("eventLoopUtilizationMonitor", () => {
122+
let stop: (() => void) | undefined;
109123

110-
stopEventLoopUtilizationMonitoring?.();
124+
return {
125+
enable: () => {
126+
if (stop) {
127+
return;
128+
}
129+
130+
console.log("🥸 Initializing event loop utilization monitor");
131+
132+
stop = startEventLoopUtilizationMonitoring();
133+
},
134+
disable: () => {
135+
stop?.();
136+
stop = undefined;
111137
},
112138
};
113139
});

apps/webapp/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,8 @@
2525
"upload:sourcemaps": "bash ./upload-sourcemaps.sh",
2626
"test": "vitest --no-file-parallelism",
2727
"test:perf": "vitest --config ./vitest.perf.config.ts --run",
28-
"eval:dev": "evalite watch"
28+
"eval:dev": "evalite watch",
29+
"test:bench": "vitest --config ./vitest.bench.config.ts --run"
2930
},
3031
"dependencies": {
3132
"@ai-sdk/openai": "^3.0.0",

apps/webapp/test/bench/README.md

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
# Engine CPU benchmarks
2+
3+
Two benchmarks for the paths the production engine service spends its CPU in, plus a
4+
`.cpuprofile` analyzer. Neither runs in CI: they take minutes, attach the V8 profiler, and
5+
report numbers rather than assert on them.
6+
7+
| bench | what it covers | where |
8+
| --- | --- | --- |
9+
| `engineHttp.bench.test.ts` | the full request stack for `engine/v1/worker-actions/*` | `apps/webapp` |
10+
| `runEngineLifecycle.bench.test.ts` | run-engine and run-queue with no HTTP in the way | `internal-packages/run-engine` |
11+
12+
Artifacts (profiles + JSON summaries) land in `.bench/` at the repo root, which is gitignored.
13+
14+
## HTTP bench
15+
16+
Measures what a managed supervisor actually does: dequeue, start attempt, heartbeat,
17+
read latest snapshot, complete attempt. Needs a built webapp.
18+
19+
```bash
20+
pnpm run build --filter webapp
21+
cd apps/webapp
22+
pnpm run test:bench
23+
```
24+
25+
It spawns a real webapp against throwaway Postgres and Redis containers, seeds a production
26+
environment with a promoted managed deployment, fills the worker queue over the public
27+
trigger API, then drives a closed-loop supervisor pool for the measured window.
28+
29+
The webapp is spawned with `--inspect` and profiled over CDP, so the profile covers only the
30+
measured window rather than boot. Event-loop utilization is sampled **inside** the webapp
31+
process over the same connection.
32+
33+
Knobs:
34+
35+
| var | default | meaning |
36+
| --- | --- | --- |
37+
| `BENCH_RUNS` | 1200 | runs queued before the window opens |
38+
| `BENCH_SUPERVISORS` | 16 | concurrent virtual supervisors |
39+
| `BENCH_HEARTBEATS` | 2 | heartbeats per run |
40+
| `BENCH_DURATION_MS` | 60000 | measured window |
41+
| `BENCH_SAMPLING_INTERVAL_US` | 200 | V8 sampling interval |
42+
| `BENCH_PROFILE_NAME` | `engine-http` | artifact basename |
43+
| `BENCH_EXTRA_ENV` || JSON merged into the webapp's env |
44+
| `BENCH_OUT_DIR` | `<repo>/.bench` | artifact directory |
45+
46+
`BENCH_EXTRA_ENV` plus `BENCH_PROFILE_NAME` is how you A/B a single flag:
47+
48+
```bash
49+
BENCH_RUNS=5000 BENCH_SUPERVISORS=24 BENCH_DURATION_MS=90000 \
50+
BENCH_PROFILE_NAME=engine-http-no-elm \
51+
BENCH_EXTRA_ENV='{"EVENT_LOOP_MONITOR_ENABLED":"0"}' \
52+
pnpm run test:bench
53+
```
54+
55+
Run the same size for both arms and compare `on-cpu ms per completed run` rather than
56+
throughput: throughput on a laptop moves ~5% run to run, on-CPU per unit of work is far
57+
steadier.
58+
59+
## Run-engine bench
60+
61+
No HTTP, no webapp: drives `RunEngine` directly so engine and queue costs are not mixed with
62+
request-stack overhead. Profiles two phases separately, because blending them hides which one
63+
owns a hot frame.
64+
65+
```bash
66+
cd internal-packages/run-engine
67+
pnpm run test:bench
68+
```
69+
70+
Knobs: `BENCH_RUNS`, `BENCH_CONSUMERS`, `BENCH_HEARTBEATS`, `BENCH_CONCURRENCY_LIMIT`,
71+
`BENCH_SAMPLING_INTERVAL_US`, `BENCH_OUT_DIR`.
72+
73+
The driver shares a process with the code under measurement, so its own cost is in the
74+
profile. It is a thin await loop and appears under its own frames rather than smeared across
75+
engine frames.
76+
77+
## Analyzing a profile
78+
79+
```bash
80+
pnpm --filter webapp exec tsx test/bench/analyzeProfile.ts .bench/engine-http.cpuprofile --top 30
81+
```
82+
83+
Three views: CPU by bucket (which package owns the cycles), hottest frames by self time (what
84+
to go fix), and hottest frames by total time (entry points, and a check that the load
85+
exercised the route mix you intended). Frames are symbolicated through the build's source
86+
maps, so bundled chunks report as the source files they came from.
87+
88+
Percentages are shares of **on-CPU** time, with V8's `(idle)` and `(program)` excluded. A
89+
share of wall clock would make everything look cheap whenever the bench was IO-bound.
90+
91+
`--json <path>` writes the full analysis for diffing two runs.
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
#!/usr/bin/env tsx
2+
/**
3+
* Ranks where a `.cpuprofile` spent its cycles.
4+
*
5+
* pnpm --filter webapp exec tsx test/bench/analyzeProfile.ts <profile> [--top 40] [--json out.json]
6+
*
7+
* `--root` overrides the repo root used to make source paths relative and to
8+
* find the build's source maps; it defaults to the repo containing this file.
9+
*/
10+
import { readFileSync, writeFileSync } from "node:fs";
11+
import { resolve } from "node:path";
12+
import { analyzeProfile, formatAnalysis, type CpuProfile } from "./lib/profileAnalysis";
13+
14+
function parseArgs(argv: string[]): {
15+
profilePath?: string;
16+
top: number;
17+
json?: string;
18+
root: string;
19+
} {
20+
const here = typeof __dirname === "string" ? __dirname : import.meta.dirname;
21+
22+
const defaults = {
23+
top: 30,
24+
root: resolve(here, "..", "..", "..", ".."),
25+
};
26+
27+
let profilePath: string | undefined;
28+
let top = defaults.top;
29+
let json: string | undefined;
30+
let root = defaults.root;
31+
32+
for (let i = 0; i < argv.length; i++) {
33+
const arg = argv[i]!;
34+
if (arg === "--top") {
35+
const raw = argv[++i];
36+
const parsed = Number(raw);
37+
if (!Number.isFinite(parsed) || parsed <= 0) {
38+
console.error(`--top expects a positive number, got "${raw ?? ""}"`);
39+
process.exit(1);
40+
}
41+
top = parsed;
42+
} else if (arg === "--json") json = argv[++i];
43+
else if (arg === "--root") root = resolve(argv[++i]!);
44+
else if (!arg.startsWith("--")) profilePath = arg;
45+
}
46+
47+
return { profilePath, top, json, root };
48+
}
49+
50+
const { profilePath, top, json, root } = parseArgs(process.argv.slice(2));
51+
52+
if (!profilePath) {
53+
console.error("usage: analyzeProfile.ts <path-to-.cpuprofile> [--top N] [--json out.json]");
54+
process.exit(1);
55+
}
56+
57+
const profile = JSON.parse(readFileSync(profilePath, "utf8")) as CpuProfile;
58+
const analysis = analyzeProfile(profile, root);
59+
60+
console.log(`\n=== ${profilePath} ===`);
61+
console.log(formatAnalysis(analysis, top));
62+
63+
if (json) {
64+
writeFileSync(json, JSON.stringify(analysis, null, 2));
65+
console.log(`\nwrote ${json}`);
66+
}

0 commit comments

Comments
 (0)