fix(engine): escalate hung browser lease closes

This commit is contained in:
James
2026-07-16 12:58:10 -04:00
parent 9b23c00237
commit d4cfa08cb6
2 changed files with 69 additions and 3 deletions
@@ -32,6 +32,8 @@ interface BrowserPoolEntry {
result?: BrowserLaunchResult;
launchPromise: Promise<BrowserLaunchResult>;
closePromise?: Promise<void>;
forceCloseRequested: boolean;
forceClose?: () => void;
}
export interface BrowserLeasePoolOptions {
@@ -165,6 +167,7 @@ export class BrowserLeasePool {
state: "launching",
refCount: 1,
closeRequested: false,
forceCloseRequested: false,
launchPromise: undefined as unknown as Promise<BrowserLaunchResult>,
};
entry.launchPromise = this.options.launch(fingerprint).then(
@@ -207,7 +210,9 @@ export class BrowserLeasePool {
await entry.closePromise;
},
forceRelease: () => {
deactivate(true);
if (!deactivate(true) && entry.refCount === 0 && entry.closePromise) {
this.requestClose(entry, true);
}
},
};
const leases = this.leasesByBrowser.get(result.browser) ?? new Set<BrowserLease>();
@@ -217,20 +222,34 @@ export class BrowserLeasePool {
}
private requestClose(entry: BrowserPoolEntry, force: boolean): void {
if (force) {
entry.forceCloseRequested = true;
entry.forceClose?.();
}
if (entry.closePromise) return;
entry.closeRequested = true;
if (this.available.get(entry.key) === entry) this.available.delete(entry.key);
entry.closePromise = entry.launchPromise
.then(async (result) => {
entry.state = "closing";
if (force) {
if (entry.forceCloseRequested) {
this.options.forceClose(result.browser);
} else {
await this.options.close(result.browser).catch(() => {});
await Promise.race([
this.options.close(result.browser).catch(() => {}),
new Promise<void>((resolve) => {
entry.forceClose = () => {
entry.forceClose = undefined;
this.options.forceClose(result.browser);
resolve();
};
}),
]);
}
})
.catch(() => {})
.finally(() => {
entry.forceClose = undefined;
this.entries.delete(entry);
if (entry.result) this.leasesByBrowser.delete(entry.result.browser);
});
@@ -103,4 +103,51 @@ describe("createCaptureSession construction ownership", () => {
rmSync(outputDir, { recursive: true, force: true });
}
});
it("force-releases its browser lease when rollback browser close never settles", async () => {
vi.useFakeTimers();
const outputDir = mkdtempSync(join(tmpdir(), "hf-session-browser-timeout-"));
const page = {
evaluateOnNewDocument: vi.fn().mockRejectedValue(new Error("bootstrap failed")),
close: vi.fn().mockResolvedValue(undefined),
} as unknown as Page;
const disconnect = vi.fn();
const browser = {
connected: true,
newPage: vi.fn().mockResolvedValue(page),
version: vi.fn().mockResolvedValue("HeadlessChrome/150.0.0.0"),
close: vi.fn().mockReturnValue(new Promise<void>(() => {})),
disconnect,
process: () => null,
} as unknown as Browser;
_setPuppeteerForTests({
launch: vi.fn().mockResolvedValue(browser),
} as unknown as PuppeteerNode);
try {
const creating = expect(
createCaptureSession(
"http://127.0.0.1:3000",
outputDir,
{ width: 320, height: 180, fps: { num: 30, den: 1 }, format: "jpeg" },
null,
{
browserGpuMode: "software",
enableBrowserPool: true,
forceScreenshot: true,
},
),
).rejects.toThrow("bootstrap failed");
await vi.runAllTimersAsync();
await creating;
expect(page.close).toHaveBeenCalledTimes(1);
expect(browser.close).toHaveBeenCalledTimes(1);
expect(disconnect).toHaveBeenCalledTimes(1);
} finally {
vi.useRealTimers();
rmSync(outputDir, { recursive: true, force: true });
}
});
});