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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8186-the-forge-s-app-proxy-adds-under-5-ms-to → main 4 commits fork 3 threads · 3 unresolved
4 files
changed +224
−9
src/apps/proxy.ts +12 −7
| @@ -745,9 +745,12 @@ function admit( | ||
| 745 | 745 | if (repo?.archivedAt != null) return err(archivedPage(target)); |
| 746 | 746 | // A preview stopped for want of attention is asked for: it deploys again |
| 747 | 747 | // 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) { | |
| 749 | 751 | runtime.rouse(target.app, target.env); |
| 750 | const status = runtime.envStatus(target.app, target.env); | |
| 752 | status = runtime.envStatus(target.app, target.env); | |
| 753 | } | |
| 751 | 754 | if (status === undefined || status.port === undefined) { |
| 752 | 755 | return err(unavailable(runtime, target, status, undefined, grant?.who)); |
| 753 | 756 | } |
| @@ -1183,11 +1186,13 @@ export async function proxyApp( | ||
| 1183 | 1186 | ? mintProof(grantCookie, request.headers.get("host") ?? "") |
| 1184 | 1187 | : undefined; |
| 1185 | 1188 | 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 | ), | |
| 1191 | 1196 | ); |
| 1192 | 1197 | let sent = page; |
| 1193 | 1198 | // 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): | ||
| 589 | 589 | return `<script src="${new URL(TOOLBAR_PATH, forgeUrl).href}"${data}${token} defer></script>`; |
| 590 | 590 | } |
| 591 | 591 | |
| 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; | |
| 600 | } | |
| 601 | return -1; | |
| 602 | } | |
| 603 | ||
| 592 | 604 | /** `bytes` with the pill's tag before the last `</body>`, or at the end. */ |
| 593 | 605 | export function withToolbar( |
| 594 | 606 | bytes: Uint8Array, |
| @@ -598,8 +610,7 @@ export function withToolbar( | ||
| 598 | 610 | ): Uint8Array<ArrayBuffer> { |
| 599 | 611 | const tag = Buffer.from(toolbarTag(forgeUrl, where, proof)); |
| 600 | 612 | 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); | |
| 603 | 614 | const cut = at < 0 ? buffer.length : at; |
| 604 | 615 | return Buffer.concat([buffer.subarray(0, cut), tag, buffer.subarray(cut)]); |
| 605 | 616 | } |
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 | }); |
new above
offline
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.