Public: anyone can read this; the crew can change it.

A repository's settings, danger, hooks, labels and prompts pages name their Server-Timing phases and answer under 50 ms #4436

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/4318-a-repository-s-settings-danger-hooks-lab → main 6 commits fork 2 threads · 2 unresolved

  • Merged. scout/4318-a-repository-s-settings-danger-hooks-lab was merged into main as 75131b7.

Review

Sign in to review this change.
5 files changed +184 −38
src/core/route_timing.ts +25
@@ -273,6 +273,31 @@ export function takePhases(request: Request): Phase[] {
273273 return list;
274274 }
275275
276 const startTimes = new WeakMap<Request, number>();
277
278 /** The server stamps when it took a request, so the work before the handler can be named `route`. */
279 export function noteStart(request: Request, at: number): void {
280 startTimes.set(request, at);
281 }
282
283 const framed = new WeakSet<Request>();
284
285 /**
286 * A page that sums its phases to `app` calls this first: it names `route`, the
287 * server's work before the handler less the session, and asks the server to
288 * name `compress`, the work after it. Pages that don't call it are unchanged.
289 */
290 export function nameFrame(request: Request): void {
291 const at = startTimes.get(request);
292 if (at !== undefined) notePhase(request, "route", Math.max(0, performance.now() - at - (sessionTimes.get(request) ?? 0)));
293 framed.add(request);
294 }
295
296 /** Whether the page asked for `compress` to be named. */
297 export function namesFrame(request: Request): boolean {
298 return framed.has(request);
299 }
300
276301 const sessionTimes = new WeakMap<Request, number>();
277302
278303 /** The server says how long resolving the session took; a page may name it. */
src/http/server.ts +10 −1
@@ -469,6 +469,9 @@ import {
469469 createRouteTiming,
470470 noteDbTime,
471471 noteSessionTime,
472 notePhase,
473 namesFrame,
474 noteStart,
472475 takePhases,
473476 serverTiming,
474477 takeDbTime,
@@ -2207,7 +2210,12 @@ export async function serve(app: App): Promise<Servers> {
22072210 if (request.signal.aborted) onAbort();
22082211 else request.signal.addEventListener("abort", onAbort, { once: true });
22092212 const work = run().then(
2210 (response) => compress(request, response).then(securityFloor),
2213 async (response) => {
2214 const compressing = performance.now();
2215 const done = await compress(request, response).then(securityFloor);
2216 if (namesFrame(request)) notePhase(request, "compress", performance.now() - compressing);

This puts framing on every request's hot path (noteStart plus an extra async hop) just so these five pages can sum to app within 1 ms. Sibling pages (#4316, test/settings-timing.test.ts) meet the same bar with a 5 ms slack and no server change. That's fine to keep, but if a sixth page needs route/compress, write this framing up in AGENTS.md so it doesn't become a second way of timing a page.

2217 return done;
2218 },
22112219 (error) => failure(owner, error),
22122220 );
22132221 try {
@@ -2225,6 +2233,7 @@ export async function serve(app: App): Promise<Servers> {
22252233 server: Bun.Server<AppSocket>,
22262234 ): Promise<Response | undefined> => {
22272235 const started = performance.now();
2236 noteStart(request, started);
22282237 // An app's hostname is answered before any route: `hello.<domain>` is
22292238 // the app, whatever its path, and the forge's own routes never apply.
22302239 const runtime = appsRuntime(app);
src/web/pages/settings.ts +69 −32
@@ -59,7 +59,7 @@ import {
5959 updateRepoSettings,
6060 } from "../../actions/repos.ts";
6161 import { appsRuntime } from "../../apps/runtime.ts";
62 import { notePhase, takeSessionTime, timePhase } from "../../core/route_timing.ts";
62 import { nameFrame, notePhase, takeSessionTime, timePhase, timePhaseAsync } from "../../core/route_timing.ts";
6363 import { appVitals, shiftText, speedText as ms, type AppVitals } from "../../apps/vitals.ts";
6464 import type { PreviewData } from "../../apps/data.ts";
6565 import {
@@ -138,6 +138,13 @@ function loadAdminRepo(ctx: Ctx): Repo | undefined {
138138 return repo;
139139 }
140140
141 /** `loadAdminRepo` for a GET page: names the session, then the repo read as `repo`. */
142 function timedAdminRepo(ctx: Ctx): Repo | undefined {
143 nameFrame(ctx.request);
144 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
145 return timePhase(ctx.request, "repo", () => loadAdminRepo(ctx));
146 }
147
141148 function sections(base: string, active: Section) {
142149 return [
143150 {
@@ -225,31 +232,41 @@ async function adminForm(
225232 * webhooks, the danger zone — one quiet section over in the same frame.
226233 */
227234 export async function settingsPage(ctx: Ctx): Promise<Response> {
228 const readable = loadRepo(ctx);
235 nameFrame(ctx.request);
236 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
237 const [readable, repo] = timePhase(ctx.request, "repo", () => {
238 const r = loadRepo(ctx);
239 return [r, r ? loadAdminRepo(ctx) : undefined] as const;
240 });
229241 if (!readable) return repoNotFound(ctx);
230 const lead = settingsLead(ctx, readable);
231 const repo = loadAdminRepo(ctx);
242 const lead = timePhase(ctx.request, "lead", () => settingsLead(ctx, readable));
232243 if (!repo) {
233 const proposals = proposalTokensSection(ctx, readable);
234 return page({
235 ctx,
236 title: pageTitle("settings", readable, true),
237 crumbs: crumbsFor(readable),
238 palette: repoPalette(ctx, readable),
239 body: html`${repoHeader(ctx, readable, "settings", { compact: true })}
244 return timePhase(ctx.request, "render", () => {
245 const proposals = proposalTokensSection(ctx, readable);
246 return page({
247 ctx,
248 title: pageTitle("settings", readable, true),
249 crumbs: crumbsFor(readable),
250 palette: repoPalette(ctx, readable),
251 body: html`${repoHeader(ctx, readable, "settings", { compact: true })}
240252 ${lead}${proposals ? html`<div class="mt">${proposals}</div>` : null}`,
253 });
241254 });
242255 }
243256 const base = repoBase(repo);
244257 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
245 const refs = await listRefs(dir, "refs/heads/");
258 const refs = await timePhaseAsync(ctx.request, "refs", () => listRefs(dir, "refs/heads/"));
259 let renderMs = 0;
260 let since = performance.now();
246261 const branches = refs.ok
247262 ? refs.value.map((r) => r.name.replace("refs/heads/", "")).sort()
248263 : [];
249264 const tip = refs.ok
250265 ? refs.value.find((r) => r.name === `refs/heads/${repo.defaultBranch}`)?.oid
251266 : undefined;
252 const cellMemory = await checkMemoryAt(dir, tip);
267 renderMs += performance.now() - since;
268 const cellMemory = await timePhaseAsync(ctx.request, "check", () => checkMemoryAt(dir, tip));
269 since = performance.now();
253270 if (!branches.includes(repo.defaultBranch))
254271 branches.unshift(repo.defaultBranch);
255272
@@ -372,7 +389,9 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
372389 </form>`,
373390 { head: html`${icon("folder")}<h2>Archive</h2>` },
374391 );
375 const held = getMirrorTarget(ctx.app.db, repo.id);
392 renderMs += performance.now() - since;
393 const held = timePhase(ctx.request, "mirror", () => getMirrorTarget(ctx.app.db, repo.id));
394 since = performance.now();
376395 const mirror =
377396 held === undefined
378397 ? null
@@ -388,7 +407,7 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
388407 </form>`,
389408 { head: html`${icon("branch")}<h2>Mirror</h2>` },
390409 );
391 return settingsPageResponse(
410 const response = settingsPageResponse(
392411 ctx,
393412 repo,
394413 "general",
@@ -401,6 +420,8 @@ export async function settingsPage(ctx: Ctx): Promise<Response> {
401420 ? html`<div class="mt">${secrets}</div>`
402421 : null}`,
403422 );
423 notePhase(ctx.request, "render", renderMs + performance.now() - since);
424 return response;
404425 }
405426
406427 export async function settingsUpdate(ctx: Ctx): Promise<Response> {
@@ -1538,15 +1559,17 @@ function promptsView(base: string, sources: PromptSourceView[]): Html {
15381559
15391560 // GET /:owner/:name/settings/prompts
15401561 export function promptsSettingsPage(ctx: Ctx): Response {
1541 const repo = loadAdminRepo(ctx);
1562 const repo = timedAdminRepo(ctx);
15421563 if (!repo) return repoNotFound(ctx);
1543 const listed = listPromptSources(ctx.app, ctx.auth.user, repo);
1544 return settingsPageResponse(
1545 ctx,
1546 repo,
1547 "prompts",
1548 "Prompts",
1549 promptsView(repoBase(repo), listed.ok ? listed.value : []),
1564 const listed = timePhase(ctx.request, "sources", () => listPromptSources(ctx.app, ctx.auth.user, repo));
1565 return timePhase(ctx.request, "render", () =>
1566 settingsPageResponse(
1567 ctx,
1568 repo,
1569 "prompts",
1570 "Prompts",
1571 promptsView(repoBase(repo), listed.ok ? listed.value : []),
1572 ),
15501573 );
15511574 }
15521575
@@ -1739,10 +1762,11 @@ function labelEditor(base: string, label: Label): Html {
17391762 }
17401763
17411764 export function labelsPage(ctx: Ctx): Response {
1742 const repo = loadAdminRepo(ctx);
1765 const repo = timedAdminRepo(ctx);
17431766 if (!repo) return repoNotFound(ctx);
1767 const labels = timePhase(ctx.request, "labels", () => listLabels(ctx.app.db, repo.id));
1768 const rendering = performance.now();
17441769 const base = repoBase(repo);
1745 const labels = listLabels(ctx.app.db, repo.id);
17461770
17471771 const list =
17481772 labels.length === 0
@@ -1775,7 +1799,7 @@ export function labelsPage(ctx: Ctx): Response {
17751799
17761800 const body = html`${list}
17771801 <div class="mt">${create}</div>`;
1778 return settingsPageResponse(
1802 const response = settingsPageResponse(
17791803 ctx,
17801804 repo,
17811805 "labels",
@@ -1783,6 +1807,8 @@ export function labelsPage(ctx: Ctx): Response {
17831807 body,
17841808 labelCss(labels),
17851809 );
1810 notePhase(ctx.request, "render", performance.now() - rendering);
1811 return response;
17861812 }
17871813
17881814 export async function labelCreate(ctx: Ctx): Promise<Response> {
@@ -1862,10 +1888,16 @@ export async function labelDelete(ctx: Ctx): Promise<Response> {
18621888 // ---- webhooks ----
18631889
18641890 export function hooksPage(ctx: Ctx): Response {
1865 const repo = loadAdminRepo(ctx);
1891 const repo = timedAdminRepo(ctx);
18661892 if (!repo) return repoNotFound(ctx);
1893 const hooks = timePhase(ctx.request, "hooks", () => listWebhooks(ctx.app.db, repo.id));
1894 const recent = timePhase(
1895 ctx.request,
1896 "deliveries",
1897 () => new Map(hooks.map((h) => [h.id, recentDeliveries(ctx.app.db, h.id, 10)] as const)),
1898 );
1899 const rendering = performance.now();
18671900 const base = repoBase(repo);
1868 const hooks = listWebhooks(ctx.app.db, repo.id);
18691901
18701902 const list =
18711903 hooks.length === 0
@@ -1875,7 +1907,7 @@ export function hooksPage(ctx: Ctx): Response {
18751907 note: "Every event this repository produces can be POSTed to a URL you control, signed and retried.",
18761908 })
18771909 : html`${hooks.map((hook) => {
1878 const deliveries = recentDeliveries(ctx.app.db, hook.id, 10);
1910 const deliveries = recent.get(hook.id) ?? [];
18791911 return card(
18801912 deliveries.length === 0
18811913 ? html`<p class="muted small">No deliveries yet.</p>`
@@ -1972,7 +2004,9 @@ export function hooksPage(ctx: Ctx): Response {
19722004
19732005 const body = html`${list}
19742006 <div class="mt">${create}</div>`;
1975 return settingsPageResponse(ctx, repo, "hooks", "Webhooks", body);
2007 const response = settingsPageResponse(ctx, repo, "hooks", "Webhooks", body);
2008 notePhase(ctx.request, "render", performance.now() - rendering);
2009 return response;
19762010 }
19772011
19782012 export async function webhookCreate(ctx: Ctx): Promise<Response> {
@@ -2021,8 +2055,9 @@ export async function webhookDelete(ctx: Ctx): Promise<Response> {
20212055 // ---- danger zone ----
20222056
20232057 export function dangerPage(ctx: Ctx): Response {
2024 const repo = loadAdminRepo(ctx);
2058 const repo = timedAdminRepo(ctx);
20252059 if (!repo) return repoNotFound(ctx);
2060 const rendering = performance.now();
20262061 const base = repoBase(repo);
20272062 const full = `${repo.ownerName}/${repo.name}`;
20282063
@@ -2048,7 +2083,9 @@ export function dangerPage(ctx: Ctx): Response {
20482083 <h2>Delete this repository</h2>`,
20492084 },
20502085 );
2051 return settingsPageResponse(ctx, repo, "danger", "Danger zone", body);
2086 const response = settingsPageResponse(ctx, repo, "danger", "Danger zone", body);
2087 notePhase(ctx.request, "render", performance.now() - rendering);
2088 return response;
20522089 }
20532090
20542091 /**
test/server-timing-sweep.test.ts −5
@@ -24,11 +24,6 @@ const UNNAMED: Array<[path: string, issue: string]> = [
2424 ["/alice/forge", "#3325"],
2525 ["/alice/forge/changes", "#3323"],
2626 ["/alice/forge/map", "#3320"],
27 ["/alice/forge/settings", "#3390"],
28 ["/alice/forge/settings/danger", "#3390"],
29 ["/alice/forge/settings/hooks", "#3390"],
30 ["/alice/forge/settings/labels", "#3390"],
31 ["/alice/forge/settings/prompts", "#3390"],
3227 ["/alice/forge/tells", "#3322"],
3328 ["/live/apps", "#3390"],
3429 ["/live/on-you", "#3390"],
test/settings-repo-timing.test.ts added +80
@@ -0,0 +1,80 @@
1 /** A repository's settings pages name session, each read and render in Server-Timing, and answer under 50 ms (#4318). */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtempSync, rmSync } from "node:fs";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createRepo } from "../src/actions/repos.ts";
7 import { createApp, type App } from "../src/app.ts";
8 import { createUser } from "../src/core/users.ts";
9 import { serve, type Servers } from "../src/http/server.ts";
10 import { proveAll } from "./helpers/verified.ts";
11 import { quietLogs } from "./helpers/globals.ts";
12
13 quietLogs();
14
15 let root = "";
16 let app: App;
17 let servers: Servers;
18 let cookie = "";
19
20 beforeAll(async () => {
21 root = mkdtempSync(join(tmpdir(), "forge-settings-timing-"));
22 app = createApp({ dataDir: root, port: 0, forgeRepo: "alice/forge" });
23 const u = await createUser(app.db, {
24 origin: "seed",
25 trusted: true,
26 username: "alice",
27 email: "[email protected]",
28 password: "a-good-passphrase",
29 adminIfFirst: true,
30 });
31 if (!u.ok) throw new Error(u.error);
32 proveAll(app);
33 const made = await createRepo(app, { id: u.value.id, username: "alice" } as never, { name: "forge" });
34 if (!made.ok) throw new Error(made.error);
35 servers = await serve(app);
36 const signin = await fetch(`${servers.url}/signin`, {
37 method: "POST",
38 headers: { origin: servers.url },
39 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
40 redirect: "manual",
41 });
42 cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0]!;
43 });
44 afterAll(() => {
45 servers.stop();
46 app.close();
47 rmSync(root, { recursive: true, force: true });
48 });
49
50 const PAGES: [string, string[]][] = [
51 ["/alice/forge/settings", ["route", "session", "repo", "lead", "refs", "check", "mirror", "render", "compress"]],
52 ["/alice/forge/settings/danger", ["route", "session", "repo", "render", "compress"]],
53 ["/alice/forge/settings/hooks", ["route", "session", "repo", "hooks", "deliveries", "render", "compress"]],
54 ["/alice/forge/settings/labels", ["route", "session", "repo", "labels", "render", "compress"]],
55 ["/alice/forge/settings/prompts", ["route", "session", "repo", "sources", "render", "compress"]],
56 ];
57
58 for (const [path, names] of PAGES)
59 test(`GET ${path} names its phases and answers under 50 ms`, async () => {
60 const get = async () => {
61 const res = await fetch(`${servers.url}${path}`, { headers: { cookie } });
62 await res.text();
63 return res;
64 };
65 await get(); // warm
66 const res = await get();
67 expect(res.status).toBe(200);
68 const parts = (res.headers.get("server-timing") ?? "").split(", ");
69 const dur = (name: string) => Number(parts.find((p) => p.startsWith(`${name};`))?.match(/dur=([\d.]+)/)?.[1]);
70 const got = parts.map((p) => p.split(";")[0]);
71 for (const name of ["app", ...names]) {
72 expect(got).toContain(name);
73 expect(Number.isFinite(dur(name))).toBe(true);
74 }
75 expect(got.filter((n) => n === "render").length).toBe(1);
76 expect(dur("app")).toBeLessThan(50);
77 // The named phases cover the request (route and compress are the server's own); the issue's bound is 1 ms.
78 const sum = names.reduce((t, n) => t + dur(n), 0);
79 expect(Math.abs(dur("app") - sum)).toBeLessThanOrEqual(1);

A sum of up to 9 phases, each rounded to 0.1 ms, plus the unmeasured microtask gaps (after the handler, and between compress and counted), held to within 1 ms of app, can fail by chance when the check runs suites in parallel. Priority 1 is a merge queue that lands fast, so if this check ever fails without a real cause, relax it to the shape test/settings-timing.test.ts uses (sum <= app + 0.3, app - sum < 5) instead of retrying.

80 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs