Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions .memory/perf-browser-throttling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# 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.

## 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.

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.
66 changes: 66 additions & 0 deletions docs/performance/browser-throttling-attached-native.json
Original file line number Diff line number Diff line change
@@ -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
}
}
69 changes: 69 additions & 0 deletions docs/performance/browser-throttling.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
# 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.

## 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 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, 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.

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.
64 changes: 64 additions & 0 deletions main/services/browser/background-throttling.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
31 changes: 31 additions & 0 deletions main/services/browser/background-throttling.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
/**
* 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: {
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);
Comment thread
sambitcreate marked this conversation as resolved.
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();
}
}
Loading
Loading