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
Check · Review · Land · Live done — Landed and live in production.
mason/tempo:mason/86-a-limit-can-be-tested-without-draining-t → main 1 commit fork
8 files
changed +115
−62
README.md +8
| @@ -242,6 +242,14 @@ process-wide (undo, columns, cards, board creation), the write limit per | ||
| 242 | 242 | caller instead, for the reason above; hitting one always says the number |
| 243 | 243 | in the error, so it reads as a limit rather than a broken app. |
| 244 | 244 | |
| 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 | ||
| 245 | 253 | It also has a terminal: the exact command is in `FORGE_APP_SSH` when the app |
| 246 | 254 | runs (`ssh -p 2222 <name>@<forge>` — the app is the login name, the forge is |
| 247 | 255 | 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 @@ | ||
| 9 | 9 | * and CSS come from the window; fetch stays Bun's own, wrapped so every |
| 10 | 10 | * request board.js makes is recorded and checked to stay same-origin. |
| 11 | 11 | */ |
| 12 | import { expect, test } from "bun:test"; | |
| 12 | import { beforeEach, expect, test } from "bun:test"; | |
| 13 | 13 | import { Window } from "happy-dom"; |
| 14 | 14 | import { mkdtempSync } from "node:fs"; |
| 15 | 15 | import { tmpdir } from "node:os"; |
| @@ -19,13 +19,13 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | ||
| 19 | 19 | process.env.PORT = "0"; |
| 20 | 20 | process.env.FORGE_APP_IDENTITY_KEY = "test-key"; |
| 21 | 21 | const { server } = await import("./server.ts"); |
| 22 | const { boardCreationBucket } = await import("./limits.ts"); | |
| 22 | const { resetLimits } = await import("./limits.ts"); | |
| 23 | 23 | const base = `http://${server.hostname}:${server.port}`; |
| 24 | 24 | |
| 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); | |
| 29 | 29 | |
| 30 | 30 | const post = (path: string, body: Record<string, string> = {}) => |
| 31 | 31 | 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"; | |
| 2 | 2 | import { mkdtempSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | 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"; | |
| 6 | 6 | import { ANON } from "./who.ts"; |
| 7 | 7 | |
| 8 | 8 | process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); |
| 9 | 9 | const db = await import("./db.ts"); |
| 10 | 10 | |
| 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 | ||
| 11 | 15 | test("a board cannot grow past its column limit, and a refused create writes no row", () => { |
| 12 | 16 | const board = db.createBoard("Board"); |
| 13 | 17 | 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", () = | ||
| 81 | 85 | expect(bucket.retryAfterSeconds()).toBe(0); |
| 82 | 86 | }); |
| 83 | 87 | |
| 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 | ||
| 84 | 122 | test("writeBucket is its own bucket, separate from board creation's: draining one leaves the other full", () => { |
| 85 | 123 | while (writeBucket.take("drain-test")) { |
| 86 | 124 | /* spend every token writeBucket has */ |
src/limits.ts +19 −5
| @@ -96,11 +96,10 @@ export class TokenBucket { | ||
| 96 | 96 | } |
| 97 | 97 | |
| 98 | 98 | /** |
| 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. | |
| 104 | 103 | */ |
| 105 | 104 | reset(): void { |
| 106 | 105 | this.tokens = this.capacityPerMinute; |
| @@ -175,6 +174,21 @@ export class KeyedTokenBucket { | ||
| 175 | 174 | // out people trying to create new ones, and vice versa. |
| 176 | 175 | export const writeBucket = new KeyedTokenBucket(MAX_WRITES_PER_MINUTE, MAX_WRITE_BUCKET_KEYS); |
| 177 | 176 | |
| 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 | ||
| 178 | 192 | /** Who a write's rate limit is charged to: a forge identity is unforgeable |
| 179 | 193 | * (src/who.ts verifies it), so a signed-in caller is keyed by id; a guest |
| 180 | 194 | * 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-")); | ||
| 19 | 19 | process.env.PORT = "0"; |
| 20 | 20 | process.env.FORGE_APP_IDENTITY_KEY = "test-key"; |
| 21 | 21 | const { server } = await import("./server.ts"); |
| 22 | const { boardCreationBucket } = await import("./limits.ts"); | |
| 22 | const { resetLimits } = await import("./limits.ts"); | |
| 23 | 23 | const base = `http://${server.hostname}:${server.port}`; |
| 24 | 24 | |
| 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); | |
| 29 | 29 | |
| 30 | 30 | const post = (path: string, body: Record<string, string> = {}) => |
| 31 | 31 | 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"; | |
| 2 | 2 | import { existsSync, mkdtempSync, rmSync } from "node:fs"; |
| 3 | 3 | import { tmpdir } from "node:os"; |
| 4 | 4 | import { dirname, join } from "node:path"; |
| @@ -9,14 +9,15 @@ process.env.PORT = "0"; | ||
| 9 | 9 | process.env.FORGE_APP_IDENTITY_KEY = "test-key"; |
| 10 | 10 | const { server } = await import("./server.ts"); |
| 11 | 11 | 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 } = | |
| 13 | 13 | await import("./limits.ts"); |
| 14 | 14 | const db = await import("./db.ts"); |
| 15 | 15 | 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); | |
| 20 | 21 | const KEY = "test-key"; |
| 21 | 22 | const identityHeaders = (id: string, name: string, role: "user" | "admin" = "user") => { |
| 22 | 23 | const identity = `${id}|${name}|${role}|${Date.now() + 60_000}`; |
| @@ -28,19 +29,13 @@ const j = { accept: "application/json", "content-type": "application/x-www-form- | ||
| 28 | 29 | const post = (path: string, body: Record<string, string> = {}, headers: Record<string, string> = j) => |
| 29 | 30 | fetch(base + path, { method: "POST", headers, body: new URLSearchParams(body) }); |
| 30 | 31 | |
| 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. | |
| 36 | 36 | 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]; | |
| 44 | 39 | } |
| 45 | 40 | |
| 46 | 41 | test("serves the landing page", async () => { |
| @@ -281,10 +276,9 @@ test("escapes card titles in the rendered page", async () => { | ||
| 281 | 276 | }); |
| 282 | 277 | |
| 283 | 278 | 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. | |
| 288 | 282 | const id = db.createBoard("Control-byte board").id; |
| 289 | 283 | const boardUrl = `/b/${id}`; |
| 290 | 284 | const [todo] = db.listColumns(id); |
| @@ -978,11 +972,9 @@ test("a board cannot grow past its column limit, and the board is left unchanged | ||
| 978 | 972 | }); |
| 979 | 973 | |
| 980 | 974 | 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. | |
| 986 | 978 | const id = db.createBoard("No-JS board").id; |
| 987 | 979 | const boardUrl = `/b/${id}`; |
| 988 | 980 | 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 () => | ||
| 1163 | 1155 | // open /events stream hears about a move it could never have heard about |
| 1164 | 1156 | // any other way. |
| 1165 | 1157 | 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(); | |
| 1170 | 1158 | const id = await newBoard(); |
| 1171 | 1159 | const page = await (await fetch(base + `/b/${id}`)).text(); |
| 1172 | 1160 | 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 @@ | ||
| 4 | 4 | * drag.test.ts) so the toggle and the "?" dialog run against real DOM |
| 5 | 5 | * events, not a mock of them. |
| 6 | 6 | */ |
| 7 | import { expect, test } from "bun:test"; | |
| 7 | import { beforeEach, expect, test } from "bun:test"; | |
| 8 | 8 | import { Window } from "happy-dom"; |
| 9 | 9 | import { mkdtempSync } from "node:fs"; |
| 10 | 10 | import { tmpdir } from "node:os"; |
| @@ -14,13 +14,13 @@ process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-")); | ||
| 14 | 14 | process.env.PORT = "0"; |
| 15 | 15 | process.env.FORGE_APP_IDENTITY_KEY = "test-key"; |
| 16 | 16 | const { server } = await import("./server.ts"); |
| 17 | const { boardCreationBucket } = await import("./limits.ts"); | |
| 17 | const { resetLimits } = await import("./limits.ts"); | |
| 18 | 18 | const base = `http://${server.hostname}:${server.port}`; |
| 19 | 19 | |
| 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); | |
| 24 | 24 | |
| 25 | 25 | test("the stylesheet follows the system theme and lets an explicit choice override it", async () => { |
| 26 | 26 | const page = await (await fetch(base + "/")).text(); |
src/write-limit.test.ts +12 −7
| @@ -4,9 +4,10 @@ | ||
| 4 | 4 | * the only way to see the refusal a real flood would cause — would refuse |
| 5 | 5 | * every other file's anonymous writes for the rest of the run, since every |
| 6 | 6 | * 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. | |
| 10 | 11 | * |
| 11 | 12 | * writeBucket refills off the real clock, so a drained bucket only stays |
| 12 | 13 | * empty for as long as no real time passes before the write it's meant to |
| @@ -19,7 +20,7 @@ | ||
| 19 | 20 | * same rate-limiting code, same writeBucket, but no socket round trip |
| 20 | 21 | * between draining and the write actually being checked. |
| 21 | 22 | */ |
| 22 | import { afterAll, expect, test } from "bun:test"; | |
| 23 | import { afterAll, beforeEach, expect, test } from "bun:test"; | |
| 23 | 24 | import { mkdtempSync } from "node:fs"; |
| 24 | 25 | import { tmpdir } from "node:os"; |
| 25 | 26 | import { join } from "node:path"; |
| @@ -29,7 +30,7 @@ process.env.PORT = "0"; | ||
| 29 | 30 | process.env.HOST = "127.0.0.1"; |
| 30 | 31 | const { server } = await import("./server.ts"); |
| 31 | 32 | const { route } = await import("./routes.ts"); |
| 32 | const { callerKey, writeBucket } = await import("./limits.ts"); | |
| 33 | const { callerKey, resetLimits, writeBucket } = await import("./limits.ts"); | |
| 33 | 34 | const { ANON } = await import("./who.ts"); |
| 34 | 35 | const db = await import("./db.ts"); |
| 35 | 36 | const base = `http://${server.hostname}:${server.port}`; |
| @@ -55,11 +56,15 @@ const mcp = (body: unknown): Promise<Response> => { | ||
| 55 | 56 | |
| 56 | 57 | function drainWriteBucket(): void { |
| 57 | 58 | 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 */ | |
| 59 | 60 | } |
| 60 | 61 | } |
| 61 | 62 | |
| 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); | |
| 63 | 68 | |
| 64 | 69 | test("a write past the bucket's capacity is refused with retry-after, and does not touch the database", async () => { |
| 65 | 70 | const board = db.createBoard("Rate limited board"); |
new above
offline