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

The forge's app proxy adds under 5 ms to a warm app's answer, measured on loopback and named in the gate #8193

Merged opened this

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

Merged

torque/forge:torque/8186-the-forge-s-app-proxy-adds-under-5-ms-to → main 4 commits fork 3 threads · 3 unresolved

  • Merged. torque/8186-the-forge-s-app-proxy-adds-under-5-ms-to was merged into main as 36389a2.

Review

Sign in to review this change.
4 files changed +224 −9
src/apps/proxy.ts +12 −7
@@ -745,9 +745,12 @@ function admit(
745745 if (repo?.archivedAt != null) return err(archivedPage(target));
746746 // A preview stopped for want of attention is asked for: it deploys again
747747 // the ordinary way, and this request sees "starting" until it answers.
748 if (runtime.envStatus(target.app, target.env)?.dormant !== undefined)
748 // Read once; read again only when the rouse changed what it would say.
749 let status = runtime.envStatus(target.app, target.env);
750 if (status?.dormant !== undefined) {
749751 runtime.rouse(target.app, target.env);
750 const status = runtime.envStatus(target.app, target.env);
752 status = runtime.envStatus(target.app, target.env);
753 }
751754 if (status === undefined || status.port === undefined) {
752755 return err(unavailable(runtime, target, status, undefined, grant?.who));
753756 }
@@ -1183,11 +1186,13 @@ export async function proxyApp(
11831186 ? mintProof(grantCookie, request.headers.get("host") ?? "")
11841187 : undefined;
11851188 if (proof !== undefined) out.set("cache-control", "private, no-store");
1186 const page = withToolbar(
1187 bytes,
1188 runtime.options.forgeUrl,
1189 pillWhere(runtime, target, vouched?.who),
1190 proof,
1189 const page = timePhase(request, "rewrite", () =>
1190 withToolbar(
1191 bytes,
1192 runtime.options.forgeUrl,
1193 pillWhere(runtime, target, vouched?.who),
1194 proof,
1195 ),
11911196 );
11921197 let sent = page;
11931198 // Whether it is gzipped depends on the client, so every copy says so.
src/apps/toolbar.ts +13 −2
@@ -589,6 +589,18 @@ export function toolbarTag(forgeUrl: string, where?: PillWhere, proof?: string):
589589 return `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}"${data}${token} defer></script>`;
590590 }
591591
592 /**
593 * The byte offset of the last `</body`, any case, or -1. Scanned from the end
594 * so a page pays for its tail, not for a lowercase copy of the whole body.
595 */
596 function lastBodyClose(buffer: Buffer): number {
597 for (let i = buffer.length - 6; i >= 0; i--) {
598 if (buffer[i] !== 0x3c || buffer[i + 1] !== 0x2f) continue;
599 if (buffer.toString("latin1", i + 2, i + 6).toLowerCase() === "body") return i;

New scan with no test of its own. Add toolbar tests for upper and mixed case, the last of several matches, no match, and inputs under 6 bytes.

600 }
601 return -1;
602 }
603
592604 /** `bytes` with the pill's tag before the last `</body>`, or at the end. */
593605 export function withToolbar(
594606 bytes: Uint8Array,
@@ -598,8 +610,7 @@ export function withToolbar(
598610 ): Uint8Array<ArrayBuffer> {
599611 const tag = Buffer.from(toolbarTag(forgeUrl, where, proof));
600612 const buffer = Buffer.from(bytes.buffer, bytes.byteOffset, bytes.byteLength);
601 // latin1 is one character per byte, so an index in it is a byte offset.
602 const at = buffer.toString("latin1").toLowerCase().lastIndexOf("</body");
613 const at = lastBodyClose(buffer);
603614 const cut = at < 0 ? buffer.length : at;
604615 return Buffer.concat([buffer.subarray(0, cut), tag, buffer.subarray(cut)]);
605616 }
test/apps-proxy-overhead.test.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * The app proxy adds little to a warm app's answer (#8186): the median over a
3 * direct fetch of the app's port stays under 5 ms and the p95 under 20 ms,
4 * with the pill's rewrite and without it. Compared to a direct fetch made in
5 * the same test, so a loaded machine slows both sides alike.
6 */
7
8 import { afterAll, beforeAll, spyOn, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { newApp } from "../src/actions/apps.ts";
14 import { AppsRuntime, type EnvStatus } from "../src/apps/runtime.ts";
15 import { proxyApp, resolveAppHost, type AppSocket } from "../src/apps/proxy.ts";
16 import { takePhases } from "../src/core/route_timing.ts";
17 import { createUser } from "../src/core/users.ts";
18 import { serve, type Servers } from "../src/http/server.ts";
19 import { quietLogs } from "./helpers/globals.ts";
20
21 quietLogs();
22
23 const DOMAIN = "apps.test";
24 const RUNS = 50;
25 const MEDIAN_MS = 5;
26 const P95_MS = 20;
27 // About the size of the scaffold app's home page.
28 const PAGE = `<!doctype html><html><head><title>notes</title></head><body>${"<p>hello hello hello</p>".repeat(60)}</body></html>`;
29
30 /** A plain GET never upgrades, so the proxy only holds this; it is never called. */
31 const NO_SOCKETS = { upgrade: () => false } as unknown as Bun.Server<AppSocket>;
32
33 let dataDir: string;
34 let app: App;
35 let servers: Servers;
36 let runtime: AppsRuntime;
37 let backend: ReturnType<typeof Bun.serve>;
38 let toolbar: boolean | undefined = undefined;
39 let status: ReturnType<typeof spyOn>;
40
41 beforeAll(async () => {
42 backend = Bun.serve({
43 port: 0,
44 fetch: () =>
45 new Response(PAGE, { headers: { "content-type": "text/html; charset=utf-8" } }),
46 });
47 dataDir = await mkdtemp(join(tmpdir(), "forge-proxy-timing-"));
48 app = createApp({ dataDir, port: 0, host: "forge.test" });
49 servers = await serve(app);
50 runtime = new AppsRuntime(app, {
51 domain: DOMAIN,
52 scheme: "http",
53 publicPort: servers.port,
54 forgeUrl: servers.url,
55 concurrency: 1,
56 });
57 await runtime.start();
58 status = spyOn(AppsRuntime.prototype, "envStatus").mockImplementation(
59 () =>
60 ({
61 env: "production",
62 state: "running",
63 port: backend.port,
64 commitOid: "a".repeat(40),
65 toolbar,
66 }) as EnvStatus,
67 );
68 const a = await createUser(app.db, {
69 origin: "seed",
70 trusted: true,
71 username: "alice",
72 email: "[email protected]",
73 password: "a-good-passphrase",
74 adminIfFirst: true,
75 });
76 if (!a.ok) throw new Error(a.error);
77 const made = await newApp(app, a.value, {
78 name: "notes",
79 description: "A place for words",
80 visibility: "public",
81 });
82 if (!made.ok) throw new Error(String(made.error));
83 }, 120_000);
84
85 afterAll(async () => {
86 status.mockRestore();
87 await runtime.stop();
88 servers.stop();
89 backend.stop(true);
90 app.close();
91 await rm(dataDir, { recursive: true, force: true });
92 });
93
94 const HEADERS = { host: `notes.${DOMAIN}`, accept: "text/html" };
95
96 async function time(url: string, headers: Record<string, string>): Promise<number> {
97 const started = performance.now();
98 const res = await fetch(url, { headers, redirect: "manual" });
99 await res.arrayBuffer();
100 return performance.now() - started;
101 }
102
103 const at = (sorted: number[], q: number): number =>
104 sorted[Math.min(sorted.length - 1, Math.floor(q * sorted.length))]!;
105
106 /** The five slowest phases of the forge's side of a request, for a failure to name. */
107 async function slowest(): Promise<string> {
108 const request = new Request(`${servers.url}/`, { headers: HEADERS });
109 const target = resolveAppHost(app, DOMAIN, request);
110 if (target === undefined || target === "unknown") return "(no target)";
111 const totals = new Map<string, number>();
112 for (let i = 0; i < 20; i++) {
113 const r = new Request(`${servers.url}/`, { headers: HEADERS });
114 const res = await proxyApp(runtime, r, target, NO_SOCKETS);
115 await res?.arrayBuffer();
116 for (const p of takePhases(r)) totals.set(p.name, (totals.get(p.name) ?? 0) + p.ms / 20);
117 }
118 return [...totals]
119 .sort((x, y) => y[1] - x[1])
120 .slice(0, 5)
121 .map(([n, ms]) => `${n}=${ms.toFixed(2)}ms`)
122 .join(", ");
123 }
124
125 for (const [label, pill] of [["with the toolbar rewrite", undefined], ["without it", false]] as const) {
126 test(`the proxy adds under ${MEDIAN_MS} ms (median) and ${P95_MS} ms (p95) ${label}`, async () => {
127 toolbar = pill;
128 const direct = `http://127.0.0.1:${backend.port}/`;
129 const proxied = `${servers.url}/`;
130 for (let i = 0; i < 2; i++) {
131 await time(direct, { accept: "text/html" });
132 await time(proxied, HEADERS);
133 }
134 // A loaded machine can spoil one round; the budget is missed only if
135 // two rounds in a row miss it.
136 let verdict = "";
137 for (let round = 0; round < 2; round++) {
138 const overhead: number[] = [];
139 for (let i = 0; i < RUNS; i++) {
140 // Back to back, so a slow moment on the machine lands on both.
141 const d = await time(direct, { accept: "text/html" });
142 const p = await time(proxied, HEADERS);
143 overhead.push(p - d);
144 }
145 overhead.sort((x, y) => x - y);
146 const median = at(overhead, 0.5);
147 const p95 = at(overhead, 0.95);
148 if (median < MEDIAN_MS && p95 < P95_MS) return;
149 verdict = `proxy overhead median ${median.toFixed(2)} ms (budget ${MEDIAN_MS}), p95 ${p95.toFixed(2)} ms (budget ${P95_MS}); five slowest phases: ${await slowest()}`;
150 }
151 throw new Error(verdict);
152 });
153 }
test/toolbar-body-close.test.ts added +46
@@ -0,0 +1,46 @@
1 /** Where the pill's tag goes: before the last `</body`, any case, else at the end (#8186). */
2 import { expect, test } from "bun:test";
3 import { withToolbar } from "../src/apps/toolbar.ts";
4
5 const TAG = `<script src="http://f.test/`;
6 const put = (html: string): string =>
7 new TextDecoder().decode(withToolbar(new TextEncoder().encode(html), "http://f.test"));
8 /** Where the tag landed, as the text before it. */
9 const before = (html: string): string => {
10 const out = put(html);
11 return out.slice(0, out.indexOf(TAG));
12 };
13
14 test("an upper-case </BODY> takes the tag", () => {
15 expect(before("<BODY>x</BODY>")).toBe("<BODY>x");
16 });
17
18 test("a mixed-case </Body takes the tag", () => {
19 expect(before("<Body>x</Body>")).toBe("<Body>x");
20 });
21
22 test("of two </body the last one wins", () => {
23 expect(before("<p></body>a</body>z")).toBe("<p></body>a");
24 });
25
26 test("a page with no </body gets the tag at the end", () => {
27 const html = "<p>no close</p> </bod </bodx";
28 expect(before(html)).toBe(html);
29 expect(put(html).endsWith("</script>")).toBe(true);
30 });
31
32 test("a page shorter than the needle gets the tag at the end", () => {
33 expect(before("")).toBe("");
34 expect(before("</body")).toBe("");
35 expect(before("</bod")).toBe("</bod");
36 expect(before("x")).toBe("x");
37 });
38
39 test("</body text inside a script near the end is where the tag goes", () => {
40 // The last </body wins, as it always has: a script that writes the text
41 // inside a string takes the tag in front of it, still inside the script.
42 const html = `<body><script>document.write("</body>")</script></body>`;
43 expect(before(html)).toBe(`<body><script>document.write("</body>")</script>`);
44 const only = `<body><script>var s = "</body>"</script>`;
45 expect(before(only)).toBe(`<body><script>var s = "`);
46 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs