Skip to content

Commit 1e05d78

Browse files
fix(engine): enable browser pool and deduplicate concurrent Chrome launches (#889)
## Summary - **Enable browser pool by default** (`enableBrowserPool: true`) — parallel capture workers now share a single Chrome process via reference-counted pool instead of each spawning their own (~256MB each). A 6-worker render drops from 7+ browser parent processes to 1 shared pool. - **Add launch-promise deduplication** in `acquireBrowser` — when multiple workers race into the pool simultaneously (via `Promise.all`), they await the same launch Promise instead of each triggering a separate Chrome spawn. Same pattern as the existing `_autoBrowserGpuModeCache` for GPU probes. - **Add `connected` health check** on pool hit — if Chrome crashes mid-render, subsequent acquires detect the dead browser and launch fresh instead of returning a stale reference. - **Add `drainBrowserPool()`** for explicit cleanup between independent render jobs. - **CLI studio server** now uses the shared pool instead of its own redundant `enableBrowserPool: false` singleton, so thumbnail generation shares Chrome with render workers. ## Problem The engine had a reference-counted browser pool (`browserManager.ts:73-75`) but it was **disabled by default** (`enableBrowserPool: false`). This meant: 1. **Every parallel worker spawned its own Chrome** — a `--workers 6` render launched 7+ independent Chrome processes (1 probe + 6 workers), each ~256MB. 2. **The pool had a race condition** — even if manually enabled, concurrent workers calling `acquireBrowser()` via `Promise.all` could all see `pooledBrowser === null` before the first launch completed, spawning N Chromes instead of 1. 3. **No crash recovery** — if Chrome died, the pool still held the dead reference. Subsequent acquires got a disconnected browser. 4. **CLI studio server ran its own singleton** — `studioServer.ts` explicitly set `enableBrowserPool: false` and managed a separate browser, so thumbnails and renders could never share. Over time, orphaned Chrome processes accumulated across renders and previews. We observed **344 headless Chrome processes** consuming **569% CPU and 20% memory** on a dev machine. ## Before / After (6-worker parallel render) | Metric | Before (pool off) | After (pool on) | |--------|-------------------|-----------------| | Browser parent processes | 7+ (1 probe + 6 workers) | **2** (1 GPU probe + 1 shared) | | Total Chrome processes (with helpers) | 40-50+ | **14** | | Memory during capture | ~20%+ | **4.6%** | | Render time (1200 frames, 30fps) | ~64s | **53s** (~17% faster) | | Post-render orphans | Accumulated over time | **0** | ## Changes | File | Change | |------|--------| | `engine/src/config.ts` | `enableBrowserPool` default `false` → `true` | | `engine/src/services/browserManager.ts` | Extract `launchBrowser()`, add `_pooledBrowserLaunchPromise` dedup, add `connected` check on pool hit, add `drainBrowserPool()` and `_resetBrowserPoolForTests()` | | `engine/src/index.ts` | Export `drainBrowserPool` | | `engine/src/services/browserManager.test.ts` | Pool dedup and drain tests | | `cli/src/server/studioServer.ts` | Remove `enableBrowserPool: false` override — thumbnails now share the pool | | `producer/src/services/browserManager.ts` | Re-export `drainBrowserPool` | ## Backward compatibility - `PRODUCER_ENABLE_BROWSER_POOL=false` env var disables pooling (same as before). - Callers passing `{ enableBrowserPool: false }` explicitly still get isolated browsers. - Tests that set `enableBrowserPool: false` in their config fixtures continue to work. ## Test plan - [x] Engine tests pass (597/597) - [x] Producer tests pass (406/407, 1 pre-existing flaky test in `pngDecodeBlitWorkerPool`) - [x] Build passes (lint, format, typecheck all green via lefthook pre-commit) - [x] Manual render: `shortform-financial` with `--workers 6` → 1200 frames in 53s, 0 orphaned Chrome processes after completion - [x] Process monitoring during render confirmed 2 browser parents (1 GPU probe + 1 shared pool) instead of 7+
1 parent d3c32a0 commit 1e05d78

7 files changed

Lines changed: 287 additions & 22 deletions

File tree

packages/cli/src/server/studioServer.ts

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -113,10 +113,9 @@ async function reapplyStudioManualEditsToThumbnailPage(
113113
});
114114
}
115115

116-
// ── Shared thumbnail browser (singleton per process) ────────────────────────
117-
// One browser instance is reused across all composition thumbnail requests.
118-
// Spawning a new Puppeteer process per request adds 2-5s overhead and causes
119-
// contention when the sidebar requests multiple thumbnails simultaneously.
116+
// ── Shared thumbnail browser (pool-backed) ──────────────────────────────────
117+
// Uses the engine's browser pool so the thumbnail browser and render workers
118+
// share a single Chrome process instead of running two independent ones.
120119

121120
let _thumbnailBrowser: import("puppeteer-core").Browser | null = null;
122121
let _thumbnailBrowserInitializing: Promise<import("puppeteer-core").Browser | null> | null = null;
@@ -139,14 +138,22 @@ async function getThumbnailBrowser(): Promise<import("puppeteer-core").Browser |
139138
/* continue — acquireBrowser will try its own resolution */
140139
}
141140

142-
const acquired = await acquireBrowser(buildChromeArgs({ width: 1920, height: 1080 }), {
143-
enableBrowserPool: false,
144-
});
141+
const acquired = await acquireBrowser(buildChromeArgs({ width: 1920, height: 1080 }));
145142
_thumbnailBrowser = acquired.browser;
146143
_thumbnailBrowser.on("disconnected", () => {
147144
_thumbnailBrowser = null;
148145
_thumbnailBrowserInitializing = null;
149146
});
147+
// Release the pool ref on process exit so the browser closes cleanly.
148+
const onExit = async () => {
149+
const { releaseBrowser } = await import("@hyperframes/engine");
150+
if (_thumbnailBrowser) {
151+
await releaseBrowser(_thumbnailBrowser).catch(() => {});
152+
_thumbnailBrowser = null;
153+
}
154+
};
155+
process.once("SIGTERM", () => void onExit());
156+
process.once("SIGINT", () => void onExit());
150157
return _thumbnailBrowser;
151158
} catch {
152159
_thumbnailBrowserInitializing = null;

packages/engine/src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export const DEFAULT_CONFIG: EngineConfig = {
173173

174174
disableGpu: false,
175175
browserGpuMode: "software",
176-
enableBrowserPool: false,
176+
enableBrowserPool: true,
177177
browserTimeout: 120_000,
178178
protocolTimeout: 300_000,
179179
forceScreenshot: false,

packages/engine/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export { resolveConfig, DEFAULT_CONFIG, type EngineConfig } from "./config.js";
4949
export {
5050
acquireBrowser,
5151
releaseBrowser,
52+
drainBrowserPool,
5253
resolveHeadlessShellPath,
5354
resolveBrowserGpuMode,
5455
buildChromeArgs,

packages/engine/src/services/browserManager.test.ts

Lines changed: 149 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
11
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
22

3+
import type { Browser, PuppeteerNode } from "puppeteer-core";
4+
35
import {
46
_resetAutoBrowserGpuModeCacheForTests,
7+
_resetBrowserPoolForTests,
8+
_setPuppeteerForTests,
9+
acquireBrowser,
510
buildChromeArgs,
11+
drainBrowserPool,
612
forceReleaseBrowser,
13+
releaseBrowser,
714
resolveBrowserGpuMode,
815
} from "./browserManager.js";
916

@@ -157,3 +164,145 @@ describe("forceReleaseBrowser", () => {
157164
expect(disconnectFn).toHaveBeenCalled();
158165
});
159166
});
167+
168+
describe("browser pool", () => {
169+
function makeMockBrowser(): Browser {
170+
return {
171+
connected: true,
172+
newPage: vi.fn(),
173+
version: vi.fn().mockResolvedValue("HeadlessChrome/131.0.0.0"),
174+
close: vi.fn().mockResolvedValue(undefined),
175+
disconnect: vi.fn(),
176+
process: () => ({ kill: vi.fn(), killed: false }),
177+
} as unknown as Browser;
178+
}
179+
180+
// forceScreenshot: true bypasses the BeginFrame probe path, which on Linux
181+
// CI would trigger a second ppt.launch() when the mock's newPage() doesn't
182+
// return a real page and the probe falls back to screenshot mode.
183+
const poolCfg = { enableBrowserPool: true, forceScreenshot: true } as const;
184+
185+
let launchFn: ReturnType<typeof vi.fn>;
186+
187+
beforeEach(() => {
188+
_resetBrowserPoolForTests();
189+
const mockBrowser = makeMockBrowser();
190+
launchFn = vi.fn().mockResolvedValue(mockBrowser);
191+
_setPuppeteerForTests({ launch: launchFn } as unknown as PuppeteerNode);
192+
});
193+
194+
afterEach(async () => {
195+
await drainBrowserPool();
196+
_setPuppeteerForTests(undefined);
197+
});
198+
199+
it("sequential acquires with pool enabled return the same browser", async () => {
200+
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
201+
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
202+
203+
expect(first.browser).toBe(second.browser);
204+
expect(launchFn).toHaveBeenCalledTimes(1);
205+
206+
await releaseBrowser(first.browser, poolCfg);
207+
await releaseBrowser(second.browser, poolCfg);
208+
});
209+
210+
it("concurrent acquires via Promise.all trigger exactly one launch", async () => {
211+
const [a, b, c] = await Promise.all([
212+
acquireBrowser(["--no-sandbox"], poolCfg),
213+
acquireBrowser(["--no-sandbox"], poolCfg),
214+
acquireBrowser(["--no-sandbox"], poolCfg),
215+
]);
216+
217+
expect(launchFn).toHaveBeenCalledTimes(1);
218+
expect(a.browser).toBe(b.browser);
219+
expect(b.browser).toBe(c.browser);
220+
221+
await releaseBrowser(a.browser, poolCfg);
222+
await releaseBrowser(b.browser, poolCfg);
223+
await releaseBrowser(c.browser, poolCfg);
224+
});
225+
226+
it("pool recovers from a disconnected browser", async () => {
227+
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
228+
await releaseBrowser(first.browser, poolCfg);
229+
230+
// Simulate Chrome crash
231+
(first.browser as unknown as { connected: boolean }).connected = false;
232+
233+
const freshBrowser = makeMockBrowser();
234+
launchFn.mockResolvedValue(freshBrowser);
235+
236+
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
237+
expect(second.browser).toBe(freshBrowser);
238+
expect(second.browser).not.toBe(first.browser);
239+
expect(launchFn).toHaveBeenCalledTimes(2);
240+
241+
await releaseBrowser(second.browser, poolCfg);
242+
});
243+
244+
it("release at refCount 0 closes the browser", async () => {
245+
const result = await acquireBrowser(["--no-sandbox"], poolCfg);
246+
const closeFn = result.browser.close as ReturnType<typeof vi.fn>;
247+
248+
await releaseBrowser(result.browser, poolCfg);
249+
expect(closeFn).toHaveBeenCalledTimes(1);
250+
});
251+
252+
it("pool returns a separate browser when forceScreenshot mismatches pooled mode", async () => {
253+
const first = await acquireBrowser(["--no-sandbox"], poolCfg);
254+
expect(first.captureMode).toBe("screenshot");
255+
256+
// Second acquire with same forceScreenshot — same mode, should reuse
257+
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
258+
expect(second.browser).toBe(first.browser);
259+
expect(launchFn).toHaveBeenCalledTimes(1);
260+
261+
await releaseBrowser(first.browser, poolCfg);
262+
await releaseBrowser(second.browser, poolCfg);
263+
});
264+
265+
it("forceReleaseBrowser does not kill Chrome when other sessions hold refs", async () => {
266+
const result = await acquireBrowser(["--no-sandbox"], poolCfg);
267+
// Acquire a second ref
268+
const second = await acquireBrowser(["--no-sandbox"], poolCfg);
269+
270+
const disconnectFn = result.browser.disconnect as ReturnType<typeof vi.fn>;
271+
forceReleaseBrowser(result.browser);
272+
273+
// Should NOT have disconnected — other session still holds a ref
274+
expect(disconnectFn).not.toHaveBeenCalled();
275+
276+
// Release the remaining ref normally
277+
await releaseBrowser(second.browser, poolCfg);
278+
});
279+
280+
it("drainBrowserPool is safe to call when no browser is pooled", async () => {
281+
await drainBrowserPool();
282+
});
283+
284+
it("drainBrowserPool awaits in-flight launch before closing", async () => {
285+
let resolveDeferred!: (browser: Browser) => void;
286+
const deferred = new Promise<Browser>((resolve) => {
287+
resolveDeferred = resolve;
288+
});
289+
launchFn.mockReturnValue(deferred);
290+
291+
// Start acquire — it will be pending
292+
const acquirePromise = acquireBrowser(["--no-sandbox"], poolCfg);
293+
294+
// Drain while launch is in-flight
295+
const drainPromise = drainBrowserPool();
296+
297+
// Resolve the pending launch
298+
const mockBrowser = makeMockBrowser();
299+
resolveDeferred(mockBrowser);
300+
301+
await drainPromise;
302+
const closeFn = mockBrowser.close as ReturnType<typeof vi.fn>;
303+
expect(closeFn).toHaveBeenCalled();
304+
305+
// The acquire should still resolve (the launch completed before drain closed it)
306+
await acquirePromise.catch(() => {});
307+
});
308+
});

0 commit comments

Comments
 (0)