mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 16:34:36 +00:00
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:
@@ -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 }));
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user