Repository navigation
fix: close the newest capture when memory nears the container limit #26
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a9e6afe
91c2003
8366e3a
0cacec0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<void>; | ||
| } | ||
|
|
||
| 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<typeof setInterval> | 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); | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [minor] Retain multi-capture guard coverage The latest commit removes the configurable polling and settle intervals and drops Prompt To Fix With AIThis is a comment left during a code review.
Path: src/utils/memory.ts
Line: 91
Comment:
**[minor] Retain multi-capture guard coverage**
The latest commit removes the configurable polling and settle intervals and drops `tests/unit/memory.test.ts`; the remaining recovery test exercises only one capture, so newest-first selection and waiting before closing another capture are no longer asserted. Could we keep equivalent automated coverage, using production timings or fake timers?
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.🟡 Minor · testing · Reply if this doesn't apply. |
||
| } | ||
|
|
||
| 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), | ||
| ); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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); | ||
| }); |
Uh oh!
There was an error while loading. Please reload this page.