From a9e6afe15b91314a904efbb23a2a59126d16eb02 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 1 Nov 2026 12:35:22 +0000 Subject: [PATCH 1/4] fix: close the newest capture when memory nears the container limit One heavy page (a 7000x7000 image drawn by MapLibre in software WebGL) pushed Chromium past the 2GiB limit in production. The kernel then killed the whole container, failing every capture in flight on it, and the worker's retry did the same to the other replica. --- README.md | 1 + src/routes/reports.ts | 15 ++++ src/routes/screenshots.ts | 15 ++++ src/utils/memory.ts | 148 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 179 insertions(+) create mode 100644 src/utils/memory.ts diff --git a/README.md b/README.md index 5d340fb..1c86096 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,7 @@ curl -X POST http://localhost:3000/v1/screenshots \ | --- | --- | --- | | `PORT` | `3000` | Port the API listens on | | `ALLOWED_HOSTS` | _(empty)_ | Comma-separated hostnames the browser may load even though they resolve to a private address, e.g. `appwrite` | +| `MEMORY_BUDGET` | `0.85` | Fraction of the container memory limit captures may use. Past it the newest screenshot or report is closed and fails with `507`, instead of the kernel killing Chromium and every capture in it. No effect without a cgroup v2 memory limit | Screenshots and reports refuse any URL, frame or subresource whose host is not publicly routable, which includes private, loopback and link-local ranges such as the `169.254.169.254` metadata address. A self-hosted setup that captures its own internal services lists them in `ALLOWED_HOSTS`. Matching is exact and case-insensitive. Every other private host stays blocked, including on redirect hops and script navigations; see [Network Access](#network-access). diff --git a/src/routes/reports.ts b/src/routes/reports.ts index 043205a..d8cc5e5 100755 --- a/src/routes/reports.ts +++ b/src/routes/reports.ts @@ -8,11 +8,17 @@ import { startEgressGuard, watchNavigations, } from "../utils/egress.js"; +import { + type Capture, + MEMORY_BUDGET_EXCEEDED, + memoryGuard, +} from "../utils/memory.js"; import { assertAllowedUrl } from "../utils/ssrf.js"; export async function handleReportsRequest(req: Request): Promise { let context: BrowserContext | undefined; let guard: EgressGuard | undefined; + let capture: Capture | undefined; try { const json = await req.json(); @@ -38,6 +44,7 @@ export async function handleReportsRequest(req: Request): Promise { const browser = await getBrowser(); context = await browser.newContext(contextOptions); + capture = memoryGuard.track(context); // Grant permissions if specified if (body.permissions && body.permissions.length > 0) { @@ -107,12 +114,20 @@ export async function handleReportsRequest(req: Request): Promise { headers: { "Content-Type": "application/json" }, }); } catch (error) { + if (capture?.exceeded) { + return new Response(JSON.stringify({ error: MEMORY_BUDGET_EXCEEDED }), { + status: 507, + headers: { "Content-Type": "application/json" }, + }); + } + const message = error instanceof Error ? error.message : "Unknown error"; return new Response(JSON.stringify({ error: message }), { status: error instanceof BlockedNavigationError ? 403 : 400, headers: { "Content-Type": "application/json" }, }); } finally { + capture?.release(); await context?.close(); guard?.close(); } diff --git a/src/routes/screenshots.ts b/src/routes/screenshots.ts index ce6766b..6b3432e 100755 --- a/src/routes/screenshots.ts +++ b/src/routes/screenshots.ts @@ -12,6 +12,11 @@ import { startEgressGuard, watchNavigations, } from "../utils/egress.js"; +import { + type Capture, + MEMORY_BUDGET_EXCEEDED, + memoryGuard, +} from "../utils/memory.js"; import { assertAllowedUrl } from "../utils/ssrf.js"; export async function handleScreenshotsRequest( @@ -19,6 +24,7 @@ export async function handleScreenshotsRequest( ): Promise { let context: BrowserContext | undefined; let guard: EgressGuard | undefined; + let capture: Capture | undefined; try { const json = await req.json(); @@ -49,6 +55,7 @@ export async function handleScreenshotsRequest( const browser = await getBrowser(); context = await browser.newContext(contextOptions); + capture = memoryGuard.track(context); // Grant permissions if specified if (body.permissions && body.permissions.length > 0) { @@ -103,12 +110,20 @@ export async function handleScreenshotsRequest( }, }); } catch (error) { + if (capture?.exceeded) { + return new Response(JSON.stringify({ error: MEMORY_BUDGET_EXCEEDED }), { + status: 507, + headers: { "Content-Type": "application/json" }, + }); + } + const message = error instanceof Error ? error.message : "Unknown error"; return new Response(JSON.stringify({ error: message }), { status: error instanceof BlockedNavigationError ? 403 : 400, headers: { "Content-Type": "application/json" }, }); } finally { + capture?.release(); await context?.close(); guard?.close(); } diff --git a/src/utils/memory.ts b/src/utils/memory.ts new file mode 100644 index 0000000..8efea00 --- /dev/null +++ b/src/utils/memory.ts @@ -0,0 +1,148 @@ +import { readFileSync } from "node:fs"; + +/** + * Chromium shares one GPU process between pages, so no page's memory can be + * measured on its own. The guard watches the container's working set instead, + * the figure the kernel OOM-kills on, and closes the newest capture whenever + * it crosses the budget. One capture then fails instead of Chromium and every + * capture running in it. + */ +export interface Capture { + /** Whether the guard closed this capture to stay within the budget. */ + readonly exceeded: boolean; + release(): void; +} + +interface Closable { + close(): Promise; +} + +interface Tracked { + readonly target: Closable; + exceeded: boolean; +} + +export interface MemoryUsage { + readonly used: number; + readonly limit: number; +} + +export const MEMORY_BUDGET_EXCEEDED = + "Page exceeded the memory budget and was closed."; + +const CGROUP_PATH = "/sys/fs/cgroup"; +const DEFAULT_BUDGET = 0.85; +const POLL_INTERVAL_MS = 100; +const SETTLE_MS = 1_000; +const MEBIBYTE = 1024 * 1024; + +export function readCgroupMemory(): MemoryUsage | null { + try { + const limit = readFileSync(`${CGROUP_PATH}/memory.max`, "utf8").trim(); + if (limit === "max") { + return null; + } + + const current = Number( + readFileSync(`${CGROUP_PATH}/memory.current`, "utf8"), + ); + const stat = readFileSync(`${CGROUP_PATH}/memory.stat`, "utf8"); + const inactiveFile = Number(/^inactive_file (\d+)$/m.exec(stat)?.[1] ?? 0); + + return { used: current - inactiveFile, limit: Number(limit) }; + } catch { + return null; + } +} + +export function parseMemoryBudget(value: string | undefined): number { + if (value === undefined || value === "") { + return DEFAULT_BUDGET; + } + + const budget = Number(value); + if (!(budget > 0 && budget <= 1)) { + throw new Error( + `MEMORY_BUDGET must be a fraction of the memory limit between 0 and 1, got "${value}".`, + ); + } + + return budget; +} + +export class MemoryGuard { + private readonly tracked: Tracked[] = []; + private readonly enabled: boolean; + private timer: ReturnType | null = null; + private settledAt = 0; + + constructor( + private readonly read: () => MemoryUsage | null, + private readonly budget: number, + ) { + this.enabled = read() !== null; + } + + track(target: Closable): Capture { + const entry: Tracked = { target, exceeded: false }; + this.tracked.push(entry); + + if (this.enabled && this.timer === null) { + this.timer = setInterval(() => this.check(), POLL_INTERVAL_MS); + } + + return { + get exceeded() { + return entry.exceeded; + }, + release: () => { + const index = this.tracked.indexOf(entry); + if (index !== -1) { + this.tracked.splice(index, 1); + } + + if (this.tracked.length === 0 && this.timer !== null) { + clearInterval(this.timer); + this.timer = null; + } + }, + }; + } + + private check(): void { + if (Date.now() < this.settledAt) { + return; + } + + const usage = this.read(); + if (usage === null || usage.used < usage.limit * this.budget) { + return; + } + + let newest: Tracked | undefined; + for (let index = this.tracked.length - 1; index >= 0; index--) { + if (!this.tracked[index].exceeded) { + newest = this.tracked[index]; + break; + } + } + + if (newest === undefined) { + return; + } + + newest.exceeded = true; + this.settledAt = Date.now() + SETTLE_MS; + + console.log( + `Memory budget exceeded: ${Math.round(usage.used / MEBIBYTE)} of ${Math.round(usage.limit / MEBIBYTE)} MiB, closing the newest capture`, + ); + + newest.target.close().catch(() => {}); + } +} + +export const memoryGuard = new MemoryGuard( + readCgroupMemory, + parseMemoryBudget(process.env.MEMORY_BUDGET), +); From 91c20032784112ceecd2345033070db717d037d8 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 1 Nov 2026 12:50:57 +0000 Subject: [PATCH 2/4] test: cover the memory budget guard and its 507 response --- src/utils/memory.ts | 36 +++++--- tests/recovery/memory.test.ts | 59 +++++++++++++ tests/unit/memory.test.ts | 151 ++++++++++++++++++++++++++++++++++ 3 files changed, 235 insertions(+), 11 deletions(-) create mode 100644 tests/recovery/memory.test.ts create mode 100644 tests/unit/memory.test.ts diff --git a/src/utils/memory.ts b/src/utils/memory.ts index 8efea00..6778372 100644 --- a/src/utils/memory.ts +++ b/src/utils/memory.ts @@ -22,6 +22,15 @@ interface Tracked { exceeded: boolean; } +export interface MemoryGuardOptions { + readonly read: () => MemoryUsage | null; + /** Fraction of the memory limit captures may use. */ + readonly budget: number; + readonly interval?: number; + /** How long to let a closed capture free its memory before closing another. */ + readonly settle?: number; +} + export interface MemoryUsage { readonly used: number; readonly limit: number; @@ -72,15 +81,20 @@ export function parseMemoryBudget(value: string | undefined): number { export class MemoryGuard { private readonly tracked: Tracked[] = []; + private readonly read: () => MemoryUsage | null; + private readonly budget: number; + private readonly interval: number; + private readonly settle: number; private readonly enabled: boolean; private timer: ReturnType | null = null; private settledAt = 0; - constructor( - private readonly read: () => MemoryUsage | null, - private readonly budget: number, - ) { - this.enabled = read() !== null; + constructor(options: MemoryGuardOptions) { + this.read = options.read; + this.budget = options.budget; + this.interval = options.interval ?? POLL_INTERVAL_MS; + this.settle = options.settle ?? SETTLE_MS; + this.enabled = this.read() !== null; } track(target: Closable): Capture { @@ -88,7 +102,7 @@ export class MemoryGuard { this.tracked.push(entry); if (this.enabled && this.timer === null) { - this.timer = setInterval(() => this.check(), POLL_INTERVAL_MS); + this.timer = setInterval(() => this.check(), this.interval); } return { @@ -132,7 +146,7 @@ export class MemoryGuard { } newest.exceeded = true; - this.settledAt = Date.now() + SETTLE_MS; + this.settledAt = Date.now() + this.settle; console.log( `Memory budget exceeded: ${Math.round(usage.used / MEBIBYTE)} of ${Math.round(usage.limit / MEBIBYTE)} MiB, closing the newest capture`, @@ -142,7 +156,7 @@ export class MemoryGuard { } } -export const memoryGuard = new MemoryGuard( - readCgroupMemory, - parseMemoryBudget(process.env.MEMORY_BUDGET), -); +export const memoryGuard = new MemoryGuard({ + read: readCgroupMemory, + budget: parseMemoryBudget(process.env.MEMORY_BUDGET), +}); diff --git a/tests/recovery/memory.test.ts b/tests/recovery/memory.test.ts new file mode 100644 index 0000000..df5951d --- /dev/null +++ b/tests/recovery/memory.test.ts @@ -0,0 +1,59 @@ +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; + +const IMAGE = "appwrite/browser:local"; +const NAME = "appwrite-browser-memory-budget"; +const PORT = 3001; +const BASE_URL = `http://localhost:${PORT}`; + +const imageBuilt = Bun.spawnSync(["docker", "image", "inspect", IMAGE]).success; + +describe.skipIf(!imageBuilt)("E2E Tests - memory budget", () => { + beforeAll(async () => { + Bun.spawnSync(["docker", "rm", "-f", NAME]); + // Idle Chromium alone is over 5% of 1 GiB, so every capture trips the budget. + Bun.spawnSync([ + "docker", + "run", + "-d", + "--name", + NAME, + "--memory", + "1g", + "-e", + "MEMORY_BUDGET=0.05", + "-p", + `${PORT}:3000`, + IMAGE, + ]); + + const deadline = Date.now() + 30_000; + while (Date.now() < deadline) { + const health = await fetch(`${BASE_URL}/v1/health`).catch(() => null); + if (health?.ok) return; + await Bun.sleep(500); + } + throw new Error("Memory budget container did not become healthy."); + }, 60_000); + + afterAll(() => { + Bun.spawnSync(["docker", "rm", "-f", NAME]); + }); + + test("should fail a capture over the budget with 507 and keep Chromium up", async () => { + const capture = await fetch(`${BASE_URL}/v1/screenshots`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "https://example.com", sleep: 2000 }), + }); + expect(capture.status).toBe(507); + expect(capture.headers.get("Content-Type")).toBe("application/json"); + const data = await capture.json(); + expect(data.error).toBe("Page exceeded the memory budget and was closed."); + + const health = await fetch(`${BASE_URL}/v1/health`); + expect(health.status).toBe(200); + + const logs = Bun.spawnSync(["docker", "logs", NAME]).stdout.toString(); + expect(logs).toContain("Memory budget exceeded"); + }, 60_000); +}); diff --git a/tests/unit/memory.test.ts b/tests/unit/memory.test.ts new file mode 100644 index 0000000..f98726c --- /dev/null +++ b/tests/unit/memory.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, test } from "bun:test"; +import { + MemoryGuard, + type MemoryUsage, + parseMemoryBudget, +} from "../../src/utils/memory.js"; + +const MEBIBYTE = 1024 * 1024; +const LIMIT = 2048 * MEBIBYTE; +const INTERVAL = 5; +const SETTLE = 100; + +class FakeContext { + closed = 0; + + async close(): Promise { + this.closed++; + } +} + +function guardWith(usage: { used: number }, reads?: { count: number }) { + return new MemoryGuard({ + read: (): MemoryUsage => { + if (reads) reads.count++; + return { used: usage.used, limit: LIMIT }; + }, + budget: 0.85, + interval: INTERVAL, + settle: SETTLE, + }); +} + +describe("parseMemoryBudget", () => { + test("defaults to 85% when unset or empty", () => { + expect(parseMemoryBudget(undefined)).toBe(0.85); + expect(parseMemoryBudget("")).toBe(0.85); + }); + + test("accepts a fraction up to 1", () => { + expect(parseMemoryBudget("0.5")).toBe(0.5); + expect(parseMemoryBudget("1")).toBe(1); + }); + + test.each(["0", "-0.2", "1.5", "85", "abc"])("rejects %p", (value) => { + expect(() => parseMemoryBudget(value)).toThrow("MEMORY_BUDGET"); + }); +}); + +describe("MemoryGuard", () => { + test("leaves captures alone under the budget", async () => { + const usage = { used: 1500 * MEBIBYTE }; + const guard = guardWith(usage); + const context = new FakeContext(); + const capture = guard.track(context); + + await Bun.sleep(INTERVAL * 6); + + expect(context.closed).toBe(0); + expect(capture.exceeded).toBe(false); + capture.release(); + }); + + test("closes only the newest capture once over the budget", async () => { + const usage = { used: 1500 * MEBIBYTE }; + const guard = guardWith(usage); + const older = new FakeContext(); + const newer = new FakeContext(); + const olderCapture = guard.track(older); + const newerCapture = guard.track(newer); + + usage.used = 1900 * MEBIBYTE; + await Bun.sleep(INTERVAL * 6); + + expect(newer.closed).toBe(1); + expect(newerCapture.exceeded).toBe(true); + expect(older.closed).toBe(0); + expect(olderCapture.exceeded).toBe(false); + olderCapture.release(); + newerCapture.release(); + }); + + test("waits for the settle time before closing the next capture", async () => { + const usage = { used: 1900 * MEBIBYTE }; + const guard = guardWith(usage); + const older = new FakeContext(); + const newer = new FakeContext(); + const olderCapture = guard.track(older); + const newerCapture = guard.track(newer); + + await Bun.sleep(SETTLE / 2); + expect(newer.closed).toBe(1); + expect(older.closed).toBe(0); + + await Bun.sleep(SETTLE); + expect(older.closed).toBe(1); + expect(olderCapture.exceeded).toBe(true); + olderCapture.release(); + newerCapture.release(); + }); + + test("skips captures that were already released", async () => { + const usage = { used: 1500 * MEBIBYTE }; + const guard = guardWith(usage); + const older = new FakeContext(); + const newer = new FakeContext(); + const olderCapture = guard.track(older); + const newerCapture = guard.track(newer); + newerCapture.release(); + + usage.used = 1900 * MEBIBYTE; + await Bun.sleep(INTERVAL * 6); + + expect(newer.closed).toBe(0); + expect(older.closed).toBe(1); + olderCapture.release(); + }); + + test("stops polling once every capture is released", async () => { + const reads = { count: 0 }; + const guard = guardWith({ used: 1500 * MEBIBYTE }, reads); + const capture = guard.track(new FakeContext()); + + await Bun.sleep(INTERVAL * 4); + capture.release(); + const afterRelease = reads.count; + await Bun.sleep(INTERVAL * 6); + + expect(afterRelease).toBeGreaterThan(1); + expect(reads.count).toBe(afterRelease); + }); + + test("does nothing without a memory limit", async () => { + const reads = { count: 0 }; + const guard = new MemoryGuard({ + read: () => { + reads.count++; + return null; + }, + budget: 0.85, + interval: INTERVAL, + }); + const context = new FakeContext(); + const capture = guard.track(context); + + await Bun.sleep(INTERVAL * 6); + + expect(reads.count).toBe(1); + expect(context.closed).toBe(0); + capture.release(); + }); +}); From 8366e3ac8d5c8e54d84cfb9541442a6c2e328079 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 1 Nov 2026 12:37:16 +0000 Subject: [PATCH 3/4] test: move SSRF fixtures to httpbin.dev httpbin.org now refuses redirects to internal addresses and serves /base64 as text/plain with nosniff, so the browser never attempted the private hop and the four redirect and script tests captured a 200 instead of a 403. --- tests/e2e/ssrf.test.ts | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/tests/e2e/ssrf.test.ts b/tests/e2e/ssrf.test.ts index 90d3706..d8838e7 100644 --- a/tests/e2e/ssrf.test.ts +++ b/tests/e2e/ssrf.test.ts @@ -4,8 +4,9 @@ const BASE_URL = process.env.BASE_URL || "http://localhost:3000"; // A public site the first-hop URL check accepts, whose redirect or script // then sends the browser somewhere private. -const OPEN_REDIRECT = "https://httpbin.org/redirect-to?url="; -const RAW_HTML = "https://httpbin.org/base64/"; +const OPEN_REDIRECT = "https://httpbin.dev/redirect-to?url="; +const RAW_HTML = + "https://httpbin.dev/response-headers?Content-Type=text/html&page="; // The browser service itself, reachable on loopback inside its container. const LOOPBACK = "http://127.0.0.1:3000/v1/health"; @@ -24,15 +25,15 @@ beforeAll(async () => { httpbinUp = false; } if (!httpbinUp) { - console.warn("httpbin.org is unreachable; skipping its SSRF checks."); + console.warn("httpbin.dev is unreachable; skipping its SSRF checks."); } }); function scriptPage(target: string): string { - const html = ``; - // httpbin decodes URL-safe base64 but insists on the padding. - const encoded = Buffer.from(html).toString("base64url"); - return `${RAW_HTML}${encoded.padEnd(Math.ceil(encoded.length / 4) * 4, "=")}`; + // httpbin echoes this into a JSON body served as HTML, and JSON escapes + // double quotes, so the script quotes its target with backticks. + const html = ``; + return `${RAW_HTML}${encodeURIComponent(html)}`; } async function screenshot(url: string): Promise { From 0cacec054ea608b3d9c08e699c24a6df50168b79 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Sun, 1 Nov 2026 12:58:12 +0000 Subject: [PATCH 4/4] test: drop the memory guard unit tests --- src/utils/memory.ts | 36 +++------ tests/unit/memory.test.ts | 151 -------------------------------------- 2 files changed, 11 insertions(+), 176 deletions(-) delete mode 100644 tests/unit/memory.test.ts diff --git a/src/utils/memory.ts b/src/utils/memory.ts index 6778372..8efea00 100644 --- a/src/utils/memory.ts +++ b/src/utils/memory.ts @@ -22,15 +22,6 @@ interface Tracked { exceeded: boolean; } -export interface MemoryGuardOptions { - readonly read: () => MemoryUsage | null; - /** Fraction of the memory limit captures may use. */ - readonly budget: number; - readonly interval?: number; - /** How long to let a closed capture free its memory before closing another. */ - readonly settle?: number; -} - export interface MemoryUsage { readonly used: number; readonly limit: number; @@ -81,20 +72,15 @@ export function parseMemoryBudget(value: string | undefined): number { export class MemoryGuard { private readonly tracked: Tracked[] = []; - private readonly read: () => MemoryUsage | null; - private readonly budget: number; - private readonly interval: number; - private readonly settle: number; private readonly enabled: boolean; private timer: ReturnType | null = null; private settledAt = 0; - constructor(options: MemoryGuardOptions) { - this.read = options.read; - this.budget = options.budget; - this.interval = options.interval ?? POLL_INTERVAL_MS; - this.settle = options.settle ?? SETTLE_MS; - this.enabled = this.read() !== null; + constructor( + private readonly read: () => MemoryUsage | null, + private readonly budget: number, + ) { + this.enabled = read() !== null; } track(target: Closable): Capture { @@ -102,7 +88,7 @@ export class MemoryGuard { this.tracked.push(entry); if (this.enabled && this.timer === null) { - this.timer = setInterval(() => this.check(), this.interval); + this.timer = setInterval(() => this.check(), POLL_INTERVAL_MS); } return { @@ -146,7 +132,7 @@ export class MemoryGuard { } newest.exceeded = true; - this.settledAt = Date.now() + this.settle; + this.settledAt = Date.now() + SETTLE_MS; console.log( `Memory budget exceeded: ${Math.round(usage.used / MEBIBYTE)} of ${Math.round(usage.limit / MEBIBYTE)} MiB, closing the newest capture`, @@ -156,7 +142,7 @@ export class MemoryGuard { } } -export const memoryGuard = new MemoryGuard({ - read: readCgroupMemory, - budget: parseMemoryBudget(process.env.MEMORY_BUDGET), -}); +export const memoryGuard = new MemoryGuard( + readCgroupMemory, + parseMemoryBudget(process.env.MEMORY_BUDGET), +); diff --git a/tests/unit/memory.test.ts b/tests/unit/memory.test.ts deleted file mode 100644 index f98726c..0000000 --- a/tests/unit/memory.test.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { describe, expect, test } from "bun:test"; -import { - MemoryGuard, - type MemoryUsage, - parseMemoryBudget, -} from "../../src/utils/memory.js"; - -const MEBIBYTE = 1024 * 1024; -const LIMIT = 2048 * MEBIBYTE; -const INTERVAL = 5; -const SETTLE = 100; - -class FakeContext { - closed = 0; - - async close(): Promise { - this.closed++; - } -} - -function guardWith(usage: { used: number }, reads?: { count: number }) { - return new MemoryGuard({ - read: (): MemoryUsage => { - if (reads) reads.count++; - return { used: usage.used, limit: LIMIT }; - }, - budget: 0.85, - interval: INTERVAL, - settle: SETTLE, - }); -} - -describe("parseMemoryBudget", () => { - test("defaults to 85% when unset or empty", () => { - expect(parseMemoryBudget(undefined)).toBe(0.85); - expect(parseMemoryBudget("")).toBe(0.85); - }); - - test("accepts a fraction up to 1", () => { - expect(parseMemoryBudget("0.5")).toBe(0.5); - expect(parseMemoryBudget("1")).toBe(1); - }); - - test.each(["0", "-0.2", "1.5", "85", "abc"])("rejects %p", (value) => { - expect(() => parseMemoryBudget(value)).toThrow("MEMORY_BUDGET"); - }); -}); - -describe("MemoryGuard", () => { - test("leaves captures alone under the budget", async () => { - const usage = { used: 1500 * MEBIBYTE }; - const guard = guardWith(usage); - const context = new FakeContext(); - const capture = guard.track(context); - - await Bun.sleep(INTERVAL * 6); - - expect(context.closed).toBe(0); - expect(capture.exceeded).toBe(false); - capture.release(); - }); - - test("closes only the newest capture once over the budget", async () => { - const usage = { used: 1500 * MEBIBYTE }; - const guard = guardWith(usage); - const older = new FakeContext(); - const newer = new FakeContext(); - const olderCapture = guard.track(older); - const newerCapture = guard.track(newer); - - usage.used = 1900 * MEBIBYTE; - await Bun.sleep(INTERVAL * 6); - - expect(newer.closed).toBe(1); - expect(newerCapture.exceeded).toBe(true); - expect(older.closed).toBe(0); - expect(olderCapture.exceeded).toBe(false); - olderCapture.release(); - newerCapture.release(); - }); - - test("waits for the settle time before closing the next capture", async () => { - const usage = { used: 1900 * MEBIBYTE }; - const guard = guardWith(usage); - const older = new FakeContext(); - const newer = new FakeContext(); - const olderCapture = guard.track(older); - const newerCapture = guard.track(newer); - - await Bun.sleep(SETTLE / 2); - expect(newer.closed).toBe(1); - expect(older.closed).toBe(0); - - await Bun.sleep(SETTLE); - expect(older.closed).toBe(1); - expect(olderCapture.exceeded).toBe(true); - olderCapture.release(); - newerCapture.release(); - }); - - test("skips captures that were already released", async () => { - const usage = { used: 1500 * MEBIBYTE }; - const guard = guardWith(usage); - const older = new FakeContext(); - const newer = new FakeContext(); - const olderCapture = guard.track(older); - const newerCapture = guard.track(newer); - newerCapture.release(); - - usage.used = 1900 * MEBIBYTE; - await Bun.sleep(INTERVAL * 6); - - expect(newer.closed).toBe(0); - expect(older.closed).toBe(1); - olderCapture.release(); - }); - - test("stops polling once every capture is released", async () => { - const reads = { count: 0 }; - const guard = guardWith({ used: 1500 * MEBIBYTE }, reads); - const capture = guard.track(new FakeContext()); - - await Bun.sleep(INTERVAL * 4); - capture.release(); - const afterRelease = reads.count; - await Bun.sleep(INTERVAL * 6); - - expect(afterRelease).toBeGreaterThan(1); - expect(reads.count).toBe(afterRelease); - }); - - test("does nothing without a memory limit", async () => { - const reads = { count: 0 }; - const guard = new MemoryGuard({ - read: () => { - reads.count++; - return null; - }, - budget: 0.85, - interval: INTERVAL, - }); - const context = new FakeContext(); - const capture = guard.track(context); - - await Bun.sleep(INTERVAL * 6); - - expect(reads.count).toBe(1); - expect(context.closed).toBe(0); - capture.release(); - }); -});