fix(desktop): throttle hidden browsers between screenshots

Keep Chromium background throttling enabled outside serialized captures. Temporarily enable frame production and wait for fresh paint so hidden-window screenshots remain usable without continuously animating idle guests.
This commit is contained in:
Mohamed Boudra
2026-09-10 09:11:37 +02:00
parent 92504cd525
commit 4cf4b70ee0
11 changed files with 274 additions and 28 deletions
+14 -9
View File
@@ -4,6 +4,7 @@ const http = require("node:http");
const path = require("node:path");
const { isDeepStrictEqual } = require("node:util");
const { app, BrowserWindow, ipcMain, Menu, nativeImage, screen, session } = require("electron");
const { adaptWebContents } = require("../dist/features/browser-automation/ipc.js");
const ROOT = __dirname;
const OUT_DIR = process.env.PASEO_CAPTURE_HARNESS_OUT_DIR || path.join(ROOT, "out");
@@ -48,7 +49,7 @@ const PERMANENT_STATE_FILTER = new Set(
.filter(Boolean),
);
const PERMANENT_VARIANT_FILTER = new Set(
(process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "attach-off")
(process.env.PASEO_CAPTURE_HARNESS_VARIANTS || "default")
.split(",")
.map((variant) => variant.trim())
.filter(Boolean),
@@ -56,9 +57,9 @@ const PERMANENT_VARIANT_FILTER = new Set(
const PERMANENT_CAPTURE_MODES = ["viewport", "full-page"];
const PERMANENT_THROTTLING_VARIANTS = [
{
id: "capture-only-throttling",
code: "capture-only",
label: "backgroundThrottling disabled only during each capture",
id: "default-throttling",
code: "default",
label: "Chromium background throttling enabled",
disableGuestBackgroundThrottlingAtAttach: false,
},
{
@@ -442,8 +443,10 @@ async function readGuestMetrics(contents) {
}
async function capturePageSequence(contents) {
contents.invalidate();
return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage");
return adaptWebContents(contents).withFrameProduction(async () => {
contents.invalidate();
return await withTimeout(contents.capturePage(undefined, { stayHidden: false }), "capturePage");
});
}
async function captureFullPage(contents) {
@@ -485,8 +488,10 @@ async function captureFullPage(contents) {
}
async function captureFullPageSequence(contents) {
contents.invalidate();
return await captureFullPage(contents);
return adaptWebContents(contents).withFrameProduction(async () => {
contents.invalidate();
return await captureFullPage(contents);
});
}
function installHarnessWebviewGuards(win, options = {}) {
@@ -2115,7 +2120,7 @@ async function runAutomationGroup() {
installHarnessWebviewGuards(win, {
preloadPath: PRODUCTION_BROWSER_GUEST_PRELOAD_PATH,
});
const tracker = trackAttachedGuests(win, { disableGuestBackgroundThrottlingAtAttach: true });
const tracker = trackAttachedGuests(win);
try {
await withTimeout(
win.loadFile(path.join(ROOT, "index.html"), {
@@ -0,0 +1,161 @@
// Run with the repository Electron after npm run build:main.
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { setTimeout: delay } = require("node:timers/promises");
const { app, BrowserWindow, nativeImage } = require("electron");
const { preparePaseoBrowserWebContents } = require("../dist/features/browser-webviews/index.js");
const { adaptWebContents } = require("../dist/features/browser-automation/ipc.js");
const { executeAutomationCommand } = require("../dist/features/browser-automation/service.js");
const output =
process.env.PASEO_BROWSER_THROTTLING_ARTIFACT_DIR ||
fs.mkdtempSync(path.join(os.tmpdir(), "paseo-browser-throttling-"));
fs.mkdirSync(output, { recursive: true });
const profile = fs.mkdtempSync(path.join(os.tmpdir(), "paseo-browser-throttling-profile-"));
app.setPath("userData", profile);
if (process.platform === "darwin") app.setActivationPolicy("accessory");
const measurements = [];
// The existing full-page repetition bug (#3196) is independently reproducible.
// Keep its strict pixel assertion available without conflating it with throttling.
const captureModes = process.argv.includes("--full-page") ? [false, true] : [false];
function pageUrl(html) {
return `data:text/html,${encodeURIComponent(html)}`;
}
async function openParkedBrowser() {
const win = new BrowserWindow({
width: 900,
height: 600,
show: false,
skipTaskbar: true,
webPreferences: { webviewTag: true },
});
const attached = new Promise((resolve) =>
win.webContents.once("did-attach-webview", (_event, guest) => {
preparePaseoBrowserWebContents(guest);
resolve(guest);
}),
);
await win.loadURL(
pageUrl(
'<div id="parking" style="position:fixed;left:0;top:0;width:1px;height:1px;overflow:hidden;opacity:1;pointer-events:none"></div>',
),
);
const url = pageUrl(
`<style>html,body{margin:0;width:640px}section{height:480px}#top{background:rgb(255,0,0)}#bottom{background:rgb(0,0,255)}</style><section id="top">TOP</section><section id="bottom">BOTTOM</section><script>window.frameCount=0;function tick(){window.frameCount++;requestAnimationFrame(tick)}requestAnimationFrame(tick)</script>`,
);
await win.webContents.executeJavaScript(
`(() => { const view = document.createElement('webview'); view.style.cssText='display:inline-flex;width:640px;height:480px'; view.src=${JSON.stringify(url)}; document.getElementById('parking').appendChild(view); })()`,
);
const guest = await attached;
if (guest.isLoading()) await new Promise((resolve) => guest.once("did-finish-load", resolve));
win.showInactive();
return { win, guest };
}
async function expectBrowserToAnimate(guest) {
const start = await guest.executeJavaScript("window.frameCount");
const deadline = Date.now() + 5000;
while (Date.now() < deadline) {
if ((await guest.executeJavaScript("window.frameCount")) > start + 2) return;
await delay(50);
}
assert.fail("Browser did not resume animation after restoring the window");
}
async function expectHiddenBrowserToIdle(win, guest, label) {
win.hide();
await delay(500); // Allow the compositor to consume the visibility change before sampling.
const start = await guest.executeJavaScript("window.frameCount");
await delay(1000); // Measure a fixed interval; this is the behavior under test.
const frames = (await guest.executeJavaScript("window.frameCount")) - start;
measurements.push({ label, frames, backgroundThrottling: guest.getBackgroundThrottling() });
fs.writeFileSync(path.join(output, "measurements.json"), JSON.stringify(measurements, null, 2));
assert.equal(frames, 0, `${label}: hidden browser continues animating`);
}
function browserRegistry(guest) {
const contents = adaptWebContents(guest);
return {
getTabContents: () => contents,
getBrowserWorkspaceId: () => "fixture",
listRegisteredBrowserIds: () => ["preview"],
listRegisteredBrowserIdsForWorkspace: () => ["preview"],
getWorkspaceActiveBrowserId: () => "preview",
};
}
function expectPixel(image, x, y, rgb) {
const { width } = image.getSize();
const offset = (y * width + x) * 4;
const bitmap = image.toBitmap();
assert.deepEqual(
[bitmap[offset + 2], bitmap[offset + 1], bitmap[offset]],
rgb,
`Incorrect page content at ${x},${y}`,
);
}
async function expectFreshScreenshots(guest, label) {
// Mutate while backgrounded: successful capture must show fresh pixels, not a cached frame.
await guest.executeJavaScript("document.getElementById('top').style.background='rgb(0,255,0)'");
for (const fullPage of captureModes) {
const response = await executeAutomationCommand(
{
requestId: label,
workspaceId: "fixture",
command: { command: "screenshot", args: { browserId: "preview", fullPage } },
},
browserRegistry(guest),
);
assert.equal(response.ok, true, JSON.stringify(response));
const image = nativeImage.createFromBuffer(Buffer.from(response.result.dataBase64, "base64"));
fs.writeFileSync(
path.join(output, `${label}-${fullPage ? "full-page" : "viewport"}.png`),
image.toPNG(),
);
assert.deepEqual(image.getSize(), { width: 640, height: fullPage ? 960 : 480 });
expectPixel(image, 320, 240, [0, 255, 0]);
if (fullPage) expectPixel(image, 320, 720, [0, 0, 255]);
}
await guest.executeJavaScript("document.getElementById('top').style.background='rgb(255,0,0)'");
}
async function verifyBrowserLifecycle() {
const { win, guest } = await openParkedBrowser();
try {
await expectBrowserToAnimate(guest);
await expectHiddenBrowserToIdle(win, guest, "before-capture");
await expectFreshScreenshots(guest, "hidden");
await expectHiddenBrowserToIdle(win, guest, "after-capture");
win.showInactive();
await expectBrowserToAnimate(guest);
await expectFreshScreenshots(guest, "parked");
const destroyed = new Promise((resolve) => guest.once("destroyed", resolve));
await win.webContents.executeJavaScript("document.querySelector('webview').remove()");
await destroyed;
console.log(
`PASS hidden browser idles; fresh screenshot pixels; restore and close. Artifacts: ${output}`,
);
} finally {
win.destroy();
}
}
const deadline = setTimeout(() => {
console.error("Browser throttling regression timed out");
app.exit(1);
}, 30000);
app
.whenReady()
.then(verifyBrowserLifecycle)
.then(() => app.quit())
.catch((error) => {
console.error(error);
app.exit(1);
})
.finally(() => clearTimeout(deadline));
app.on("will-quit", () => fs.rmSync(profile, { recursive: true, force: true }));
+2 -1
View File
@@ -17,11 +17,12 @@
"scripts": {
"build": "npm --prefix ../.. run build:server:clean && npm run build:main && electron-builder --config electron-builder.yml",
"build:main": "tsc -p tsconfig.json",
"capture-harness": "./capture-harness/run.sh",
"capture-harness": "npm run build:main && ./capture-harness/run.sh",
"dev": "./scripts/dev.sh",
"dev:win": "powershell ./scripts/dev.ps1",
"test:e2e:renderer": "cross-env E2E_DESKTOP_RUNTIME=1 playwright test --config=playwright.config.ts --project=desktop",
"test:e2e:browser-tabs": "npm run build:main && node ./e2e/browser-tabs.e2e.mjs",
"test:e2e:browser-throttling": "npm run build:main && electron --no-sandbox e2e/browser-throttling.electron.cjs",
"verify:electron-cdp": "node ./scripts/verify-electron-cdp.mjs",
"test": "vitest run --exclude \"e2e/**\"",
"typecheck": "tsgo --noEmit -p tsconfig.json"
@@ -108,6 +108,14 @@ type ConsoleMessageListener = (
) => void;
class FakeWebContents {
public backgroundThrottling = true;
public getBackgroundThrottling(): boolean {
return this.backgroundThrottling;
}
public setBackgroundThrottling(allowed: boolean): void {
if (this.destroyed) throw new Error("Object has been destroyed");
this.backgroundThrottling = allowed;
}
public readonly debugger = new FakeDebugger();
public readonly inputEvents: IsolatedKeyboardInputEvent[] = [];
public readonly loadedUrls: string[] = [];
@@ -672,3 +680,38 @@ function deferred<T>() {
});
return { promise, resolve };
}
describe("pixel capture frame production", () => {
test.each([true, false])(
"restores the prior throttling policy (%s) after capture or failure",
async (previous) => {
const contents = new FakeWebContents(8001);
contents.backgroundThrottling = previous;
const tab = adaptWebContents(contents);
expect(
await tab.withFrameProduction(async () => {
expect(contents.backgroundThrottling).toBe(false);
return "pixels";
}),
).toBe("pixels");
expect(contents.backgroundThrottling).toBe(previous);
await expect(
tab.withFrameProduction(async () => {
throw new Error("capture failed");
}),
).rejects.toThrow("capture failed");
expect(contents.backgroundThrottling).toBe(previous);
},
);
test("does not touch a guest destroyed during capture", async () => {
const contents = new FakeWebContents(8002);
const tab = adaptWebContents(contents);
await expect(
tab.withFrameProduction(async () => {
contents.destroyed = true;
throw new Error("capture closed");
}),
).rejects.toThrow("capture closed");
});
});
@@ -107,6 +107,8 @@ interface BrowserAutomationWebContents extends ConsoleMessageEmitter {
reload(): void;
capturePage(rect?: Rectangle, options?: { stayHidden?: boolean }): Promise<TabImage>;
invalidate(): void;
getBackgroundThrottling(): boolean;
setBackgroundThrottling(allowed: boolean): void;
sendInputEvent(event: IsolatedKeyboardInputEvent): void;
}
@@ -130,6 +132,15 @@ export function adaptWebContents(contents: BrowserAutomationWebContents): TabCon
reload: () => contents.reload(),
capturePage: (captureOptions) => contents.capturePage(undefined, captureOptions),
invalidate: () => contents.invalidate(),
withFrameProduction: async (capture) => {
const previous = contents.getBackgroundThrottling();
contents.setBackgroundThrottling(false);
try {
return await capture();
} finally {
if (!contents.isDestroyed()) contents.setBackgroundThrottling(previous);
}
},
sendInputEvent: (event) => contents.sendInputEvent(event),
getConsoleMessages: () => consoleMessagesByContentsId.get(contentsId) ?? [],
captureDialogs: (task) => dialogMonitor.capture(task),
@@ -33,6 +33,9 @@ class FakeImage implements TabImage {
}
class FakeTab implements TabContents {
public withFrameProduction<T>(capture: () => Promise<T>): Promise<T> {
return capture();
}
public readonly loadedUrls: string[] = [];
public readonly scripts: string[] = [];
public readonly actions: string[] = [];
@@ -37,6 +37,7 @@ export interface TabContents {
reload(): void;
capturePage(options?: TabCapturePageOptions): Promise<TabImage>;
invalidate(): void;
withFrameProduction<T>(capture: () => Promise<T>): Promise<T>;
sendInputEvent(event: IsolatedKeyboardInputEvent): void;
getConsoleMessages?(): BrowserAutomationConsoleLogEntry[];
captureDialogs?<T>(
@@ -189,11 +190,26 @@ function isKnownNoFrameCaptureError(error: unknown): boolean {
);
}
async function waitForPaint(contents: TabContents): Promise<void> {
// A hidden page may have unpainted DOM updates. The first animation callback
// precedes paint; the next frame ensures capture cannot reuse the old surface.
await withPixelCaptureTimeout(
contents.executeJavaScript(
"new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))",
),
);
}
async function runPaintedPixelCapture<T>(
contents: TabContents,
capture: () => Promise<T>,
): Promise<T> {
return runSerializedPixelCapture(() => capturePixelFrameWithRetry(contents, capture));
return runSerializedPixelCapture(() =>
contents.withFrameProduction(async () => {
await waitForPaint(contents);
return capturePixelFrameWithRetry(contents, capture);
}),
);
}
async function capturePaintedViewport(contents: TabContents): Promise<TabImage> {
@@ -19,7 +19,6 @@ class FakeRenderer {
}
class FakeBrowserGuest {
public readonly backgroundThrottlingCalls: boolean[] = [];
private destroyedListener: (() => void) | null = null;
private destroyed = false;
@@ -33,10 +32,6 @@ class FakeBrowserGuest {
return this.destroyed;
}
public setBackgroundThrottling(allowed: boolean): void {
this.backgroundThrottlingCalls.push(allowed);
}
public once(event: "destroyed", listener: () => void): void {
expect(event).toBe("destroyed");
this.destroyedListener = listener;
@@ -187,7 +182,7 @@ describe("browser webview attachment", () => {
unregisterPaseoBrowser("browser-shared-hosts");
});
test("prepares throttling once and removes registration when the guest is destroyed", () => {
test("removes registration when the guest is destroyed", () => {
const profileSession = {};
const renderer = new FakeRenderer(31);
const guest = new FakeBrowserGuest(601, renderer, profileSession);
@@ -201,12 +196,10 @@ describe("browser webview attachment", () => {
findWebContents: () => guest,
});
expect(guest.backgroundThrottlingCalls).toEqual([false]);
expect(getPaseoBrowserIdForWebContents(guest)).toBe("browser-cleanup");
guest.destroy();
expect(getPaseoBrowserIdForWebContents(guest)).toBeNull();
expect(guest.backgroundThrottlingCalls).toEqual([false]);
});
});
@@ -24,7 +24,6 @@ interface BrowserWebContentsIdentity {
interface RegisteredBrowserWebContents extends BrowserWebContentsIdentity {
readonly hostWebContents: BrowserWebContentsIdentity | null;
readonly session: object;
setBackgroundThrottling(allowed: boolean): void;
once(event: "destroyed", listener: () => void): void;
}
@@ -56,7 +55,8 @@ export function getPaseoBrowserWebviewRegistry(): PaseoBrowserWebviewRegistry {
export function preparePaseoBrowserWebContents(contents: RegisteredBrowserWebContents): void {
const webContentsId = contents.id;
contents.setBackgroundThrottling(false);
// Preserve Chromium throttling when the host window is hidden. Browser
// residency and screenshot capture do not require a lifetime override.
contents.once("destroyed", () => {
browserRegistry.unregisterWebContents(webContentsId);
});