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

A limit can be tested without draining the process: give the token buckets a reset seam #132

Merged opened this

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

Merged

mason/tempo:mason/86-a-limit-can-be-tested-without-draining-t → main 1 commit fork

  • Merged. mason/86-a-limit-can-be-tested-without-draining-t was merged into main as 5bc59ac.

Review

Sign in to review this change.
8 files changed +115 −62
README.md +8
@@ -242,6 +242,14 @@ process-wide (undo, columns, cards, board creation), the write limit per
242242 caller instead, for the reason above; hitting one always says the number
243243 in the error, so it reads as a limit rather than a broken app.
244244
245 Both buckets live in one process, which is also the one process `bun test`
246 runs every test file in, so `src/limits.ts` exports `resetLimits()`: every
247 bucket back to full in one call. Tests that want the limits out of their
248 way reset before each test, and the one file that wants a limit exhausted
249 (`src/write-limit.test.ts`) drains it and resets after. Nothing in the
250 running app calls it — no test file's result depends on the order bun
251 happened to run the files in.
252
245253 It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app
246254 runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is
247255 the host; `<name>--<pull>@<forge>` for a preview's). It opens `src/tty.ts` on
src/drag.test.ts +6 −6
@@ -9,7 +9,7 @@
99 * and CSS come from the window; fetch stays Bun's own, wrapped so every
1010 * request board.js makes is recorded and checked to stay same-origin.
1111 */
12 import { expect, test } from "bun:test";
12 import { beforeEach, expect, test } from "bun:test";
1313 import { Window } from "happy-dom";
1414 import { mkdtempSync } from "node:fs";
1515 import { tmpdir } from "node:os";
@@ -19,13 +19,13 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
1919 process.env.PORT = "0";
2020 process.env.FORGE_APP_IDENTITY_KEY = "test-key";
2121 const { server } = await import("./server.ts");
22 const { boardCreationBucket } = await import("./limits.ts");
22 const { resetLimits } = await import("./limits.ts");
2323 const base = `http://${server.hostname}:${server.port}`;
2424
25 // boardCreationBucket is one process-wide bucket; reset it so this file's
26 // own board-creation calls aren't at the mercy of what other test files
27 // already spent from it in this same `bun test` run.
28 boardCreationBucket.reset();
25 // The rate limits are process-wide state shared by every test file in one
26 // `bun test` run (see limits.ts). Nothing here is about being refused, so
27 // every test starts with them out of the way.
28 beforeEach(resetLimits);
2929
3030 const post = (path: string, body: Record<string, string> = {}) =>
3131 fetch(base + path, { method: "POST", body: new URLSearchParams(body) });
src/limits.test.ts +40 −2
@@ -1,13 +1,17 @@
1 import { expect, test } from "bun:test";
1 import { beforeEach, expect, test } from "bun:test";
22 import { mkdtempSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { join } from "node:path";
5 import { boardCreationBucket, callerKey, KeyedTokenBucket, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_UNDO_DEPTH, TokenBucket, writeBucket } from "./limits.ts";
5 import { boardCreationBucket, callerKey, KeyedTokenBucket, MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_UNDO_DEPTH, resetLimits, TokenBucket, writeBucket } from "./limits.ts";
66 import { ANON } from "./who.ts";
77
88 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
99 const db = await import("./db.ts");
1010
11 // The module's own buckets are process-wide, shared with every other test
12 // file in this run; the tests below that take from them start from full.
13 beforeEach(resetLimits);
14
1115 test("a board cannot grow past its column limit, and a refused create writes no row", () => {
1216 const board = db.createBoard("Board");
1317 for (let i = db.listColumns(board.id).length; i < MAX_COLUMNS_PER_BOARD; i++) db.createColumn(board.id, `c${i}`);
@@ -81,6 +85,40 @@ test("a token bucket says how long until the next token, in whole seconds", () =
8185 expect(bucket.retryAfterSeconds()).toBe(0);
8286 });
8387
88 test("a reset token bucket is full again, and says nothing is owed", () => {
89 let now = 0;
90 const bucket = new TokenBucket(3, () => now);
91 while (bucket.take()) {
92 /* drain it */
93 }
94 expect(bucket.take()).toBe(false);
95 expect(bucket.retryAfterSeconds()).toBe(20); // a third of a minute, for one of three tokens
96
97 bucket.reset();
98
99 expect(bucket.retryAfterSeconds()).toBe(0);
100 expect(bucket.take()).toBe(true);
101 expect(bucket.take()).toBe(true);
102 expect(bucket.take()).toBe(true);
103 expect(bucket.take()).toBe(false); // full capacity, not unlimited
104 });
105
106 test("resetLimits hands back every bucket this module owns, not just one of them", () => {
107 while (boardCreationBucket.take()) {
108 /* drain both of the module's buckets, the way a flood of each would */
109 }
110 while (writeBucket.take("reset-test")) {
111 /* ... */
112 }
113 expect(boardCreationBucket.take()).toBe(false);
114 expect(writeBucket.take("reset-test")).toBe(false);
115
116 resetLimits();
117
118 expect(boardCreationBucket.take()).toBe(true);
119 expect(writeBucket.take("reset-test")).toBe(true);
120 });
121
84122 test("writeBucket is its own bucket, separate from board creation's: draining one leaves the other full", () => {
85123 while (writeBucket.take("drain-test")) {
86124 /* spend every token writeBucket has */
src/limits.ts +19 −5
@@ -96,11 +96,10 @@ export class TokenBucket {
9696 }
9797
9898 /**
99 * Back to full capacity. Not used by the app itself — only by a test file
100 * that posts to `/boards` enough times to matter, so the process-wide
101 * `boardCreationBucket` (shared across every test file in one `bun test`
102 * run) starts each file with its own full bucket instead of running down
103 * against whatever earlier files already spent.
99 * Back to full capacity, as if the bucket had just been made: every token
100 * available and the clock started again from now. Forgiving a limiter is
101 * an ordinary operation on one — see resetLimits below for the caller
102 * this module actually has.
104103 */
105104 reset(): void {
106105 this.tokens = this.capacityPerMinute;
@@ -175,6 +174,21 @@ export class KeyedTokenBucket {
175174 // out people trying to create new ones, and vice versa.
176175 export const writeBucket = new KeyedTokenBucket(MAX_WRITES_PER_MINUTE, MAX_WRITE_BUCKET_KEYS);
177176
177 /**
178 * Every bucket this module owns back to full: one call that forgives whoever
179 * is currently rate limited, for whatever reason. The app itself never calls
180 * it — the callers are the tests, which share one process (and so one set of
181 * buckets) across every test file in a `bun test` run. A file that wants a
182 * limit out of its way resets before each test; a file that wants one
183 * exhausted (src/write-limit.test.ts) drains it and resets when it is done.
184 * Either way no test depends on what another already spent, or on the order
185 * bun happened to run them in. Add a bucket to this module and add it here.
186 */
187 export function resetLimits(): void {
188 boardCreationBucket.reset();
189 writeBucket.reset();
190 }
191
178192 /** Who a write's rate limit is charged to: a forge identity is unforgeable
179193 * (src/who.ts verifies it), so a signed-in caller is keyed by id; a guest
180194 * has none, so is keyed by the connecting socket address instead — never
src/live-refresh.test.ts +5 −5
@@ -19,13 +19,13 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
1919 process.env.PORT = "0";
2020 process.env.FORGE_APP_IDENTITY_KEY = "test-key";
2121 const { server } = await import("./server.ts");
22 const { boardCreationBucket } = await import("./limits.ts");
22 const { resetLimits } = await import("./limits.ts");
2323 const base = `http://${server.hostname}:${server.port}`;
2424
25 // boardCreationBucket is one process-wide bucket; reset it so this file's
26 // own board-creation calls aren't at the mercy of what other test files
27 // already spent from it in this same `bun test` run.
28 boardCreationBucket.reset();
25 // The rate limits are process-wide state shared by every test file in one
26 // `bun test` run (see limits.ts). Nothing here is about being refused, so
27 // every test starts with them out of the way.
28 beforeEach(resetLimits);
2929
3030 const post = (path: string, body: Record<string, string> = {}) =>
3131 fetch(base + path, { method: "POST", headers: { accept: "application/json" }, body: new URLSearchParams(body) });
src/server.test.ts +19 −31
@@ -1,4 +1,4 @@
1 import { expect, test } from "bun:test";
1 import { beforeEach, expect, test } from "bun:test";
22 import { existsSync, mkdtempSync, rmSync } from "node:fs";
33 import { tmpdir } from "node:os";
44 import { dirname, join } from "node:path";
@@ -9,14 +9,15 @@ process.env.PORT = "0";
99 process.env.FORGE_APP_IDENTITY_KEY = "test-key";
1010 const { server } = await import("./server.ts");
1111 const { sign } = await import("./who.ts");
12 const { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_REQUEST_BODY_BYTES, MAX_SUBSCRIBERS_PER_BOARD, boardCreationBucket } =
12 const { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD, MAX_REQUEST_BODY_BYTES, MAX_SUBSCRIBERS_PER_BOARD, resetLimits } =
1313 await import("./limits.ts");
1414 const db = await import("./db.ts");
1515 const { THEME_JS } = await import("./pages/board.ts");
16 // boardCreationBucket is one process-wide bucket; reset it so this file's
17 // own board-creation tests aren't at the mercy of what other test files
18 // already spent from it in this same `bun test` run.
19 boardCreationBucket.reset();
16 // The rate limits are process-wide state shared by every test file in one
17 // `bun test` run (see limits.ts). Nothing here is about being refused, so
18 // every test starts with them out of the way — whatever this file or any
19 // other already spent, and whichever order bun ran them in.
20 beforeEach(resetLimits);
2021 const KEY = "test-key";
2122 const identityHeaders = (id: string, name: string, role: "user" | "admin" = "user") => {
2223 const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`;
@@ -28,19 +29,13 @@ const j = { accept: "application/json", "content-type": "application/x-www-form-
2829 const post = (path: string, body: Record<string, string> = {}, headers: Record<string, string> = j) =>
2930 fetch(base + path, { method: "POST", headers, body: new URLSearchParams(body) });
3031
31 // A test that only needs a board to act on gets one, whatever earlier
32 // tests already spent: this file creates enough boards to run the
33 // process-wide creation bucket (src/limits.ts, 30 a minute) dry, and every
34 // test here would then fail on a 429 for a reason it isn't about. The
35 // refusal itself is tested where it belongs — src/limits.ts's own tests.
32 // A test that only needs a board to act on gets one. No test here creates
33 // anywhere near a full bucket's worth on its own, and beforeEach hands the
34 // bucket back full, so a 429 never lands here; the refusal itself is tested
35 // where it belongs — src/limits.ts's own tests, and src/write-limit.test.ts.
3636 async function newBoard(name = "Test board"): Promise<string> {
37 const create = () => fetch(base + "/boards", { method: "POST", body: new URLSearchParams({ name }) });
38 let res = await create();
39 if (res.status === 429) {
40 boardCreationBucket.reset();
41 res = await create();
42 }
43 return new URL(res.url).pathname.split("/")[2];
37 const created = await fetch(base + "/boards", { method: "POST", body: new URLSearchParams({ name }) });
38 return new URL(created.url).pathname.split("/")[2];
4439 }
4540
4641 test("serves the landing page", async () => {
@@ -281,10 +276,9 @@ test("escapes card titles in the rendered page", async () => {
281276 });
282277
283278 test("strips control bytes from a card title — leaving ordinary characters untouched — and refuses a title that is only control characters, the same as an empty one", async () => {
284 // db.createBoard, not POST /boards: the board-creation bucket is shared,
285 // process-wide state (see limits.ts and the no-JS column-limit test
286 // above) — spending a token here would just narrow the margin for the
287 // tests that actually exercise board creation.
279 // db.createBoard, not POST /boards: this test is about what a card title
280 // is allowed to contain, so the board it needs is set up directly rather
281 // than through a route that has its own answers to get wrong.
288282 const id = db.createBoard("Control-byte board").id;
289283 const boardUrl = `/b/${id}`;
290284 const [todo] = db.listColumns(id);
@@ -978,11 +972,9 @@ test("a board cannot grow past its column limit, and the board is left unchanged
978972 });
979973
980974 test("the same column-limit refusal, hit without JavaScript, re-renders the board with the sentence already in it", async () => {
981 // Built directly against db.ts, not POST /boards: the board-creation
982 // bucket is shared, process-wide state (see limits.ts), and every test
983 // file in this suite runs against the same one — spending a token here
984 // for a test that has nothing to do with that bucket would just narrow
985 // the margin left for the tests that do exercise board creation.
975 // Built directly against db.ts, not POST /boards: what this test is about
976 // starts at the column the board already has, so the board it needs is set
977 // up directly rather than through a route of its own.
986978 const id = db.createBoard("No-JS board").id;
987979 const boardUrl = `/b/${id}`;
988980 for (let count = 3; count < MAX_COLUMNS_PER_BOARD; count++) {
@@ -1163,10 +1155,6 @@ test("list_boards over MCP only ever shows the caller's own boards", async () =>
11631155 // open /events stream hears about a move it could never have heard about
11641156 // any other way.
11651157 test("a card moved by a second process (standing in for the ssh terminal) reaches an open stream", async () => {
1166 // This file alone creates enough boards that, this late in it, the
1167 // process-wide creation bucket (src/limits.ts) can already be spent by
1168 // whichever test ran before it — irrelevant to what this test checks.
1169 boardCreationBucket.reset();
11701158 const id = await newBoard();
11711159 const page = await (await fetch(base + `/b/${id}`)).text();
11721160 const [todoId, doingId] = [...page.matchAll(/data-column-id="([a-z0-9]{12})"/g)].map((m) => m[1]);
src/theme.test.ts +6 −6
@@ -4,7 +4,7 @@
44 * drag.test.ts) so the toggle and the "?" dialog run against real DOM
55 * events, not a mock of them.
66 */
7 import { expect, test } from "bun:test";
7 import { beforeEach, expect, test } from "bun:test";
88 import { Window } from "happy-dom";
99 import { mkdtempSync } from "node:fs";
1010 import { tmpdir } from "node:os";
@@ -14,13 +14,13 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
1414 process.env.PORT = "0";
1515 process.env.FORGE_APP_IDENTITY_KEY = "test-key";
1616 const { server } = await import("./server.ts");
17 const { boardCreationBucket } = await import("./limits.ts");
17 const { resetLimits } = await import("./limits.ts");
1818 const base = `http://${server.hostname}:${server.port}`;
1919
20 // boardCreationBucket is one process-wide bucket; reset it so this file's
21 // own board-creation calls aren't at the mercy of what other test files
22 // already spent from it in this same `bun test` run.
23 boardCreationBucket.reset();
20 // The rate limits are process-wide state shared by every test file in one
21 // `bun test` run (see limits.ts). Nothing here is about being refused, so
22 // every test starts with them out of the way.
23 beforeEach(resetLimits);
2424
2525 test("the stylesheet follows the system theme and lets an explicit choice override it", async () => {
2626 const page = await (await fetch(base + "/")).text();
src/write-limit.test.ts +12 −7
@@ -4,9 +4,10 @@
44 * the only way to see the refusal a real flood would cause — would refuse
55 * every other file's anonymous writes for the rest of the run, since every
66 * request here is unauthenticated from the same loopback address and so
7 * shares one key with theirs (see callerKey). `afterAll` below undoes the
8 * drain once this file is done, so this is safe regardless of what order
9 * bun runs test files in.
7 * shares one key with theirs (see callerKey). `resetLimits()` is the seam
8 * that makes the drain safe: before each test here, so the drain starts
9 * from a full bucket, and after the file, so nothing inherits the empty
10 * one. No test in the suite depends on the order bun runs files in.
1011 *
1112 * writeBucket refills off the real clock, so a drained bucket only stays
1213 * empty for as long as no real time passes before the write it's meant to
@@ -19,7 +20,7 @@
1920 * same rate-limiting code, same writeBucket, but no socket round trip
2021 * between draining and the write actually being checked.
2122 */
22 import { afterAll, expect, test } from "bun:test";
23 import { afterAll, beforeEach, expect, test } from "bun:test";
2324 import { mkdtempSync } from "node:fs";
2425 import { tmpdir } from "node:os";
2526 import { join } from "node:path";
@@ -29,7 +30,7 @@ process.env.PORT = "0";
2930 process.env.HOST = "127.0.0.1";
3031 const { server } = await import("./server.ts");
3132 const { route } = await import("./routes.ts");
32 const { callerKey, writeBucket } = await import("./limits.ts");
33 const { callerKey, resetLimits, writeBucket } = await import("./limits.ts");
3334 const { ANON } = await import("./who.ts");
3435 const db = await import("./db.ts");
3536 const base = `http://${server.hostname}:${server.port}`;
@@ -55,11 +56,15 @@ const mcp = (body: unknown): Promise<Response> => {
5556
5657 function drainWriteBucket(): void {
5758 while (writeBucket.take(anonKey)) {
58 /* spend every token left, from whatever every earlier test left in it */
59 /* spend every token this test's own full bucket holds */
5960 }
6061 }
6162
62 afterAll(() => writeBucket.reset());
63 // This is the file that wants a limit exhausted, so it takes it: a full
64 // bucket before each test, drained by the test itself, and the limits back
65 // to full once the file is done so no later one inherits an empty bucket.
66 beforeEach(resetLimits);
67 afterAll(resetLimits);
6368
6469 test("a write past the bucket's capacity is refused with retry-after, and does not touch the database", async () => {
6570 const board = db.createBoard("Rate limited board");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs