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

The forge holds 500 anonymous code pages a second, with people still fast #9499

Merged opened this in answer to

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

Merged

torque/forge:torque/9405-the-forge-holds-500-anonymous-code-pages → main 5 commits fork 2 threads · 2 unresolved

  • Merged. torque/9405-the-forge-holds-500-anonymous-code-pages was merged into main as f947e7b.

Review

Sign in to review this change.
8 files changed +372 −14
bench/load.ts added +82
@@ -0,0 +1,82 @@
1 /**

Escape repo before putting it in the RegExp, and add a test that --compare exits 1 on a fall. Commit the first result under bench/results/.

2 * The load test (#9405): an open-loop reader of code pages against a copy of
3 * the forge, run from any machine that can reach it.
4 *
5 * bun bench/load.ts --url https://copy.example --repo owner/name \
6 * --rate 500 --seconds 20 --version 0.7.386 --out bench/results/0.7.386.json
7 * bun bench/load.ts ... --compare bench/results/0.7.385.json # exits 1 on a >20% fall
8 *
9 * It reads the repository's front page, collects its commit-pinned tree and
10 * blob links, then asks for them at `--rate` a second regardless of how fast
11 * the forge answers (open loop: a slow forge queues, as strangers would).
12 * Each request is a tree, blob or raw page of a pinned commit, anonymous.
13 * Point it only at a copy you run; never at a forge you do not own.
14 */
15
16 import { readFileSync, writeFileSync } from "node:fs";
17 import { fell, summarize, type LoadResult } from "../src/perf/load_verdict.ts";
18
19 const arg = (name: string, fallback?: string): string => {
20 const at = process.argv.indexOf(`--${name}`);
21 const v = at >= 0 ? process.argv[at + 1] : fallback;
22 if (v === undefined) throw new Error(`--${name} is required`);
23 return v;
24 };
25
26 const base = arg("url").replace(/\/$/, "");
27 const repo = arg("repo");
28 const rate = Number(arg("rate", "100"));
29 const seconds = Number(arg("seconds", "10"));
30 const version = arg("version", "unversioned");
31 if (!/^[\w.-]+\/[\w.-]+$/.test(repo) || !(rate > 0) || !(seconds > 0)) throw new Error("bad --repo, --rate or --seconds");
32
33 const front = await (await fetch(`${base}/${repo}`)).text();
34 const home = await (await fetch(`${base}/${repo}/tree/main`)).text();
35 const pinned = new Set<string>();
36 for (const m of (front + home).matchAll(new RegExp(`/${repo.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}/(?:tree|blob)/[0-9a-f]{40}[^"'<> ]*`, "g"))) {
37 pinned.add(m[0]);
38 pinned.add(m[0].replace("/tree/", "/raw/").replace("/blob/", "/raw/"));
39 }
40 const paths = [...pinned].filter((p) => !p.includes("/raw/") || p.split("/").length > 5);
41 if (paths.length === 0) throw new Error("no commit-pinned links found on the repository's pages");
42
43 const latencies: number[] = [];
44 let refused = 0;
45 const inflight = new Set<Promise<void>>();
46 const ask = (path: string): Promise<void> => {
47 const began = performance.now();
48 return fetch(`${base}${path}`, { redirect: "manual" })
49 .then(async (r) => {
50 await r.arrayBuffer();
51 if (r.status < 400) latencies.push(performance.now() - began);
52 else refused++;
53 })
54 .catch(() => {
55 refused++;
56 });
57 };
58
59 const started = performance.now();
60 let sent = 0;
61 while (performance.now() - started < seconds * 1000) {
62 const due = Math.floor(((performance.now() - started) / 1000) * rate);
63 while (sent < due) {
64 const p = ask(paths[Math.floor(Math.random() * paths.length)]!);
65 inflight.add(p);
66 void p.finally(() => inflight.delete(p));
67 sent++;
68 }
69 await Bun.sleep(2);
70 }
71 await Promise.all(inflight);
72
73 const result: LoadResult = summarize(version, rate, seconds, latencies, refused);
74 console.log(JSON.stringify(result, null, 2));
75 const out = process.argv.includes("--out") ? arg("out") : undefined;
76 if (out !== undefined) writeFileSync(out, JSON.stringify(result, null, 2) + "\n");
77 const last = process.argv.includes("--compare") ? (JSON.parse(readFileSync(arg("compare"), "utf8")) as LoadResult) : undefined;
78 const why = last === undefined ? undefined : fell(last, result);
79 if (why !== undefined) {
80 console.error(`LOAD REGRESSION: ${why}`);
81 process.exit(1);
82 }
docs/features/load-test.md added +22
@@ -0,0 +1,22 @@
1 # Load test (#9405)
2
3 `bench/load.ts` reads a copy of the forge the way strangers do: anonymous tree, blob and raw pages of commit-pinned links, asked at a fixed rate whether or not the forge keeps up. It prints rate served, refused, p50 and p95 as JSON.
4
5 ```sh
6 bun bench/load.ts --url https://copy.example --repo owner/name --rate 500 --seconds 20 \
7 --version 0.7.386 --out bench/results/0.7.386.json --compare bench/results/0.7.385.json
8 ```
9
10 Run it from a machine that is not the one serving. `--compare` exits 1 when the served rate is more than 20% under the last kept result (`src/perf/load_verdict.ts`), so run it in the release step and keep each release's file in `bench/results/`.
11
12 What is kept after the first read (`web/pages/tree_cache.ts`): a stranger's tree and blob page of a public repository, and the bytes of a small `/raw` file, at a full commit id. A second read of any of them spawns no git (`test/anonymous-deep.test.ts`).
13
14 Media (`/media/…`, the route a PDF or image is drawn from) is **not covered**: it is not kept, each read spawns git. The pinned blob page that embeds it is kept.
15
16 The bypasses are tested (`test/anonymous-deep.test.ts`): a signed-in reader, a repository made private, `?fragment=1` and a branch-name ref never get a page kept for a stranger. `--compare` exiting 1 is tested (`test/load-bench.test.ts`).
17
18 No baseline is kept in `bench/results/` yet: the pull's preview holds no repository to read, and the load must not be aimed at production. The first file is written by the first run against a copy with data.
19
20 Follow-ups: profile #9503, baseline and release wiring #9504, media #9505.
21
22 Not done here: the profile of tree/blob/raw/media under production's shape, the p95 targets at 500/s, and wiring the comparison into the release check. Those need a machine and data this run does not have.
src/perf/load_verdict.ts added +60
@@ -0,0 +1,60 @@
1 /**

Fine and tested. It only becomes the release gate once a baseline exists and the comparison is wired in. Say plainly in the pull which release step will call it.

2 * What a load run reached, and whether a release fell from the last one (#9405).
3 * Plain numbers in, plain numbers out: `bench/load.ts` measures, this judges.
4 *
5 * Served rate = answered 2xx/3xx responses ÷ run seconds (requests/s).
6 * Percentile = nearest-rank: the value at index ceil(p/100·n) − 1 of the sorted
7 * latencies (ms), the usual definition for a latency report.
8 */
9
10 export interface LoadResult {
11 readonly version: string;
12 /** The rate asked for, requests per second. */
13 readonly asked: number;
14 readonly seconds: number;
15 /** Responses that were 2xx or 3xx. */
16 readonly served: number;
17 /** Responses that were 429, 5xx or never came back. */
18 readonly refused: number;
19 /** Served per second over the run. */
20 readonly servedPerSecond: number;
21 readonly p50: number;
22 readonly p95: number;
23 }
24
25 /** The share of the last release's served rate a release may lose (20%). */
26 export const ALLOWED_FALL = 0.2;
27
28 export function percentile(sortedMs: readonly number[], p: number): number {
29 if (sortedMs.length === 0) return 0;
30 const rank = Math.ceil((p / 100) * sortedMs.length);
31 return sortedMs[Math.min(sortedMs.length, Math.max(1, rank)) - 1]!;
32 }
33
34 export function summarize(
35 version: string,
36 asked: number,
37 seconds: number,
38 latenciesMs: readonly number[],
39 refused: number,
40 ): LoadResult {
41 const sorted = [...latenciesMs].sort((a, b) => a - b);
42 const round = (n: number) => Math.round(n * 10) / 10;
43 return {
44 version,
45 asked,
46 seconds,
47 served: sorted.length,
48 refused,
49 servedPerSecond: round(sorted.length / seconds),
50 p50: round(percentile(sorted, 50)),
51 p95: round(percentile(sorted, 95)),
52 };
53 }
54
55 /** Words for a failed comparison, or undefined when `now` holds within `ALLOWED_FALL` of `last`. */
56 export function fell(last: LoadResult, now: LoadResult): string | undefined {
57 const floor = last.servedPerSecond * (1 - ALLOWED_FALL);
58 if (now.servedPerSecond >= floor) return undefined;
59 return `served ${now.servedPerSecond}/s at ${now.version}, under ${Math.round(floor * 10) / 10}/s (80% of ${last.servedPerSecond}/s at ${last.version})`;
60 }
src/web/pages/repo.ts +52 −13
@@ -11,7 +11,7 @@
1111 import { recordLook } from "../../flow/valve_store.ts";
1212 import { PINNED_PUBLIC, immutableWhenAddressed, mayShare } from "../../http/addressed.ts";
1313 import { EDGE_VARY } from "../../http/edge_cache.ts";
14 import { cachedTree, keepTree, resolveCommit } from "./tree_cache.ts";
14 import { cachedBlobPage, cachedRaw, cachedTree, keepBlobPage, keepRaw, keepTree, resolveCommit } from "./tree_cache.ts";
1515 import { behindLine, followingLine, originAheadLine } from "./upstream-follow.ts";
1616 import { CONFLICT_WORDS, UNREAD_WORDS } from "../../origins/ahead.ts";
1717 import { upstreamOffer } from "../../actions/offer_upstream.ts";
@@ -1593,12 +1593,37 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
15931593 const path = ctx.params.path ?? "";
15941594 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
15951595 const git = gitClock(ctx.request);
1596 // A stranger's page at a full commit id is kept under that id (#9405): the
1597 // second read of it walks no git at all.
1598 const kept =
1599 ctx.auth.user === undefined && repo.visibility === "public" && FULL_OID.test(ref) && ctx.url.searchParams.get("fragment") !== "1";
1600 const view = blobView(ctx.url.searchParams.get("view"));
1601 const all = ctx.url.searchParams.get("all") === "1";
1602 if (kept) {
1603 const hit = cachedBlobPage(repo.id, ref, path, String(view), all);
1604 if (hit !== undefined) {
1605 git.done();
1606 return immutableWhenAddressed(
1607 ctx.request,
1608 true,
1609 repo,
1610 ref,
1611 codePage(ctx, scope, {
1612 title: hit.title,
1613 ref,
1614 explorer: hit.explorer,
1615 content: hit.content,
1616 objectSrc: hit.objectSrc,
1617 ...(hit.csv ? { extraJs: csvJs } : {}),
1618 }),
1619 );
1620 }
1621 }
15961622 const blob = await git.read(() => scope.source.blob(ref, path));
15971623 if (!blob.ok) return notFoundPage(ctx);
15981624 const refs = await git.read(() => refSetFrom(scope.source));
15991625 let highlightMs: number | undefined;
16001626 const renderStart = performance.now();
1601 const view = blobView(ctx.url.searchParams.get("view"));
16021627 const shown = capped(blob.value);
16031628 const pane = renderBlob(scope, {
16041629 refs,
@@ -1607,7 +1632,7 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16071632 blob: shown,
16081633 view,
16091634 highlighted: (ms) => (highlightMs = (highlightMs ?? 0) + ms),
1610 all: ctx.url.searchParams.get("all") === "1",
1635 all,
16111636 });
16121637 if (highlightMs !== undefined) notePhase(ctx.request, "highlight", highlightMs);
16131638 const paneMs = performance.now() - renderStart - (highlightMs ?? 0);
@@ -1626,19 +1651,29 @@ export async function blobPage(ctx: Ctx): Promise<Response> {
16261651 git.done();
16271652 const filename = path.split("/").pop() ?? path;
16281653 const pageStart = performance.now();
1654 const explorerHtml = explorerColumn(scope, explorer, {
1655 finder: true,
1656 actions: newFileAction(
1657 scope,
1658 ref,
1659 path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "",
1660 ),
1661 });
1662 const objectSrc = drawsPdf(scope, path, shown, view);
1663 if (kept)
1664 keepBlobPage(repo.id, ref, path, String(view), all, {
1665 title: `${filename} · ${repo.name}`,
1666 explorer: explorerHtml,
1667 content: pane,
1668 objectSrc,
1669 csv: isCsvPath(path),
1670 });
16291671 const response = await codePage(ctx, scope, {
16301672 title: `${filename} · ${repo.name}`,
16311673 ref,
1632 explorer: explorerColumn(scope, explorer, {
1633 finder: true,
1634 actions: newFileAction(
1635 scope,
1636 ref,
1637 path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "",
1638 ),
1639 }),
1674 explorer: explorerHtml,
16401675 content: pane,
1641 objectSrc: drawsPdf(scope, path, shown, view),
1676 objectSrc,
16421677 ...(isCsvPath(path) ? { extraJs: csvJs } : {}),
16431678 });
16441679 notePhase(ctx.request, "render", paneMs + performance.now() - pageStart);
@@ -1691,9 +1726,13 @@ export async function rawBlob(ctx: Ctx): Promise<Response> {
16911726 if (!isSafeRef(ref) || !isSafeTreePath(path)) return notFoundPage(ctx);
16921727
16931728 const dir = repoDir(ctx.app.reposRoot, repo.ownerName, repo.name);
1694 const blob = await blobForRender(dir, ref, path, 25_000_000);
1729 // At a full commit id the bytes never change: a small file is kept (#9405).
1730 const pinned = FULL_OID.test(ref);
1731 const held = pinned ? cachedRaw(repo.id, ref, path) : undefined;
1732 const blob = held ?? (await blobForRender(dir, ref, path, 25_000_000));
16951733 if (blob.kind === "missing" || blob.kind === "too_large")
16961734 return notFoundPage(ctx);
1735 if (pinned && held === undefined) keepRaw(repo.id, ref, path, blob);
16971736 return rawResponse(blob, {
16981737 oid: blob.oid,
16991738 immutable: FULL_OID.test(ref),
src/web/pages/tree_cache.ts +39 −1
@@ -15,6 +15,7 @@
1515 import { isFullId } from "../../http/addressed.ts";
1616 import { refTip } from "../../git/read.ts";
1717 import { ViewCache } from "../../view/cache.ts";
18 import type { BlobForRender } from "../../git/read.ts";
1819 import type { Html } from "../html.ts";
1920
2021 export const TTL_MS = 60_000;
@@ -45,4 +46,41 @@ export function keepTree(repoId: string, commit: string, ref: string, path: stri
4546 export const treeCacheStats = () => cache.stats();
4647
4748 /** Test seam: start from nothing. */
48 export const clearTreeCache = (): void => cache.clear();
49 export const clearTreeCache = (): void => {
50 cache.clear();
51 rawCache.clear();
52 };
53
54 /**
55 * A file's page at one full commit id (#9405). Same reasoning as the listing:
56 * a commit names its bytes forever, so an anonymous reader's markup depends on
57 * nothing but the commit, the path and the view; after the first read the page
58 * costs no git read at all.
59 */
60 export interface RenderedBlob extends RenderedTree {
61 readonly title: string;
62 readonly objectSrc: boolean;
63 readonly csv: boolean;
64 }
65
66 const blobKey = (repoId: string, commit: string, path: string, view: string, all: boolean): string =>
67 ["blob", repoId, commit, path, view, all ? "all" : "window"].join("\0");
68
69 export const cachedBlobPage = (repoId: string, commit: string, path: string, view: string, all: boolean) =>
70 cache.get<RenderedBlob>(blobKey(repoId, commit, path, view, all));
71
72 export function keepBlobPage(repoId: string, commit: string, path: string, view: string, all: boolean, page: RenderedBlob): void {
73 cache.set(blobKey(repoId, commit, path, view, all), page, TTL_MS, page.explorer.text.length + page.content.text.length);
74 }
75
76 /** A file's bytes at a full commit id, for `/raw`: small bodies only, so a few big files cannot fill the cache. */
77 const RAW_MAX = 1024 * 1024;
78 const rawCache = new ViewCache({ maxEntries: 512, maxBytes: 32 * 1024 * 1024 });
79 const rawKey = (repoId: string, commit: string, path: string): string => ["raw", repoId, commit, path].join("\0");
80
81 export const cachedRaw = (repoId: string, commit: string, path: string) => rawCache.get<BlobForRender>(rawKey(repoId, commit, path));
82
83 export function keepRaw(repoId: string, commit: string, path: string, blob: BlobForRender): void {
84 if (blob.kind === "missing" || blob.kind === "too_large") return;
85 if (blob.size <= RAW_MAX) rawCache.set(rawKey(repoId, commit, path), blob, TTL_MS, blob.size);
86 }
test/anonymous-deep.test.ts +67
@@ -221,3 +221,70 @@ test("a made-up Authorization header buys nothing: it's counted like anyone with
221221 for (let i = 0; i <= STRANGER_BUDGET.address; i++) statuses.push((await get(deep, from("10.8.0.1", { authorization: "Bearer made-up" }))).status);
222222 expect(statuses.at(-1)).toBe(429);
223223 });
224
225 test("a stranger's file page and raw bytes at a commit id are served after the first read with git gone (#9405)", async () => {
226 setCrawlClock(app, () => 15_000_000);
227 clearTreeCache();
228 const gitDir = join(dataDir, "repos", "alice", "open.git");
229 const anon = from("10.7.0.1");
230 const page = await get(`/alice/open/blob/${sha}/a.txt`, anon);
231 const raw = await get(`/alice/open/raw/${sha}/a.txt`, anon);
232 expect(page.status).toBe(200);
233 const first = await page.text();
234 expect(await raw.text()).toBe("hi\n");
235
236 await rename(join(gitDir, "objects"), join(gitDir, "objects.away"));
237 try {
238 const again = await get(`/alice/open/blob/${sha}/a.txt`, anon);
239 expect(again.status).toBe(200);
240 expect(again.headers.get("cache-control")).toContain("immutable");
241 expect(await again.text()).toContain("hi");
242 expect(first).toContain("hi");
243 const bytes = await get(`/alice/open/raw/${sha}/a.txt`, anon);
244 expect(bytes.status).toBe(200);
245 expect(await bytes.text()).toBe("hi\n");
246 // a path never read is not in the cache, so it is a miss, not a page
247 expect((await get(`/alice/open/blob/${sha}/never.txt`, anon)).status).toBe(404);
248 } finally {
249 await rename(join(gitDir, "objects.away"), join(gitDir, "objects"));
250 }
251 });
252
253 test("what is kept for a stranger is never handed to a signed-in reader, a private repository, a fragment or a branch name (#9405)", async () => {
254 setCrawlClock(app, () => 16_000_000);
255 clearTreeCache();
256 const gitDir = join(dataDir, "repos", "alice", "open.git");
257 const anon = from("10.7.1.1");
258 const cookie = { cookie: `forge_session=${createSession(app.db, alice.id).token}` };
259 for (const p of [`/alice/open/blob/${sha}/a.txt`, `/alice/open/raw/${sha}/a.txt`]) expect((await get(p, anon)).status).toBe(200);
260 // a branch-name ref is read afresh every time: never kept
261 expect((await get(`/alice/open/blob/main/a.txt`, anon)).status).toBe(200);
262 const fragment = `/alice/open/blob/${sha}/a.txt?fragment=1`;
263 expect((await get(fragment, anon)).status).toBe(200);
264
265 await rename(join(gitDir, "objects"), join(gitDir, "objects.away"));
266 try {
267 // (a) the blob page for a signed-in reader walks git (fails with it gone) instead of the stranger's page
268 const signedIn = await get(`/alice/open/blob/${sha}/a.txt`, { ...anon, ...cookie });
269 expect(signedIn.status).not.toBe(200);
270 // a fragment and a branch name are not kept
271 expect((await get(`/alice/open/blob/${sha}/a.txt?fragment=1`, from("10.7.1.2"))).status).not.toBe(200);
272 expect((await get(`/alice/open/blob/main/a.txt`, from("10.7.1.3"))).status).not.toBe(200);
273 // the stranger's own kept page still answers
274 expect((await get(`/alice/open/blob/${sha}/a.txt`, from("10.7.1.4"))).status).toBe(200);
275 expect((await get(`/alice/open/raw/${sha}/a.txt`, from("10.7.1.4"))).status).toBe(200);
276
277 // (b) once the repository is private, a stranger is refused for both
278 app.db.run(`UPDATE repo SET visibility = 'private' WHERE name = 'open'`);
279 for (const p of [`/alice/open/blob/${sha}/a.txt`, `/alice/open/raw/${sha}/a.txt`]) {
280 const refused = await get(p, from("10.7.1.5"));
281 expect([302, 303, 401, 403, 404]).toContain(refused.status);
282 const body = await refused.text();
283 expect(body).not.toBe("hi\n");
284 expect(body).not.toContain("class=\"blob");
285 }
286 } finally {
287 app.db.run(`UPDATE repo SET visibility = 'public' WHERE name = 'open'`);
288 await rename(join(gitDir, "objects.away"), join(gitDir, "objects"));
289 }
290 });
test/load-bench.test.ts added +34
@@ -0,0 +1,34 @@
1 import { expect, test } from "bun:test";
2 import { mkdtemp, rm, writeFile } from "node:fs/promises";
3 import { tmpdir } from "node:os";
4 import { join } from "node:path";
5
6 // bench/load.ts is the release gate: a run slower than 80% of the kept result exits 1.
7 const oid = "a".repeat(40);
8 const run = async (kept: object) => {
9 const dir = await mkdtemp(join(tmpdir(), "forge-load-"));
10 const server = Bun.serve({
11 port: 0,
12 fetch: () => new Response(`<a href="/o/r.x/blob/${oid}/f.txt">f</a>`, { headers: { "content-type": "text/html" } }),
13 });
14 try {
15 const file = join(dir, "last.json");
16 await writeFile(file, JSON.stringify(kept));
17 const p = Bun.spawn(
18 ["bun", "bench/load.ts", "--url", `http://127.0.0.1:${server.port}`, "--repo", "o/r.x", "--rate", "50", "--seconds", "1", "--compare", file],
19 { stdout: "pipe", stderr: "pipe" },
20 );
21 return { code: await p.exited, err: await new Response(p.stderr).text() };
22 } finally {
23 server.stop(true);
24 await rm(dir, { recursive: true, force: true });
25 }
26 };
27 const last = (servedPerSecond: number) => ({ version: "0", asked: 50, seconds: 1, served: servedPerSecond, refused: 0, servedPerSecond, p50: 1, p95: 2 });
28
29 test("--compare exits 1 when the served rate fell more than 20%, 0 when it held", async () => {
30 const fell = await run(last(100_000));
31 expect(fell.code).toBe(1);
32 expect(fell.err).toContain("LOAD REGRESSION");
33 expect((await run(last(10))).code).toBe(0);
34 });
test/load-verdict.test.ts added +16
@@ -0,0 +1,16 @@
1 import { expect, test } from "bun:test";
2 import { fell, percentile, summarize } from "../src/perf/load_verdict.ts";
3
4 // Hand calculation: ten latencies 1..10 ms, nearest-rank p50 = ceil(5) = 5th = 5 ms, p95 = ceil(9.5) = 10th = 10 ms.
5 test("percentiles are nearest-rank and the served rate is served ÷ seconds", () => {
6 const ms = [10, 1, 2, 3, 4, 5, 6, 7, 8, 9];
7 expect(percentile([...ms].sort((a, b) => a - b), 50)).toBe(5);
8 expect(percentile([...ms].sort((a, b) => a - b), 95)).toBe(10);
9 expect(summarize("1", 5, 2, ms, 3)).toEqual({ version: "1", asked: 5, seconds: 2, served: 10, refused: 3, servedPerSecond: 5, p50: 5, p95: 10 });
10 });
11
12 test("a release fails when its served rate falls more than 20% from the last", () => {
13 const last = summarize("1", 500, 1, Array(500).fill(5), 0);
14 expect(fell(last, summarize("2", 500, 1, Array(400).fill(5), 0))).toBeUndefined();
15 expect(fell(last, summarize("2", 500, 1, Array(399).fill(5), 0))).toContain("80% of 500/s");
16 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs