Public: anyone can read this; the crew can change it.
A cold code page renders off the main thread, so a cold forge serves more than ~220 a second #9633
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/9630-a-cold-code-page-renders-off-the-main-th → main 3 commits fork
7 files
changed +248
−17
bench/results/9630-worker.md added +16
| @@ -0,0 +1,16 @@ | ||
| 1 | # Cold code pages rendered off the main thread (#9630) | |
| 2 | ||
| 3 | Command: `bun bench/code-pages-at-rate.ts --rate 500 --seconds 20 --warm 0 --crawl 3000` | |
| 4 | (in-process copy, edge bypassed, 3,000 distinct pinned code pages). | |
| 5 | ||
| 6 | | | served | refused (429) | served/s | p95 | | |
| 7 | |---|---|---|---|---| | |
| 8 | | Baseline (9566-cold-forge.md) | 4,424 | 5,574 | 221 | 827 ms | | |
| 9 | | Rows in a worker only | 3,812 | 6,187 | 191 | 943 ms (within noise of baseline) | | |
| 10 | | Rows + page encode in workers, run 1 | 6,436 | 3,563 | 322 | 547 ms | | |
| 11 | | Rows + page encode in workers, run 2 | 7,015 | 2,984 | 351 | 473 ms | | |
| 12 | ||
| 13 | Highlight + row layout alone moved nothing: on the main thread the brotli/gzip of the ~150 KB page | |
| 14 | was the larger share (`codePage+keep` ~4 ms vs `pane` ~1 ms once rows were out). Both now run on two | |
| 15 | workers (`src/web/code_rows_pool.ts`); past 64 jobs in flight, or a 5 s timeout, the page does the work inline. | |
| 16 | Runs differ ~15% on a shared machine. Refused strangers fall from 5,574 to ~3,000-3,560; still 429. |
src/web/code_rows.ts added +30
| @@ -0,0 +1,30 @@ | ||
| 1 | /** | |
| 2 | * The table rows of a highlighted file: plain data in, one string out. The | |
| 3 | * page builds them inline for a warm read and in a worker (`code_rows_pool.ts`) | |
| 4 | * for a cold one, from this one function, so the markup is the same either way. | |
| 5 | */ | |
| 6 | import { countLines, highlightPrefix } from "./code.ts"; | |
| 7 | import { html, type Html } from "./html.ts"; | |
| 8 | ||
| 9 | /** What a file page draws of a text file. */ | |
| 10 | export interface CodeRows { | |
| 11 | /** Lines in the whole file. */ | |
| 12 | readonly total: number; | |
| 13 | /** Lines drawn (the first `limit`). */ | |
| 14 | readonly shown: number; | |
| 15 | /** The `<tr>` rows, already escaped. */ | |
| 16 | readonly rows: string; | |
| 17 | } | |
| 18 | ||
| 19 | /** One `<tr>` per highlighted line, numbered from 1. */ | |
| 20 | export function rowsMarkup(lines: readonly Html[]): string { | |
| 21 | return lines | |
| 22 | .map((line, i) => html`<tr id="L${i + 1}"><td class="ln"><a href="#L${i + 1}">${i + 1}</a></td><td><pre>${line}</pre></td></tr>`.text) | |
| 23 | .join(""); | |
| 24 | } | |
| 25 | ||
| 26 | /** The first `limit` lines of `text` highlighted for `path`, as rows. */ | |
| 27 | export function codeRows(text: string, path: string, limit: number): CodeRows { | |
| 28 | const lines = highlightPrefix(text, path, limit); | |
| 29 | return { total: countLines(text), shown: lines.length, rows: rowsMarkup(lines) }; | |
| 30 | } |
src/web/code_rows_pool.ts added +84
| @@ -0,0 +1,84 @@ | ||
| 1 | /** | |
| 2 | * Cold code pages highlight and lay out their rows on worker threads, so the | |
| 3 | * main loop keeps serving while a crawl asks for pages nobody has kept (#9630). | |
| 4 | * Two workers, started on first use; a job that fails or runs past | |
| 5 | * `JOB_MS` answers undefined and the caller does the work inline. | |
| 6 | */ | |
| 7 | import type { Encoding } from "../http/compress.ts"; | |
| 8 | import type { CodeRows } from "./code_rows.ts"; | |
| 9 | import type { Job } from "./code_rows_worker.ts"; | |
| 10 | ||
| 11 | const WORKERS = 2; | |
| 12 | const JOB_MS = 5_000; | |
| 13 | /** Jobs in flight past which the caller renders inline instead of queueing. */ | |
| 14 | const MAX_PENDING = 64; | |
| 15 | ||
| 16 | type Reply = { id: number; result?: unknown; error?: string }; | |
| 17 | ||
| 18 | const pool: Worker[] = []; | |
| 19 | const waiting = new Map<number, { worker: Worker; done: (result: unknown) => void }>(); | |
| 20 | let next = 0; | |
| 21 | ||
| 22 | function start(): Worker | undefined { | |
| 23 | try { | |
| 24 | const w = new Worker(new URL("./code_rows_worker.ts", import.meta.url).href); | |
| 25 | w.onmessage = (e: MessageEvent<Reply>) => { | |
| 26 | const job = waiting.get(e.data.id); | |
| 27 | waiting.delete(e.data.id); | |
| 28 | job?.done(e.data.result); | |
| 29 | }; | |
| 30 | w.onerror = () => { | |
| 31 | const at = pool.indexOf(w); | |
| 32 | if (at >= 0) pool.splice(at, 1); | |
| 33 | w.terminate(); | |
| 34 | // Its in-flight jobs will never answer: let their callers fall back now, not after JOB_MS. | |
| 35 | for (const [id, job] of waiting) { | |
| 36 | if (job.worker !== w) continue; | |
| 37 | waiting.delete(id); | |
| 38 | job.done(undefined); | |
| 39 | } | |
| 40 | }; | |
| 41 | w.unref(); | |
| 42 | return w; | |
| 43 | } catch { | |
| 44 | return undefined; | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | type Spec = Job extends infer J ? (J extends Job ? Omit<J, "id"> : never) : never; | |
| 49 | ||
| 50 | function run<T>(job: Spec): Promise<T | undefined> { | |
| 51 | if (waiting.size >= MAX_PENDING) return Promise.resolve(undefined); | |
| 52 | if (pool.length < WORKERS) { | |
| 53 | const w = start(); | |
| 54 | if (w) pool.push(w); | |
| 55 | } | |
| 56 | const worker = pool[next++ % Math.max(pool.length, 1)]; | |
| 57 | if (!worker) return Promise.resolve(undefined); | |
| 58 | const id = next; | |
| 59 | return new Promise((resolve) => { | |
| 60 | const timer = setTimeout(() => { | |
| 61 | waiting.delete(id); | |
| 62 | resolve(undefined); | |
| 63 | }, JOB_MS); | |
| 64 | waiting.set(id, { | |
| 65 | worker, | |
| 66 | done: (result) => { | |
| 67 | clearTimeout(timer); | |
| 68 | resolve(result as T | undefined); | |
| 69 | }, | |
| 70 | }); | |
| 71 | worker.postMessage({ ...job, id }); | |
| 72 | }); | |
| 73 | } | |
| 74 | ||
| 75 | /** Rows for a file's bytes, or undefined when the caller should build them itself. */ | |
| 76 | export const rowsOffThread = (body: Uint8Array, path: string, limit: number): Promise<CodeRows | undefined> => | |
| 77 | run<CodeRows>({ kind: "rows", body, path, limit }); | |
| 78 | ||
| 79 | /** A finished page's bytes encoded, or undefined when the caller should encode them itself. */ | |
| 80 | export const encodeOffThread = (body: Uint8Array<ArrayBuffer>, encoding: Encoding): Promise<Uint8Array | undefined> => | |
| 81 | run<Uint8Array>({ kind: "encode", body, encoding }); | |
| 82 | ||
| 83 | /** Test seam: the live workers. */ | |
| 84 | export const workersForTest = (): readonly Worker[] => pool; |
src/web/code_rows_worker.ts added +23
| @@ -0,0 +1,23 @@ | ||
| 1 | /** The worker side of `code_rows_pool.ts`: lay out rows, or encode a finished page. */ | |
| 2 | import { compressBytes, type Encoding } from "../http/compress.ts"; | |
| 3 | import { codeRows } from "./code_rows.ts"; | |
| 4 | ||
| 5 | declare const self: Worker; | |
| 6 | ||
| 7 | export type Job = | |
| 8 | | { id: number; kind: "rows"; body: Uint8Array; path: string; limit: number } | |
| 9 | | { id: number; kind: "encode"; body: Uint8Array<ArrayBuffer>; encoding: Encoding }; | |
| 10 | ||
| 11 | self.onmessage = (e: MessageEvent<Job>) => { | |
| 12 | const job = e.data; | |
| 13 | try { | |
| 14 | if (job.kind === "rows") { | |
| 15 | const text = new TextDecoder().decode(job.body).replace(/\n$/, ""); | |
| 16 | postMessage({ id: job.id, result: codeRows(text, job.path, job.limit) }); | |
| 17 | } else { | |
| 18 | postMessage({ id: job.id, result: compressBytes(job.body, job.encoding) }); | |
| 19 | } | |
| 20 | } catch (error) { | |
| 21 | postMessage({ id: job.id, error: String(error) }); | |
| 22 | } | |
| 23 | }; |
src/web/pages/kept_page.ts +7 −3
| @@ -25,6 +25,7 @@ | ||
| 25 | 25 | import { encodeRecent, negotiate, type Encoding } from "../../http/compress.ts"; |
| 26 | 26 | import type { Ctx } from "../../http/context.ts"; |
| 27 | 27 | import { ViewCache } from "../../view/cache.ts"; |
| 28 | import { encodeOffThread } from "../code_rows_pool.ts"; | |
| 28 | 29 | import { readFlash } from "../flash.ts"; |
| 29 | 30 | import { signInHref } from "../layout.ts"; |
| 30 | 31 | |
| @@ -85,11 +86,14 @@ export function keptPage(key: string): Response | undefined { | ||
| 85 | 86 | return new Response(hit.body, { status: 200, headers: new Headers([...hit.headers]) }); |
| 86 | 87 | } |
| 87 | 88 | |
| 88 | /** Keep `response`'s bytes, encoded, under `key`, and answer with them. Anything but a plain 200 goes out as it came. */ | |
| 89 | export async function keepPage(key: string, response: Response): Promise<Response> { | |
| 89 | /** Keep `response`'s bytes, encoded, under `key`, and answer with them. `offThread` encodes on a worker. Anything but a plain 200 goes out as it came. */ | |
| 90 | export async function keepPage(key: string, response: Response, offThread = false): Promise<Response> { | |
| 90 | 91 | if (response.status !== 200 || response.headers.getSetCookie().length > 0 || response.headers.has("content-encoding")) return response; |
| 91 | 92 | const encoding = encodingOf(key); |
| 92 | const body = encodeRecent(new Uint8Array(await response.arrayBuffer()), encoding); | |
| 93 | const bytes = new Uint8Array(await response.arrayBuffer()); | |
| 94 | // A code page's encode is its biggest main-thread cost; a worker does it when one is free (#9630). | |
| 95 | // Only the code page asks: other pages are small and keep the inline encode. | |
| 96 | const body = (offThread ? await encodeOffThread(bytes, encoding) : undefined) ?? encodeRecent(bytes, encoding); | |
| 93 | 97 | const headers: [string, string][] = [...response.headers].filter(([name]) => KEPT_HEADERS.has(name)); |
| 94 | 98 | headers.push(["content-encoding", encoding]); |
| 95 | 99 | store.set(key, { body, headers }, TTL_MS, body.length + 1024); |
src/web/pages/repo.ts +32 −14
| @@ -78,7 +78,9 @@ import { appStrip } from "./apps.ts"; | ||
| 78 | 78 | import { studioApp } from "../studio/app.ts"; |
| 79 | 79 | import { getAppByRepo } from "../../core/apps.ts"; |
| 80 | 80 | import { field, readForm } from "../form.ts"; |
| 81 | import { html, when, type Html } from "../html.ts"; | |
| 81 | import { html, raw, when, type Html } from "../html.ts"; | |
| 82 | import { type CodeRows, rowsMarkup } from "../code_rows.ts"; | |
| 83 | import { rowsOffThread } from "../code_rows_pool.ts"; | |
| 82 | 84 | import { flashTo, fragment, page, redirect } from "../layout.ts"; |
| 83 | 85 | import { renderMarkdown } from "../markdown.ts"; |
| 84 | 86 | import { csvJs, isCsvPath } from "../render/csv.ts"; |
| @@ -693,6 +695,7 @@ export function blobBody( | ||
| 693 | 695 | blob: Blob, |
| 694 | 696 | highlighted?: (ms: number) => void, |
| 695 | 697 | all = false, |
| 698 | ready?: CodeRows, | |
| 696 | 699 | ): { content: Html; lines: number } { |
| 697 | 700 | const encoded = encodeURIComponent(ref); |
| 698 | 701 | if (blob.kind === "binary") { |
| @@ -725,30 +728,35 @@ export function blobBody( | ||
| 725 | 728 | }), |
| 726 | 729 | }; |
| 727 | 730 | } |
| 728 | const text = new TextDecoder().decode(blob.body).replace(/\n$/, ""); | |
| 729 | 731 | const limit = all ? MAX_RENDER_LINES : FIRST_LINES; |
| 730 | 732 | // Highlighting runs left to right, so the first N lines highlight the same |
| 731 | 733 | // alone as inside the whole file; only that prefix is read and painted. |
| 732 | const began = performance.now(); | |
| 733 | const total = countLines(text); | |
| 734 | const lines = highlightPrefix(text, path, limit); | |
| 735 | highlighted?.(performance.now() - began); | |
| 736 | const truncated = total > lines.length; | |
| 734 | let total: number; | |
| 735 | let shown: number; | |
| 736 | let rows: string; | |
| 737 | if (ready !== undefined) ({ total, shown, rows } = ready); | |
| 738 | else { | |
| 739 | const text = new TextDecoder().decode(blob.body).replace(/\n$/, ""); | |
| 740 | const began = performance.now(); | |
| 741 | total = countLines(text); | |
| 742 | const lines = highlightPrefix(text, path, limit); | |
| 743 | highlighted?.(performance.now() - began); | |
| 744 | shown = lines.length; | |
| 745 | rows = rowsMarkup(lines); | |
| 746 | } | |
| 747 | const truncated = total > shown; | |
| 737 | 748 | const fileHref = `${base}/blob/${encoded}/${path}`; |
| 738 | 749 | return { |
| 739 | 750 | lines: total, |
| 740 | 751 | content: html`<div class="card code"> |
| 741 | 752 | <table> |
| 742 | ${lines.map( | |
| 743 | (line, i) => | |
| 744 | html`<tr id="L${i + 1}"><td class="ln"><a href="#L${i + 1}">${i + 1}</a></td><td><pre>${line}</pre></td></tr>`, | |
| 745 | )} | |
| 753 | ${raw(rows)} | |
| 746 | 754 | </table> |
| 747 | 755 | </div> |
| 748 | 756 | ${when(truncated, () => |
| 749 | 757 | empty({ |
| 750 | 758 | icon: "file", |
| 751 | title: `${(total - lines.length).toLocaleString("en-US")} more lines`, | |
| 759 | title: `${(total - shown).toLocaleString("en-US")} more lines`, | |
| 752 | 760 | note: all |
| 753 | 761 | ? "The rest is in the raw file." |
| 754 | 762 | : "The page draws the first lines so it opens at once. A link to a later line (#L…) lands once you show all lines.", |
| @@ -1113,6 +1121,8 @@ export function renderBlob( | ||
| 1113 | 1121 | highlighted?: (ms: number) => void; |
| 1114 | 1122 | /** Draw every line instead of the first 1,500. */ |
| 1115 | 1123 | all?: boolean; |
| 1124 | /** Rows a worker already built for this file. */ | |
| 1125 | rows?: CodeRows; | |
| 1116 | 1126 | }, |
| 1117 | 1127 | ): Html { |
| 1118 | 1128 | const base = scope.base; |
| @@ -1135,7 +1145,7 @@ export function renderBlob( | ||
| 1135 | 1145 | const { content, lines, summary } = |
| 1136 | 1146 | drawn !== undefined && fellBack === undefined |
| 1137 | 1147 | ? drawn |
| 1138 | : { ...blobBody(base, o.ref, o.path, blob, o.highlighted, o.all), summary: undefined }; | |
| 1148 | : { ...blobBody(base, o.ref, o.path, blob, o.highlighted, o.all, o.rows), summary: undefined }; | |
| 1139 | 1149 | const sizeText = |
| 1140 | 1150 | blob.kind === "too_large" ? "" : sizeWithTokens(blob.size, o.path); |
| 1141 | 1151 | const fileHref = `${base}/blob/${encoded}/${o.path}`; |
| @@ -1620,7 +1630,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1620 | 1630 | git.done(); |
| 1621 | 1631 | return immutableWhenAddressed(ctx.request, true, repo, ref, finished); |
| 1622 | 1632 | } |
| 1623 | const keepFinished = (response: Response) => (keptKey === undefined ? response : keepPage(keptKey, response)); | |
| 1633 | const keepFinished = (response: Response) => (keptKey === undefined ? response : keepPage(keptKey, response, true)); | |
| 1624 | 1634 | if (kept) { |
| 1625 | 1635 | const hit = cachedBlobPage(repo.id, ref, path, String(view), all); |
| 1626 | 1636 | if (hit !== undefined) { |
| @@ -1649,7 +1659,15 @@ export async function blobPage(ctx: Ctx): Promise<Response> { | ||
| 1649 | 1659 | let highlightMs: number | undefined; |
| 1650 | 1660 | const renderStart = performance.now(); |
| 1651 | 1661 | const shown = capped(blob.value); |
| 1662 | // A cold page for a stranger highlights and lays out its rows on a worker (#9630). | |
| 1663 | let rows: CodeRows | undefined; | |
| 1664 | if (kept && shown.kind === "text" && (view === "source" || !blobRenderer(scope, path, shown))) { | |
| 1665 | const began = performance.now(); | |
| 1666 | rows = await rowsOffThread(shown.body, path, all ? MAX_RENDER_LINES : FIRST_LINES); | |
| 1667 | if (rows !== undefined) notePhase(ctx.request, "highlight", performance.now() - began); | |
| 1668 | } | |
| 1652 | 1669 | const pane = renderBlob(scope, { |
| 1670 | ...(rows !== undefined ? { rows } : {}), | |
| 1653 | 1671 | refs, |
| 1654 | 1672 | ref, |
| 1655 | 1673 | path, |
test/code-rows-worker.test.ts added +56
| @@ -0,0 +1,56 @@ | ||
| 1 | import { expect, test } from "bun:test"; | |
| 2 | import { codeRows } from "../src/web/code_rows.ts"; | |
| 3 | import { rowsOffThread } from "../src/web/code_rows_pool.ts"; | |
| 4 | ||
| 5 | const src = new TextEncoder().encode("const a = 1;\nif (a < 2) { return \"<b>\"; }\nlast\n"); | |
| 6 | ||
| 7 | test("a worker builds the same rows the main thread would", async () => { | |
| 8 | const made = await rowsOffThread(src, "x.ts", 1_500); | |
| 9 | const text = new TextDecoder().decode(src).replace(/\n$/, ""); | |
| 10 | expect(made).toEqual(codeRows(text, "x.ts", 1_500)); | |
| 11 | expect(made!.shown).toBe(3); | |
| 12 | expect(made!.rows).toContain('id="L3"'); | |
| 13 | expect(made!.rows).not.toContain("<b>"); | |
| 14 | }); | |
| 15 | ||
| 16 | test("only the first lines are drawn but the whole file is counted", async () => { | |
| 17 | const body = new TextEncoder().encode(Array.from({ length: 50 }, (_, i) => `line ${i}`).join("\n")); | |
| 18 | const made = await rowsOffThread(body, "a.txt", 10); | |
| 19 | expect(made).toMatchObject({ total: 50, shown: 10 }); | |
| 20 | }); | |
| 21 | ||
| 22 | test("a worker encodes a page to the bytes the main thread would", async () => { | |
| 23 | const { compressBytes } = await import("../src/http/compress.ts"); | |
| 24 | const { encodeOffThread } = await import("../src/web/code_rows_pool.ts"); | |
| 25 | const page = new TextEncoder().encode("<p>hello</p>".repeat(500)); | |
| 26 | const made = await encodeOffThread(page, "gzip"); | |
| 27 | expect(Buffer.from(made!).equals(Buffer.from(compressBytes(page, "gzip")))).toBe(true); | |
| 28 | }); | |
| 29 | ||
| 30 | test("a saturated pool answers undefined at once, and keepPage still stores a correct page", async () => { | |
| 31 | const { keepPage, keptPage } = await import("../src/web/pages/kept_page.ts"); | |
| 32 | const { encodeRecent } = await import("../src/http/compress.ts"); | |
| 33 | const big = new TextEncoder().encode("x\n".repeat(20_000)); | |
| 34 | const jobs = Array.from({ length: 64 }, () => rowsOffThread(big, "a.txt", 1_500)); | |
| 35 | expect(await rowsOffThread(src, "x.ts", 10)).toBeUndefined(); // the 65th: MAX_PENDING | |
| 36 | const html = new TextEncoder().encode("<p>kept</p>".repeat(300)); | |
| 37 | const key = "gzip\0saturated-test"; | |
| 38 | const served = await keepPage(key, new Response(html, { headers: { "content-type": "text/html" } }), true); | |
| 39 | const expected = encodeRecent(html, "gzip"); | |
| 40 | expect(Buffer.from(await served.arrayBuffer()).equals(Buffer.from(expected))).toBe(true); | |
| 41 | const again = keptPage(key); | |
| 42 | expect(Buffer.from(await again!.arrayBuffer()).equals(Buffer.from(expected))).toBe(true); | |
| 43 | await Promise.all(jobs); | |
| 44 | }); | |
| 45 | ||
| 46 | test("a worker that errors answers its in-flight jobs undefined at once, not after the timeout", async () => { | |
| 47 | const { workersForTest } = await import("../src/web/code_rows_pool.ts"); | |
| 48 | const big = new TextEncoder().encode("x\n".repeat(200_000)); | |
| 49 | const pending = Array.from({ length: 4 }, () => rowsOffThread(big, "a.txt", 1_500)); | |
| 50 | const began = performance.now(); | |
| 51 | for (const w of [...workersForTest()]) w.onerror?.(new ErrorEvent("error")); | |
| 52 | expect(await Promise.all(pending)).toEqual([undefined, undefined, undefined, undefined]); | |
| 53 | expect(performance.now() - began).toBeLessThan(1_000); | |
| 54 | // The pool starts fresh workers for the next job. | |
| 55 | expect(await rowsOffThread(src, "x.ts", 1_500)).toBeDefined(); | |
| 56 | }); |