From 0a56d0ceabcd05dde33597c61c24f8e3a5fa937f Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Sun, 27 Sep 2026 22:29:49 -0400 Subject: [PATCH 1/3] Throttle idle browser guests with scoped capture and action ownership --- .memory/perf-browser-throttling.md | 11 ++ docs/performance/browser-throttling.md | 55 ++++++++++ .../browser/background-throttling.test.ts | 64 +++++++++++ .../services/browser/background-throttling.ts | 26 +++++ main/services/browser/service.ts | 30 ++++- tests/e2e/browser-throttling-native.ts | 103 ++++++++++++++++++ tests/e2e/browser-throttling.spec.ts | 69 ++++++++++++ 7 files changed, 357 insertions(+), 1 deletion(-) create mode 100644 .memory/perf-browser-throttling.md create mode 100644 docs/performance/browser-throttling.md create mode 100644 main/services/browser/background-throttling.test.ts create mode 100644 main/services/browser/background-throttling.ts create mode 100644 tests/e2e/browser-throttling-native.ts create mode 100644 tests/e2e/browser-throttling.spec.ts diff --git a/.memory/perf-browser-throttling.md b/.memory/perf-browser-throttling.md new file mode 100644 index 000000000..8665e5510 --- /dev/null +++ b/.memory/perf-browser-throttling.md @@ -0,0 +1,11 @@ +# DP-01: Browser scheduling ownership — 2026-09-27 + +Baseline a9baa4aa3027893e5455043083465c34b4c8b4ac. See `docs/performance/browser-throttling.md` for native evidence and limits. + +Guest WebContents now default to normal background throttling. `BrowserBackgroundThrottling` owns idempotent, reference-counted temporary exceptions: queued action execution, bounded captures (including PiP/annotation), and recorder lifetime. Abort releases synchronously; finally releases normal/error completion; recorder closed releases recording; current renderer crash and tab close reset ownership. Crash also finalizes the now unusable recording. Stale releases cannot affect replacement owners. Linux invisible native-host attachment is unchanged. Hidden PiP/encoder windows keep existing configuration. + +Exact baseline native test fails `false !== true` for idle throttling. Three native baseline guests report [false,false,false], candidate [true,true,true]. Native candidate validates hidden timer automation, visible/hidden image output, cancellation/failure, real WebM stop/start-cancel, real crash closing encoder, and native guest close. Unit suite154, native test, both type checks, scoped lint, production build/main rebuild, CI discovery/policy19, diff check pass. New files are discovered by existing browser glob and E2E shard planner. + +No measured CPU/energy claim: this is native policy/ownership evidence. Five-minute power/resource traces and Linux native execution remain unperformed. A detached hidden requestAnimationFrame promise stalls on exact baseline and candidate macOS; do not misreport as a new regression or a fixed behavior. Cold never-presented blank capture also lacks a surface; native fixture presents local DOM once before hiding. No UI/protocol/onboarding change. + +Fresh review and current-head hosted CI remain required before publication/merge. Root owns plan index, papercut records, PR, and CI; this lane does not push. diff --git a/docs/performance/browser-throttling.md b/docs/performance/browser-throttling.md new file mode 100644 index 000000000..308d813c9 --- /dev/null +++ b/docs/performance/browser-throttling.md @@ -0,0 +1,55 @@ +# Browser guest background throttling (DP-01) + +Implemented against `a9baa4aa3027893e5455043083465c34b4c8b4ac` on 2026-09-27. Scope: Electron embedded browser scheduling, with no renderer UI, IPC, mobile protocol, configuration, onboarding, catalog, or dependency change. + +## Ownership + +Ordinary guests now start with Electron background throttling enabled. Visibility remains managed by the existing present/hide lifecycle; enabling throttling does not suspend visible tabs. Linux retains its invisible native child host for capture. + +Exceptions are reference counted per tab: + +- A queued browser action acquires only when execution starts. Success/failure releases in `finally`; an aborted signal releases immediately, including while a native promise has yet to settle. This covers both agent and user initiated automation. +- A capture owns a lease across its bounded attempts, including PiP frames and annotation captures that can run outside the action queue. Nested capture/action ownership cannot release each other. +- A recording owns a separate lease for its recorder window's lifetime, beyond completion of the start command. Startup failure, cancellation, explicit stop, timed stop, and tab close destroy the recorder and release its lease. The hidden encoder window retains its existing unthrottled policy. +- Current renderer crash resets all leases, interrupts the action queue, and finalizes an existing recording because its screencast source has died. Old releases are idempotent and cannot affect later owners. Tab close resets ownership before native destruction; unexpected guest destruction follows the existing close path. + +PiP retains its existing 250 ms capture cadence; only each capture temporarily disables guest throttling. This change neither discards tabs nor introduces timers, polling, or idle eviction. Electron's own timer/audio/visibility heuristics still apply. + +## Reproduced evidence + +Native fixture: `tests/e2e/browser-throttling-native.ts`, driven by `browser-throttling.spec.ts`. The real BrowserService and native WebContents run in a standalone Electron child. Workspace persistence admission is replaced with a fixture response. All user-data/config/recordings live in the test output directory. Page contents are local `about:blank` with a painted DOM; no external network or real user profile is used. The fixture presents the page once before hiding it so a compositor surface exists. + +Environment: Apple M1 Max, arm64, macOS 27.0 (26A428), Darwin 27.0.0, AC power, Electron 43.1.1, Aiden package 0.50.0, `--disable-gpu`. Build uses esbuild's standalone native fixture, not a packaged Aiden process. Optional Aiden services are not started. + +| Observation | Baseline service at a9baa4a | Changed service | +| --- | --- | --- | +| One ordinary hidden guest `getBackgroundThrottling()` | `false` | `true` | +| Three ordinary hidden guests | `[false,false,false]` | `[true,true,true]` | +| Regression assertion: ordinary hidden guest must use normal scheduling | Fails: `false !== true` | Passes | +| Hidden timer-based automation, screenshot, failure/cancel restoration | Not measured in baseline comparison | Pass | +| Recording creates valid WebM; stop/start-cancel restore policy | Not measured in baseline comparison | Pass | +| Real renderer crash retires recording and closes encoder; stale release safe | Not measured in baseline comparison | Pass | + +Baseline evidence was generated by an esbuild `onLoad` override substituting `git show a9baa4a:main/services/browser/service.ts`; no baseline product files were edited. The policy-only baseline probe uses the same fixture creation path and prints the native property for three tabs. Exact output: + +```json +{"electron":"43.1.1","platform":"darwin","idlePolicies":[false,false,false]} +``` + +The changed native fixture also validates a nonempty visible/hidden PNG and WebM signature through production capture/recording paths. The first complete three-tab run produced a 12,228-byte PNG and a 795-byte video. Byte size is evidence of output, not a performance metric or an exact test expectation. + +**Limits:** This establishes policy and lifecycle behavior, not an observed reduction in CPU, RSS, callback frequency, GPU activity, or energy. Five-minute active/background/minimized AC/battery measurements and native Linux execution remain follow-up validation. Chromium may apply other scheduling heuristics; debugger attachment and audio can affect actual behavior. No battery percentage or resource saving is claimed. + +A detached hidden `requestAnimationFrame` promise timed out on both the unchanged baseline and candidate on this macOS fixture. The native regression therefore covers timer-based automation, without claiming this change fixes the existing detached-frame limitation. A cold never-presented blank tab also lacked a capture surface; presenting the local fixture first avoids that precondition, without changing product hosting behavior. + +## Verification + +- `npm run test:browser`: 154 passed, including four new ownership tests through the existing `main/services/browser/*.test.ts` glob. +- `npx playwright test tests/e2e/browser-throttling.spec.ts --config=playwright.config.ts --fail-on-flaky-tests`: native scheduling/capture/recording/cancellation/crash test passes. +- `npm run type-check`, `npm run type-check:e2e`: pass. +- ESLint on the five changed TypeScript files: pass. +- `npm run build`: pass; subsequent `npm run build:electron` validates the final crash-recording cleanup. +- `node --test scripts/ci-e2e-shards.test.mjs scripts/check-ci-policy.test.mjs`: 19 passed. Dynamic shard discovery places `browser-throttling.spec.ts` in shard 1; no package-script or explicit shard inventory change is needed. +- `git diff --check`: pass. + +No shared server/transcript/mobile behavior changed, so native mobile suites do not apply. Independent review and hosted CI are separate publication gates. diff --git a/main/services/browser/background-throttling.test.ts b/main/services/browser/background-throttling.test.ts new file mode 100644 index 000000000..f138bf186 --- /dev/null +++ b/main/services/browser/background-throttling.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { BrowserBackgroundThrottling } from "./background-throttling.js"; + +function fixture() { + let destroyed = false; + let enabled = true; + const writes: boolean[] = []; + const policy = new BrowserBackgroundThrottling({ + isDestroyed: () => destroyed, + setBackgroundThrottling(value) { + assert.equal(destroyed, false, "never touch a destroyed native guest"); + enabled = value; + writes.push(value); + }, + }); + return { policy, writes, enabled: () => enabled, destroy: () => { destroyed = true; } }; +} + +test("overlapping capture and automation preserve scheduling until the last owner finishes", () => { + const f = fixture(); + const capture = f.policy.acquire(); + const automation = f.policy.acquire(); + capture(); + assert.equal(f.enabled(), false); + capture(); + assert.equal(f.enabled(), false, "duplicate release cannot consume another owner"); + automation(); + assert.equal(f.enabled(), true); + assert.deepEqual(f.writes, [false, true]); +}); + +test("cancellation restores idle scheduling even before a native operation settles", () => { + const f = fixture(); + const abort = new AbortController(); + const release = f.policy.acquire(abort.signal); + abort.abort(); + assert.equal(f.enabled(), true); + release(); + f.policy.acquire(abort.signal)(); + assert.deepEqual(f.writes, [false, true]); +}); + +test("crash reset retires old ownership without releasing a replacement operation", () => { + const f = fixture(); + const stale = f.policy.acquire(); + f.policy.reset(); + assert.equal(f.enabled(), true); + const replacement = f.policy.acquire(); + stale(); + assert.equal(f.enabled(), false); + replacement(); + assert.equal(f.enabled(), true); +}); + +test("destroyed guests can release all owners without native calls", () => { + const f = fixture(); + const release = f.policy.acquire(); + f.destroy(); + f.policy.reset(); + release(); + f.policy.acquire()(); + assert.deepEqual(f.writes, [false]); +}); diff --git a/main/services/browser/background-throttling.ts b/main/services/browser/background-throttling.ts new file mode 100644 index 000000000..44f809351 --- /dev/null +++ b/main/services/browser/background-throttling.ts @@ -0,0 +1,26 @@ +/** Scoped exceptions to Electron's normal background scheduling policy. */ +export class BrowserBackgroundThrottling { + private releases = new Set<() => void>(); + constructor(private readonly target: { + isDestroyed(): boolean; + setBackgroundThrottling(enabled: boolean): void; + }) {} + + acquire(signal?: AbortSignal): () => void { + if (signal?.aborted || this.target.isDestroyed()) return () => {}; + if (!this.releases.size) this.target.setBackgroundThrottling(false); + const release = () => { + signal?.removeEventListener("abort", release); + if (!this.releases.delete(release)) return; + if (!this.releases.size && !this.target.isDestroyed()) + this.target.setBackgroundThrottling(true); + }; + this.releases.add(release); + signal?.addEventListener("abort", release, { once: true }); + return release; + } + + reset(): void { + for (const release of this.releases) release(); + } +} diff --git a/main/services/browser/service.ts b/main/services/browser/service.ts index 2de676441..fe7e2f166 100644 --- a/main/services/browser/service.ts +++ b/main/services/browser/service.ts @@ -8,6 +8,7 @@ import { nativeImage, type Session, } from "electron"; +import { BrowserBackgroundThrottling } from "./background-throttling.js"; import { randomUUID } from "node:crypto"; import * as fs from "node:fs/promises"; import * as path from "node:path"; @@ -86,6 +87,7 @@ interface LiveTab { state: BrowserTab; view: WebContentsView; queue: BrowserActionQueue; + backgroundThrottling: BrowserBackgroundThrottling; diagnostics: BrowserDiagnostic[]; network: Array>; timeline: Array>; @@ -596,6 +598,10 @@ export class BrowserService { this.cancelCrashRecovery(tab); tab.navigationCommand = undefined; tab.queue.interrupt(); + tab.backgroundThrottling.reset(); + // A crashed renderer cannot keep producing screencast frames. Finalize + // the recorder instead of leaving it alive through an unrelated recovery. + if (tab.recording) void this.stopRecording(tab).catch(() => {}); tab.contextId = undefined; tab.state.crashed = true; tab.state.loading = false; @@ -809,7 +815,7 @@ export class BrowserService { nodeIntegration: false, nodeIntegrationInSubFrames: false, webSecurity: true, - backgroundThrottling: false, + backgroundThrottling: true, }, }); view.webContents.setUserAgent(browserSession.getUserAgent()); @@ -836,6 +842,7 @@ export class BrowserService { state, view, queue: new BrowserActionQueue(), + backgroundThrottling: new BrowserBackgroundThrottling(view.webContents), diagnostics: [], network: [], timeline: [], @@ -941,6 +948,7 @@ export class BrowserService { this.finishPreviewNavigation(tab); browserFileService.releaseConsumer(tab.state.id); tab.queue.interrupt(); + tab.backgroundThrottling.reset(); this.detach(tab); if (tab.pipTimer) clearInterval(tab.pipTimer); this.cancelCrashRecovery(tab); @@ -1156,6 +1164,19 @@ export class BrowserService { fullPage = false, bounds?: BrowserBounds, signal?: AbortSignal, + ): Promise { + const release = tab.backgroundThrottling.acquire(signal); + try { + return await this.capturePage(tab, fullPage, bounds, signal); + } finally { + release(); + } + } + private async capturePage( + tab: LiveTab, + fullPage: boolean, + bounds?: BrowserBounds, + signal?: AbortSignal, ): Promise { if (fullPage) { const metrics = await this.send(tab, "Page.getLayoutMetrics", {}, signal); @@ -1500,6 +1521,9 @@ export class BrowserService { backgroundThrottling: false, }, }); + // Recording outlives its start command; the recorder owns this lease. + const release = tab.backgroundThrottling.acquire(); + recorder.once("closed", release); tab.pendingRecorder = recorder; try { await browserDeadline( @@ -2050,6 +2074,9 @@ export class BrowserService { (candidate): candidate is AbortSignal => Boolean(candidate), ); const signal = signals.length ? AbortSignal.any(signals) : undefined; + // Playwright actionability waits use animation frames, including user + // initiated automation. Acquire only after the action leaves the queue. + const release = tab.backgroundThrottling.acquire(signal); tab.state.agentControlling = context.source === "agent"; this.emit(workspaceId); try { @@ -2331,6 +2358,7 @@ export class BrowserService { actionEvent.error = error instanceof Error ? error.message : "Browser action failed."; throw error; } finally { + release(); await this.removeAgentCursor(tab); actionEvent.completedAt = Date.now(); tab.state.agentControlling = false; diff --git a/tests/e2e/browser-throttling-native.ts b/tests/e2e/browser-throttling-native.ts new file mode 100644 index 000000000..a5f661820 --- /dev/null +++ b/tests/e2e/browser-throttling-native.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import { mkdir, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { app, BrowserWindow, type WebContentsView } from "electron"; +import type { BrowserTab, BrowserImage } from "../../renderer/shared/browser"; +import type { BrowserBackgroundThrottling } from "../../main/services/browser/background-throttling"; + +const root = process.env.AIDEN_BROWSER_THROTTLING_ROOT!; +await mkdir(path.join(root, "user-data"), { recursive: true }); +app.setPath("userData", path.join(root, "user-data")); +const deadline = setTimeout(() => app.exit(2), 45_000); +void app.whenReady().then(async () => { + const { BrowserService } = await import("../../main/services/browser/service"); + const { configStore } = await import("../../main/services/config-store"); + // Isolate native scheduling from workspace persistence/admission, covered elsewhere. + configStore.getWorkspace = async () => ({ id: "throttle" }) as Awaited>; + const browser = new BrowserService(); + const owner = new BrowserWindow({ show: false }); + browser.attachOwner("throttle", { + id: owner.webContents.id, documentId: "native-throttle-test", + isDestroyed: () => owner.isDestroyed(), send: () => {}, onInvalidated: () => () => {}, + }); + type Tab = { state: BrowserTab; view: WebContentsView; initialLoad: Promise; backgroundThrottling: BrowserBackgroundThrottling; recording?: { recorder: BrowserWindow } }; + const native = browser as unknown as { + create(workspace: string, url: string, profile: undefined, show: boolean): Promise; + capture(tab: Tab): Promise; + close(tab: Tab): void; + present(tab: Tab, visible: boolean, bounds?: { x: number; y: number; width: number; height: number }): void; + startRecording(tab: Tab, fps: number, signal?: AbortSignal): Promise; + stopRecording(tab: Tab): Promise<{ sizeBytes: number }>; + }; + // about:blank avoids external network and permits the existing URL policy. + const tab = await native.create("throttle", "about:blank", undefined, false); + const wc = tab.view.webContents; + await tab.initialLoad; + const idlePolicy = wc.getBackgroundThrottling(); + assert.equal(idlePolicy, true, "an ordinary hidden guest must use normal scheduling"); + owner.show(); + native.present(tab, true, { x: 0, y: 0, width: 600, height: 400 }); + await wc.executeJavaScript("document.body.style.background = 'rgb(30, 90, 160)'; document.body.textContent = 'Local scheduling fixture'"); + const screenshot = await native.capture(tab); + native.present(tab, false); + assert.ok(screenshot.data.length > 0); + assert.equal(wc.getBackgroundThrottling(), true, "capture must restore the idle policy"); + const hiddenScreenshot = await native.capture(tab); + assert.ok(hiddenScreenshot.data.length > 0); + assert.equal(wc.getBackgroundThrottling(), true); + const command = (expression: string, signal?: AbortSignal) => browser.command("throttle", { + action: "evaluate", tabId: tab.state.id, expression, + }, { source: "user", signal }); + console.error("[throttling] awaiting hidden timer"); + const value = await command("new Promise(resolve => setTimeout(() => resolve('timer'), 10))"); + assert.equal(value.value, "timer", "hidden automation must receive timer callbacks"); + assert.equal(wc.getBackgroundThrottling(), true); + await assert.rejects(command("throw new Error('fixture failure')"), /fixture failure/); + assert.equal(wc.getBackgroundThrottling(), true, "failed action must restore scheduling"); + const abort = new AbortController(); + const pending = command("new Promise(() => {})", abort.signal); + // Observe actual acquisition, rather than guessing the queue's start time. + while (wc.getBackgroundThrottling()) await new Promise(resolve => setImmediate(resolve)); + abort.abort(new Error("fixture cancelled")); + await assert.rejects(pending, /fixture cancelled/); + assert.equal(wc.getBackgroundThrottling(), true); + console.error("[throttling] starting recording"); + await native.startRecording(tab, 10); + assert.equal(wc.getBackgroundThrottling(), false, "recording owns scheduling beyond start"); + const video = await native.stopRecording(tab); + assert.ok(video.sizeBytes > 16); + assert.equal(wc.getBackgroundThrottling(), true, "recording stop releases its owner"); + const cancelledStart = new AbortController(); + cancelledStart.abort(new Error("cancel recorder startup")); + await assert.rejects(native.startRecording(tab, 10, cancelledStart.signal), /cancel recorder startup/); + assert.equal(wc.getBackgroundThrottling(), true); + const extraTabs = await Promise.all([1, 2].map(() => native.create("throttle", "about:blank", undefined, false))); + await Promise.all(extraTabs.map(extra => extra.initialLoad)); + const idlePolicies = [tab, ...extraTabs].map(extra => extra.view.webContents.getBackgroundThrottling()); + assert.deepEqual(idlePolicies, [true, true, true]); + for (const extra of extraTabs) native.close(extra); + // Crash an actual recording: production cleanup must retire both owners. + await native.startRecording(tab, 10); + const recorderClosed = new Promise(resolve => tab.recording!.recorder.once("closed", () => resolve())); + const release = tab.backgroundThrottling.acquire(); + const crashed = new Promise(resolve => wc.once("render-process-gone", () => resolve())); + wc.forcefullyCrashRenderer(); + await crashed; + assert.equal(wc.getBackgroundThrottling(), true, "crash resets scheduling"); + assert.equal(tab.state.recording, false, "crash ends the unusable recording"); + release(); + await recorderClosed; + const destroyed = new Promise(resolve => wc.once("destroyed", () => resolve())); + native.close(tab); + await destroyed; + assert.equal(wc.isDestroyed(), true); + await writeFile(path.join(root, "result.json"), JSON.stringify({ + electron: process.versions.electron, platform: process.platform, idlePolicy, idlePolicies, + screenshotBytes: Buffer.from(screenshot.data, "base64").length, + timerResult: value.value, recordingBytes: video.sizeBytes, + cancellationRestored: true, failureRestored: true, crashRestored: true, + }, null, 2)); + owner.destroy(); + clearTimeout(deadline); + app.exit(0); +}).catch(error => { console.error(error); app.exit(1); }); diff --git a/tests/e2e/browser-throttling.spec.ts b/tests/e2e/browser-throttling.spec.ts new file mode 100644 index 000000000..a45ecac2a --- /dev/null +++ b/tests/e2e/browser-throttling.spec.ts @@ -0,0 +1,69 @@ +import { execFile } from "node:child_process"; +import { createRequire } from "node:module"; +import { mkdir, mkdtemp, readFile, rm } from "node:fs/promises"; +import path from "node:path"; +import { promisify } from "node:util"; +import { build } from "esbuild"; +import playwrightTest from "@playwright/test"; +import type * as PlaywrightTestModule from "@playwright/test"; + +const { expect, test } = + playwrightTest as unknown as typeof PlaywrightTestModule; + +// A standalone native guest avoids starting unrelated application services. +test("hidden browser scheduling is owned by active automation and capture", async () => { + const testInfo = test.info(); + const root = testInfo.outputPath("native"); + await mkdir(path.resolve("build"), { recursive: true }); + const temporaryBuild = await mkdtemp(path.resolve("build/native-throttling-")); + let output = ""; + const entry = path.join(temporaryBuild, "throttling.mjs"); + try { + await build({ + entryPoints: [path.resolve("tests/e2e/browser-throttling-native.ts")], + outfile: entry, + bundle: true, + platform: "node", + format: "esm", + packages: "external", + external: ["electron"], + tsconfigRaw: { compilerOptions: {} }, + logLevel: "silent", + }); + const electron = createRequire(import.meta.url)("electron") as string; + // Match the Aiden Electron fixture's GPU switch; hosted runners have no GPU. + // SIGKILL on timeout: Chromium turns SIGTERM into a clean exit 0, which + // would hide a hang behind a missing result file instead of its stderr. + const run = await promisify(execFile)(electron, ["--disable-gpu", entry], { + timeout: 60_000, + killSignal: "SIGKILL", + env: { + PATH: process.env.PATH, + AIDEN_BROWSER_THROTTLING_ROOT: root, + AIDEN_CONFIG_DIR: path.join(root, "config"), + // This fixture bypasses the Electron fixture's environment + // assembly, so forward the display session itself on Linux. + ...(process.platform === "linux" + ? Object.fromEntries( + ["DISPLAY", "WAYLAND_DISPLAY", "XAUTHORITY", "XDG_RUNTIME_DIR", "XDG_SESSION_TYPE"] + .filter((name) => process.env[name] !== undefined) + .map((name) => [name, process.env[name]]), + ) + : {}), + }, + }); + output = `${run.stdout}${run.stderr}`; + } finally { + await rm(temporaryBuild, { recursive: true, force: true }); + } + const result = await readFile(path.join(root, "result.json"), "utf8").catch( + (error: NodeJS.ErrnoException) => { + if (error.code !== "ENOENT") throw error; + throw new Error(`Native scheduling fixture exited without a result:\n${output}`); + }, + ); + expect(JSON.parse(result)).toMatchObject({ + idlePolicy: true, timerResult: "timer", cancellationRestored: true, + failureRestored: true, crashRestored: true, + }); +}); From 32c8197989e40d629dd3dd53d6c0fba1681ceac0 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Sun, 27 Sep 2026 23:02:49 -0400 Subject: [PATCH 2/3] Document and validate attached-window browser throttling exceptions --- .memory/perf-browser-throttling.md | 8 ++ .../browser-throttling-attached-native.json | 66 ++++++++++ docs/performance/browser-throttling.md | 14 +++ .../services/browser/background-throttling.ts | 7 +- tests/e2e/browser-throttling-native.ts | 117 +++++++++++++++++- tests/e2e/browser-throttling.spec.ts | 5 + 6 files changed, 214 insertions(+), 3 deletions(-) create mode 100644 docs/performance/browser-throttling-attached-native.json diff --git a/.memory/perf-browser-throttling.md b/.memory/perf-browser-throttling.md index 8665e5510..cbd83377d 100644 --- a/.memory/perf-browser-throttling.md +++ b/.memory/perf-browser-throttling.md @@ -9,3 +9,11 @@ Exact baseline native test fails `false !== true` for idle throttling. Three nat No measured CPU/energy claim: this is native policy/ownership evidence. Five-minute power/resource traces and Linux native execution remain unperformed. A detached hidden requestAnimationFrame promise stalls on exact baseline and candidate macOS; do not misreport as a new regression or a fixed behavior. Cold never-presented blank capture also lacks a surface; native fixture presents local DOM once before hiding. No UI/protocol/onboarding change. Fresh review and current-head hosted CI remain required before publication/merge. Root owns plan index, papercut records, PR, and CI; this lane does not push. + +## PR #282 review: attached host rendering effect + +Electron43.1.1 aggregates each attached WebContents into the host compositor: any guest false permits window-wide background drawing even if host/sibling getters are true. Accepted/documented as an active-operation exception; no reparenting or production behavior change. Five-minute recording duration is not an unconditional wall-time lease cap: acquisition precedes asynchronous startup, release follows encoder finalization, and overlapping/repeated work can extend it. Source links and hashes plus decision in docs/performance/browser-throttling.md. + +Extended native fixture attaches guest+sibling, actually minimizes/restores the host, validates timer automation and valid recording, cancellation overlap, explicit stop and deterministic invocation of the production300000ms auto-stop callback. Awaits encoder closed; host stays minimized until explicit restore and attachments remain unchanged. Raw docs/performance/browser-throttling-attached-native.json records [true,true,false] → [true,true,true]. Host/sibling RAF each0 in the attached JSON samples before/during/after recording, hidden throughout; this does NOT establish compositor isolation/no drawing (renderer visibility is independent), nor any resource savings. Native1/1 passes9.6s on macOS; no Linux-native claim. Fresh review required before push. + +Linux fixture explicitly uses native hide/show visibility behavior because bare Xvfb has no window manager; this is not Linux minimize coverage. macOS/Windows path uses real minimize/restore. Final browser154/154, application/E2E type checks, scoped lint, and diff checks pass. diff --git a/docs/performance/browser-throttling-attached-native.json b/docs/performance/browser-throttling-attached-native.json new file mode 100644 index 000000000..df2376d6e --- /dev/null +++ b/docs/performance/browser-throttling-attached-native.json @@ -0,0 +1,66 @@ +{ + "electron": "43.1.1", + "platform": "darwin", + "idlePolicy": true, + "idlePolicies": [ + true, + true, + true + ], + "screenshotBytes": 12228, + "timerResult": "timer", + "recordingBytes": 795, + "cancellationRestored": true, + "failureRestored": true, + "crashRestored": true, + "attachedWindow": { + "duringRecordingPolicies": [ + true, + true, + false + ], + "afterRecordingPolicies": [ + true, + true, + true + ], + "frameSamples": [ + { + "phase": "minimized idle", + "elapsedMs": 503, + "host": 0, + "sibling": 0, + "visibility": [ + "hidden", + "hidden" + ] + }, + { + "phase": "minimized recording", + "elapsedMs": 503, + "host": 0, + "sibling": 0, + "visibility": [ + "hidden", + "hidden" + ] + }, + { + "phase": "minimized after stop", + "elapsedMs": 505, + "host": 0, + "sibling": 0, + "visibility": [ + "hidden", + "hidden" + ] + } + ], + "minimizedTimer": "minimized timer", + "recordingBytes": 9901, + "backgroundMode": "minimized", + "stayedBackgrounded": true, + "preservedAttachments": true, + "automaticStopDelayMs": 300000 + } +} \ No newline at end of file diff --git a/docs/performance/browser-throttling.md b/docs/performance/browser-throttling.md index 308d813c9..c86263a2d 100644 --- a/docs/performance/browser-throttling.md +++ b/docs/performance/browser-throttling.md @@ -53,3 +53,17 @@ A detached hidden `requestAnimationFrame` promise timed out on both the unchange - `git diff --check`: pass. No shared server/transcript/mobile behavior changed, so native mobile suites do not apply. Independent review and hosted CI are separate publication gates. + +## Attached-window exception (PR #282 follow-up) + +**Decision:** accept Electron's shared compositor exception during active browser work. Lease ownership is per tab; its rendering effect is **not isolated to that tab**. When an unthrottled guest is attached, its host window—including the application renderer and sibling views—can draw in the background. Idle per-WebContents getters do not prove that those siblings' compositor is throttled. This is particularly relevant to Linux's retained hidden capture host and to a visible guest whose host is minimized. + +Electron documents this window-wide behavior since version 28. The pinned 43.1.1 implementation registers WebContents as throttling sources on its owner window; changing the guest preference updates that window. The window enables compositor throttling only when all registered sources permit it. Thus setting the last active guest back to `true` removes its contribution to the window-wide exception; other independent unthrottled sources may still keep the window drawing. Sources: [Electron API](https://www.electronjs.org/docs/latest/api/web-contents#contentssetbackgroundthrottlingallowed), [43.1.1 WebContents ownership and setter](https://github.com/electron/electron/blob/v43.1.1/shell/browser/api/electron_api_web_contents.cc#L2519-L2534), [43.1.1 native-window aggregation](https://github.com/electron/electron/blob/v43.1.1/shell/browser/native_window.cc#L688-L717). Source SHA-256: `electron_api_web_contents.cc` = `0ee59ca23c4706734a5f46681f9a8cff1b4a71f9d287104bf603227ab1885060`; `native_window.cc` = `990e9e2abe00196177aef29a0c34e456d659a18591f0a92665581f6416812ce6`. + +The accepted bound is operation ownership, not a universal five-minute wall-clock cap. Recording acquires before asynchronous setup, retains the lease through encoding/export teardown, and schedules its existing stop callback five minutes after recording setup. Setup/finalization use existing operation deadlines; overlapping captures/actions or successive recordings can extend continuous window activity. Cancellation, stop, crash, and close release their respective owners. This is acceptable for explicitly active automation/capture: it preserves recording while minimized, native host attachment, focus, and input behavior. It does not promise idle-window efficiency while active work continues. Moving guests to another window would introduce capture/input/attachment changes outside this slice; it is not required to restore normal policy once the work ends. + +Extended native coverage uses an actual host with both the recorded guest and another guest attached. On macOS/Windows it minimizes that window; Linux uses native hide/show because bare Xvfb CI has no window manager to acknowledge minimization. Linux tests visibility lifecycle, not minimization. The fixture runs timer-based automation during recording, cancels an overlapping action without ending recording, stops recording while keeping the host minimized, then restores the window. It also captures the production stop-timer registration (`300000` ms) and invokes that callback deterministically while minimized, awaiting the real encoder's closure and checking the remaining guests all return to their normal policy. It does not wait five wall-clock minutes. Assertions verify unchanged attachments, valid video output, native minimized/restored state, and release after both explicit and automatic stop. + +Raw observations are preserved in [browser-throttling-attached-native.json](browser-throttling-attached-native.json). On the same macOS/Electron environment above, host/sibling/recording-guest preferences were `[true,true,false]` during recording and `[true,true,true]` after stop. The minimized recording produced 9,901 bytes of valid WebM. Host and sibling RAF samples were both zero over 503 ms before recording, 503 ms during recording, and 505 ms after stop; their document visibility remained `hidden`. These observations **do not demonstrate compositor isolation or prove no extra drawing**: renderer visibility can independently suppress RAF despite the shared compositor permission. The shared rendering effect is source-confirmed; no native API exposes the aggregate compositor flag. No CPU, GPU, energy, or portable frame-rate inference is made from these short samples, and tests impose no timing-rate thresholds. + +Follow-up validation: extended native fixture passes 1/1 in 9.6 s; browser units, application/E2E type checks, scoped ESLint and diff checks pass for this follow-up. Production behavior is unchanged; the only production-source edit clarifies the window-wide effect in the lease helper's documentation. diff --git a/main/services/browser/background-throttling.ts b/main/services/browser/background-throttling.ts index 44f809351..7a541c187 100644 --- a/main/services/browser/background-throttling.ts +++ b/main/services/browser/background-throttling.ts @@ -1,4 +1,9 @@ -/** Scoped exceptions to Electron's normal background scheduling policy. */ +/** + * Ownership is per guest, but Electron also disables the attached host window's + * compositor throttling while ANY guest owns an exception. Host/sibling getters + * do not reflect that aggregate effect. Keep leases scoped to active operations; + * recording intentionally retains this window-wide exception until it stops. + */ export class BrowserBackgroundThrottling { private releases = new Set<() => void>(); constructor(private readonly target: { diff --git a/tests/e2e/browser-throttling-native.ts b/tests/e2e/browser-throttling-native.ts index a5f661820..ef5d12779 100644 --- a/tests/e2e/browser-throttling-native.ts +++ b/tests/e2e/browser-throttling-native.ts @@ -20,7 +20,7 @@ void app.whenReady().then(async () => { id: owner.webContents.id, documentId: "native-throttle-test", isDestroyed: () => owner.isDestroyed(), send: () => {}, onInvalidated: () => () => {}, }); - type Tab = { state: BrowserTab; view: WebContentsView; initialLoad: Promise; backgroundThrottling: BrowserBackgroundThrottling; recording?: { recorder: BrowserWindow } }; + type Tab = { state: BrowserTab; view: WebContentsView; initialLoad: Promise; backgroundThrottling: BrowserBackgroundThrottling; recording?: { recorder: BrowserWindow; stopTimer: ReturnType } }; const native = browser as unknown as { create(workspace: string, url: string, profile: undefined, show: boolean): Promise; capture(tab: Tab): Promise; @@ -71,6 +71,119 @@ void app.whenReady().then(async () => { cancelledStart.abort(new Error("cancel recorder startup")); await assert.rejects(native.startRecording(tab, 10, cancelledStart.signal), /cancel recorder startup/); assert.equal(wc.getBackgroundThrottling(), true); + // Electron aggregates compositor throttling across an attached window. Exercise + // the real host's minimized lifecycle, including a sibling that owns no lease. + native.present(tab, true, { x: 0, y: 0, width: 600, height: 400 }); + const sibling = await native.create("throttle", "about:blank", undefined, false); + await sibling.initialLoad; + owner.contentView.addChildView(sibling.view); + sibling.view.setBounds({ x: 610, y: 0, width: 100, height: 100 }); + sibling.view.setVisible(true); + await owner.loadURL("about:blank"); + const attachment = () => { + assert.ok(owner.contentView.children.includes(tab.view), "recorded guest stays in its host"); + assert.ok(owner.contentView.children.includes(sibling.view), "sibling stays attached"); + }; + const policies = () => [owner.webContents, sibling.view.webContents, wc] + .map(contents => contents.getBackgroundThrottling()); + assert.deepEqual(policies(), [true, true, true]); + const observedContents = [owner.webContents, sibling.view.webContents]; + await Promise.all(observedContents.map(contents => contents.executeJavaScript( + "globalThis.fixtureFrames = 0; requestAnimationFrame(function tick() { globalThis.fixtureFrames++; requestAnimationFrame(tick); });", + ))); + const frameSamples: Array<{ phase: string; elapsedMs: number; host: number; sibling: number; visibility: string[] }> = []; + const sampleFrames = async (phase: string) => { + const read = () => Promise.all(observedContents.map(contents => contents.executeJavaScript( + "({frames: globalThis.fixtureFrames, visibility: document.visibilityState})", + ))) as Promise>; + const before = await read(); + const started = Date.now(); + await new Promise(resolve => setTimeout(resolve, 500)); + const after = await read(); + frameSamples.push({ phase, elapsedMs: Date.now() - started, + host: after[0].frames - before[0].frames, sibling: after[1].frames - before[1].frames, + visibility: after.map(value => value.visibility), + }); + }; + // Linux CI runs bare Xvfb without a window manager to acknowledge minimization. + // Use native hide/show there; macOS/Windows exercise real minimize/restore. + const backgroundMode = process.platform === "linux" ? "hidden" : "minimized"; + const isBackgrounded = () => backgroundMode === "hidden" ? !owner.isVisible() : owner.isMinimized(); + const backgrounded = new Promise(resolve => { + if (backgroundMode === "hidden") owner.once("hide", () => resolve()); + else owner.once("minimize", () => resolve()); + }); + if (backgroundMode === "hidden") owner.hide(); + else owner.minimize(); + await backgrounded; + assert.equal(isBackgrounded(), true); + attachment(); + await sampleFrames(`${backgroundMode} idle`); + await native.startRecording(tab, 10); + await sampleFrames(`${backgroundMode} recording`); + const duringRecordingPolicies = policies(); + assert.deepEqual(duringRecordingPolicies, [true, true, false]); + // The host/sibling getters describe their own scheduler preference, NOT the + // window compositor: Electron's native aggregator allows window-wide drawing. + const minimizedTimer = await command("new Promise(resolve => setTimeout(() => resolve('minimized timer'), 10))"); + assert.equal(minimizedTimer.value, "minimized timer"); + const concurrentAbort = new AbortController(); + let enteredAction!: () => void; + const entered = new Promise(resolve => { enteredAction = resolve; }); + const concurrent = browser.command("throttle", { + action: "evaluate", tabId: tab.state.id, expression: "new Promise(() => {})", + }, { source: "user", signal: concurrentAbort.signal, beforeEffect: enteredAction }); + await entered; + concurrentAbort.abort(new Error("cancel attached action")); + await assert.rejects(concurrent, /cancel attached action/); + assert.equal(wc.getBackgroundThrottling(), false, "recording survives another owner's cancellation"); + const minimizedVideo = await native.stopRecording(tab); + assert.ok(minimizedVideo.sizeBytes > 16); + assert.equal(isBackgrounded(), true, "stopping capture must not restore the host window"); + attachment(); + const afterRecordingPolicies = policies(); + assert.deepEqual(afterRecordingPolicies, [true, true, true]); + await sampleFrames(`${backgroundMode} after stop`); + // Exercise the production five-minute expiry without a five-minute sleep. + // Keep real timers/native encoder startup; retain the actual stop timer callback. + const schedule = globalThis.setTimeout; + const timers = new Map, { callback: () => void; delay?: number }>(); + globalThis.setTimeout = ((callback: (...args: unknown[]) => void, delay?: number, ...args: unknown[]) => { + const timer = schedule(callback, delay, ...args); + timers.set(timer, { callback: () => callback(...args), delay }); + return timer; + }) as typeof setTimeout; + try { + await native.startRecording(tab, 10); + } finally { + globalThis.setTimeout = schedule; + } + const expiring = tab.recording!; + const expiry = timers.get(expiring.stopTimer)!; + assert.equal(expiry.delay, 5 * 60_000, "active recording has a five-minute capture limit"); + const autoStopped = new Promise(resolve => expiring.recorder.once("closed", () => resolve())); + clearTimeout(expiring.stopTimer); + expiry.callback(); + await autoStopped; + assert.equal(tab.state.recording, false); + assert.deepEqual(policies(), [true, true, true], "automatic stop releases the window's last exception"); + assert.equal(isBackgrounded(), true); + attachment(); + owner.contentView.removeChildView(sibling.view); + native.close(sibling); + const restored = new Promise(resolve => { + if (backgroundMode === "hidden") owner.once("show", () => resolve()); + else owner.once("restore", () => resolve()); + }); + if (backgroundMode === "hidden") owner.show(); + else owner.restore(); + await restored; + assert.equal(isBackgrounded(), false); + const attachedWindow = { + duringRecordingPolicies, afterRecordingPolicies, frameSamples, + minimizedTimer: minimizedTimer.value, recordingBytes: minimizedVideo.sizeBytes, + backgroundMode, stayedBackgrounded: true, preservedAttachments: true, automaticStopDelayMs: expiry.delay, + }; const extraTabs = await Promise.all([1, 2].map(() => native.create("throttle", "about:blank", undefined, false))); await Promise.all(extraTabs.map(extra => extra.initialLoad)); const idlePolicies = [tab, ...extraTabs].map(extra => extra.view.webContents.getBackgroundThrottling()); @@ -95,7 +208,7 @@ void app.whenReady().then(async () => { electron: process.versions.electron, platform: process.platform, idlePolicy, idlePolicies, screenshotBytes: Buffer.from(screenshot.data, "base64").length, timerResult: value.value, recordingBytes: video.sizeBytes, - cancellationRestored: true, failureRestored: true, crashRestored: true, + cancellationRestored: true, failureRestored: true, crashRestored: true, attachedWindow, }, null, 2)); owner.destroy(); clearTimeout(deadline); diff --git a/tests/e2e/browser-throttling.spec.ts b/tests/e2e/browser-throttling.spec.ts index a45ecac2a..002700699 100644 --- a/tests/e2e/browser-throttling.spec.ts +++ b/tests/e2e/browser-throttling.spec.ts @@ -65,5 +65,10 @@ test("hidden browser scheduling is owned by active automation and capture", asyn expect(JSON.parse(result)).toMatchObject({ idlePolicy: true, timerResult: "timer", cancellationRestored: true, failureRestored: true, crashRestored: true, + attachedWindow: { + duringRecordingPolicies: [true, true, false], + afterRecordingPolicies: [true, true, true], + stayedBackgrounded: true, preservedAttachments: true, automaticStopDelayMs: 300_000, + }, }); }); From deec0c010740e8bf81d41fb93f5c8550f03208c7 Mon Sep 17 00:00:00 2001 From: Sambit Biswas Date: Sun, 27 Sep 2026 23:05:27 -0400 Subject: [PATCH 3/3] Clarify recording lifetime bounds and auto-stop evidence --- .memory/perf-browser-throttling.md | 2 ++ docs/performance/browser-throttling.md | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/.memory/perf-browser-throttling.md b/.memory/perf-browser-throttling.md index cbd83377d..0d8a320e6 100644 --- a/.memory/perf-browser-throttling.md +++ b/.memory/perf-browser-throttling.md @@ -17,3 +17,5 @@ Electron43.1.1 aggregates each attached WebContents into the host compositor: an Extended native fixture attaches guest+sibling, actually minimizes/restores the host, validates timer automation and valid recording, cancellation overlap, explicit stop and deterministic invocation of the production300000ms auto-stop callback. Awaits encoder closed; host stays minimized until explicit restore and attachments remain unchanged. Raw docs/performance/browser-throttling-attached-native.json records [true,true,false] → [true,true,true]. Host/sibling RAF each0 in the attached JSON samples before/during/after recording, hidden throughout; this does NOT establish compositor isolation/no drawing (renderer visibility is independent), nor any resource savings. Native1/1 passes9.6s on macOS; no Linux-native claim. Fresh review required before push. Linux fixture explicitly uses native hide/show visibility behavior because bare Xvfb has no window manager; this is not Linux minimize coverage. macOS/Windows path uses real minimize/restore. Final browser154/154, application/E2E type checks, scoped lint, and diff checks pass. + +Fresh-review precision: individual browser waits have deadlines, but initial debugger Network.enable and disk mkdir/writeFile do not. No aggregate startup/teardown deadline bounds the recording lease; ownership remains the boundary. Auto-stop fixture proves encoder cleanup and lease release only because its production callback suppresses stop errors; explicit stop validates WebM export. Details corrected in the performance evidence doc; docs-only follow-up. diff --git a/docs/performance/browser-throttling.md b/docs/performance/browser-throttling.md index c86263a2d..59c595528 100644 --- a/docs/performance/browser-throttling.md +++ b/docs/performance/browser-throttling.md @@ -60,9 +60,9 @@ No shared server/transcript/mobile behavior changed, so native mobile suites do Electron documents this window-wide behavior since version 28. The pinned 43.1.1 implementation registers WebContents as throttling sources on its owner window; changing the guest preference updates that window. The window enables compositor throttling only when all registered sources permit it. Thus setting the last active guest back to `true` removes its contribution to the window-wide exception; other independent unthrottled sources may still keep the window drawing. Sources: [Electron API](https://www.electronjs.org/docs/latest/api/web-contents#contentssetbackgroundthrottlingallowed), [43.1.1 WebContents ownership and setter](https://github.com/electron/electron/blob/v43.1.1/shell/browser/api/electron_api_web_contents.cc#L2519-L2534), [43.1.1 native-window aggregation](https://github.com/electron/electron/blob/v43.1.1/shell/browser/native_window.cc#L688-L717). Source SHA-256: `electron_api_web_contents.cc` = `0ee59ca23c4706734a5f46681f9a8cff1b4a71f9d287104bf603227ab1885060`; `native_window.cc` = `990e9e2abe00196177aef29a0c34e456d659a18591f0a92665581f6416812ce6`. -The accepted bound is operation ownership, not a universal five-minute wall-clock cap. Recording acquires before asynchronous setup, retains the lease through encoding/export teardown, and schedules its existing stop callback five minutes after recording setup. Setup/finalization use existing operation deadlines; overlapping captures/actions or successive recordings can extend continuous window activity. Cancellation, stop, crash, and close release their respective owners. This is acceptable for explicitly active automation/capture: it preserves recording while minimized, native host attachment, focus, and input behavior. It does not promise idle-window efficiency while active work continues. Moving guests to another window would introduce capture/input/attachment changes outside this slice; it is not required to restore normal policy once the work ends. +The accepted bound is operation ownership, not a universal five-minute wall-clock cap. Recording acquires before asynchronous setup, retains the lease through encoding/export teardown, and schedules its existing stop callback five minutes after encoder readiness, before awaiting screencast startup. Individual waits have deadlines: recorder navigation 15 s, encoder initialization 5 s, each viewport capture attempt 1.2 s (up to three attempts), each first-frame status/capture/pacing await 1 s with a checked 5 s readiness deadline, regular CDP commands 15 s, frame ingestion 3 s, and encoder finalization/serialization 15 s. These do not form an aggregate startup or teardown deadline. Initial debugger `Network.enable` and disk-export `mkdir`/`writeFile` awaits are not deadline-wrapped. Ownership is the actual lifetime boundary; a stalled unbounded await, overlapping captures/actions, or successive recordings can extend continuous window activity. Cancellation, stop, crash, and close release their respective owners. This is acceptable for explicitly active automation/capture: it preserves recording while minimized, native host attachment, focus, and input behavior. It does not promise idle-window efficiency while active work continues. Moving guests to another window would introduce capture/input/attachment changes outside this slice; it is not required to restore normal policy once the work ends. -Extended native coverage uses an actual host with both the recorded guest and another guest attached. On macOS/Windows it minimizes that window; Linux uses native hide/show because bare Xvfb CI has no window manager to acknowledge minimization. Linux tests visibility lifecycle, not minimization. The fixture runs timer-based automation during recording, cancels an overlapping action without ending recording, stops recording while keeping the host minimized, then restores the window. It also captures the production stop-timer registration (`300000` ms) and invokes that callback deterministically while minimized, awaiting the real encoder's closure and checking the remaining guests all return to their normal policy. It does not wait five wall-clock minutes. Assertions verify unchanged attachments, valid video output, native minimized/restored state, and release after both explicit and automatic stop. +Extended native coverage uses an actual host with both the recorded guest and another guest attached. On macOS/Windows it minimizes that window; Linux uses native hide/show because bare Xvfb CI has no window manager to acknowledge minimization. Linux tests visibility lifecycle, not minimization. The fixture runs timer-based automation during recording, cancels an overlapping action without ending recording, stops recording while keeping the host minimized, then restores the window. It also captures the production stop-timer registration (`300000` ms) and invokes that callback deterministically while minimized, awaiting the real encoder's closure and checking the remaining guests all return to their normal policy. It does not wait five wall-clock minutes. Assertions verify unchanged attachments, native minimized/restored state, and release after both explicit and automatic stop. The explicit-stop path validates returned WebM output. The automatic-stop fixture establishes encoder cleanup and lease release only: its production callback suppresses stop errors, so encoder closure does not establish successful export. Raw observations are preserved in [browser-throttling-attached-native.json](browser-throttling-attached-native.json). On the same macOS/Electron environment above, host/sibling/recording-guest preferences were `[true,true,false]` during recording and `[true,true,true]` after stop. The minimized recording produced 9,901 bytes of valid WebM. Host and sibling RAF samples were both zero over 503 ms before recording, 503 ms during recording, and 505 ms after stop; their document visibility remained `hidden`. These observations **do not demonstrate compositor isolation or prove no extra drawing**: renderer visibility can independently suppress RAF despite the shared compositor permission. The shared rendering effect is source-confirmed; no native API exposes the aggregate compositor flag. No CPU, GPU, energy, or portable frame-rate inference is made from these short samples, and tests impose no timing-rate thresholds.