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

A pull's page answers under 50 ms: its render phase under 15 ms #3987

Merged opened this

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

Merged

scout/forge:scout/3362-a-pull-s-page-answers-under-50-ms-its-re → main 4 commits fork 3 threads · 3 unresolved

  • Merged. scout/3362-a-pull-s-page-answers-under-50-ms-its-re was merged into main as 4b4bb57.

Review

Sign in to review this change.
2 files changed +187 −0
src/web/markdown.ts +36
@@ -92,7 +92,43 @@ function cells(line: string, align: string[], tag: "th" | "td"): string {
9292 .join("");
9393 }
9494
95 /**
96 * Rendering is a pure function of the source, and a thread is drawn again on
97 * every visit and every live append, so the last few hundred results are kept.
98 * The value is the same `Html` the first render produced, so output is
99 * byte-identical; only the work is skipped.
100 */
101 const MEMO_MAX = 512;
102 const MEMO_MAX_SOURCE = 20_000;
103 const memo = new Map<string, Html>();

Module-level counters and an exported stats function exist only for tests. Acceptable here; keep them from growing.

104 const counts = { hits: 0, misses: 0 };
105
106 /** How often a render was remembered or made; for tests to assert the memo works. */
107 export function markdownMemoStats(): { hits: number; misses: number } {
108 return { ...counts };
109 }
110
95111 export function renderMarkdown(source: string): Html {
112 if (source.length > MEMO_MAX_SOURCE) return renderUncached(source);
113 const hit = memo.get(source);
114 if (hit !== undefined) {
115 // Re-insert so the Map's order is recency: the oldest key is the first.
116 memo.delete(source);
outdated

The eviction uses as string on keys().next().value. It is safe here because size >= MEMO_MAX > 0, but a guard such as const oldest = memo.keys().next(); if (!oldest.done) memo.delete(oldest.value) drops the cast.

117 memo.set(source, hit);
118 counts.hits++;
119 return hit;
120 }
121 counts.misses++;
122 const made = renderUncached(source);
123 if (memo.size >= MEMO_MAX) {
124 const oldest = memo.keys().next();
125 if (!oldest.done) memo.delete(oldest.value);
126 }
127 memo.set(source, made);
128 return made;
129 }
130
131 function renderUncached(source: string): Html {
96132 const text = source.replaceAll("\r\n", "\n");
97133 // Escaping does not move a newline, so the two arrays stay in step: `lines`
98134 // is what prose is built from, `raws` is what the tokenizer needs.
test/pull-render-budget.test.ts added +151
@@ -0,0 +1,151 @@
1 /** A pull page draws its thread from remembered markdown renders: a repeat visit makes none. */
2 import { afterAll, beforeAll, expect, test } from "bun:test";
3 import { mkdtemp, rm } from "node:fs/promises";
4 import { tmpdir } from "node:os";
5 import { join } from "node:path";
6 import { createApp, type App } from "../src/app.ts";
7 import { addComment } from "../src/actions/issues.ts";
8 import { openPull, submitReview } from "../src/actions/pulls.ts";
9 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
10 import { createRun } from "../src/core/runs.ts";
11 import { createSession } from "../src/core/sessions.ts";
12 import { createUser } from "../src/core/users.ts";
13 import { serve, type Servers } from "../src/http/server.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15
16 quietLogs();
17
18 let dataDir: string;
19 let app: App;
20 let servers: Servers;
21 let cookie = "";
22 let number = 0;
23 const cells = process.env["FORGE_CELLS"];
24
25 const SECTION = [
26 "Looks **good** overall, with `inline code` and a [link](https://example.com/a?b=1&c=2).",
27 "",
28 "- first point <script>alert(1)</script>",
29 "- second point with *emphasis* and #12",
30 "",
31 "```ts",
32 "const x: number = 1;",
33 "```",
34 "",
35 "> quoted *text* with `code`",
36 "",
37 "| a | b |",
38 "| - | - |",
39 "| 1 | **2** |",
40 "",
41 "```js",
42 "function f(a, b) { return a + b; } // add",
43 "for (const k of Object.keys({})) console.log(k, \"s\", 12);",
44 "```",
45 "",
46 "1. one",
47 "2. two with [a](docs/x.md)",
48 ].join("\n");
49 // An agent's comment is several of these: long, with fences, lists and a table.
50 const BODY = Array.from({ length: 4 }, (_, i) => `## Part ${i}\n\n${SECTION}`).join("\n\n");
51
52 beforeAll(async () => {
53 process.env["FORGE_CELLS"] = "off";
54 dataDir = await mkdtemp(join(tmpdir(), "forge-pull-render-"));
55 app = createApp({ dataDir, port: 0 });
56 servers = await serve(app);
57 const mk = async (username: string) => {
58 const made = await createUser(app.db, {
59 origin: "seed",
60 trusted: true,
61 username,
62 email: `${username}@forge.local`,
63 password: "a-good-passphrase",
64 adminIfFirst: true,
65 });
66 if (!made.ok) throw new Error(String(made.error));
67 return made.value;
68 };
69 const alice = await mk("alice");
70 const bob = await mk("bob");
71 cookie = `forge_session=${createSession(app.db, alice.id).token}`;
72 const repo = await createRepo(app, alice, { name: "r", visibility: "public" });
73 if (!repo.ok) throw new Error(String(repo.error));
74 for (const [branch, file] of [[undefined, "forge.json"], ["topic", "t.txt"]] as const) {
75 const done = await commitToBranch(app, alice, repo.value, {
76 ...(branch === undefined ? {} : { branch }),
77 message: "change",
78 files: [{ path: file, content: file === "forge.json" ? "{}" : "x" }],
79 });
80 if (!done.ok) throw new Error(JSON.stringify(done.error));
81 }
82 const pull = await openPull(app, alice, repo.value, {
83 title: "t",
84 body: BODY,
85 baseBranch: "main",
86 headBranch: "topic",
87 });
88 if (!pull.ok) throw new Error(JSON.stringify(pull.error));
89 number = pull.value.number;
90 for (let i = 0; i < 30; i++) {
91 const c = addComment(app, i % 2 ? alice : bob, repo.value, pull.value, `#${i} ${BODY}`);
92 if (!c.ok) throw new Error(JSON.stringify(c.error));
93 }
94 for (const state of ["commented", "commented", "commented"] as const) {
95 const r = await submitReview(app, bob, repo.value, pull.value, { state, body: BODY });
96 if (!r.ok) throw new Error(JSON.stringify(r.error));
97 }
98 for (let i = 0; i < 3; i++) {
99 createRun(app.db, {
100 repoId: repo.value.id,
101 kind: "check",
102 issueId: pull.value.id,
103 env: `pull-${number}`,
104 commitOid: pull.value.headOid,
105 });
106 }
107 });
108
109 afterAll(async () => {
110 await servers.stop();
111 app.close();
112 if (cells === undefined) delete process.env["FORGE_CELLS"];

This test passes with or without the cache: server-timing always carries render. Either assert something the cache changes (hit count, or the second visit's render being below the first by a ratio on the same process), or rename the test to say it only checks that the page serves.

113 else process.env["FORGE_CELLS"] = cells;
114 await rm(dataDir, { recursive: true, force: true });
115 });
116
117 test("a pull page serves and names its phases", async () => {
118 const res = await fetch(`${servers.url}/alice/r/pulls/${number}`, { headers: { cookie } });
119 expect(res.status).toBe(200);
120 await res.arrayBuffer();
121 expect(res.headers.get("server-timing") ?? "").toMatch(/render;dur=[\d.]+/);
122 });
123
124 test("a second visit to a busy pull renders no markdown afresh", async () => {
125 const { markdownMemoStats } = await import("../src/web/markdown.ts");
126 const url = `${servers.url}/alice/r/pulls/${number}`;
127 await (await fetch(url, { headers: { cookie } })).arrayBuffer();
128 const before = markdownMemoStats();
129 const res = await fetch(url, { headers: { cookie } });
130 expect(res.status).toBe(200);
131 await res.arrayBuffer();
132 const after = markdownMemoStats();
133 expect(after.misses).toBe(before.misses);
134 expect(after.hits - before.hits).toBeGreaterThanOrEqual(30);
135 });
136
137 test("a repeat render of the same source returns the identical Html object", async () => {
138 const { renderMarkdown } = await import("../src/web/markdown.ts");
139 const first = renderMarkdown(BODY);
140 expect(renderMarkdown(BODY)).toBe(first);
141 });
142
143 test("a remembered markdown render is the render, byte for byte, and still escapes", async () => {
144 const { renderMarkdown } = await import("../src/web/markdown.ts");
145 const hostile = "<img src=x onerror=alert(1)> [x](javascript:alert(1)) **b**\n\n```ts\n<b>\n```";
146 const first = String(renderMarkdown(hostile));
147 expect(String(renderMarkdown(hostile))).toBe(first);
148 expect(first).not.toContain("<img");
149 expect(first).not.toContain('href="javascript:');
150 expect(first).toContain("<strong>b</strong>");
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs