From e29f36426ba1fe40935959c8bab027ba23fc7adb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Fri, 9 Oct 2026 21:34:57 +0200 Subject: [PATCH] perf(tooling): compositing probe, report command and route-wide clip-mask guard (#1858) * perf(tooling): compositing probe, report command and route-wide clip-mask guard Make the instrument that found the dashboard's clip masks part of the repository: `src/performance/compositing-probe.ts` reads the layer tree over CDP (bounds in device pixels, compositing reasons and owner nodes, waiting for Blink's layer debug info), classifies synthesized clip masks (no owner node and no compositing reason) and reads the renderer's `cc/tile_memory` from a memory-infra dump. `pnpm run perf:compositing` seeds one profile against the Xtream mock on a dedicated port, serves the artwork from memory, fixes the window at 1600x1000 and records tile and image memory, layer counts, the largest layers, masks and tile warnings for Live TV, the movie and series details, the dashboard (idle, crossfade, scrolled) and settings into dist/performance/compositing//summary.json and a console table. Both helpers have node:test coverage in the performance harness. The dashboard-only compositing E2E becomes `compositing.e2e.ts` and guards Live TV, a movie detail, a series detail and the dashboard in one launch, with the backdrop-filtered controls confirmed as composited layers on the dashboard and the movie detail. The rule now lives where it triggers: a Rendering Cost section in the theme/style skill, a validation line in the UI design skill, a context map row, and a Compositing budget section in the performance journeys contract (budget, mask mechanism, instruments, why megabytes are evidence rather than a ratchet). Validation map and README mention the command. Co-Authored-By: Claude Fable 5.1 * perf(tooling): sample the dashboard idle reading with rotation paused The hero advances after 8 s, so a 9 s wait sampled the next slide one second into its zoom (backdrop scale 1.116 instead of 1.1, the previous slide's tiles still pooled): 224 MB read as idle, 122 MB with rotation paused and the zoom and tile pool settled. The warning delta and its checkpoint are now taken at one boundary after the sampling, so a "tile memory limits exceeded" line logged while the layers or the dump are read counts for that route instead of for none. The report's test artifacts go under dist/test-results like the other configs'. Co-Authored-By: Claude Fable 5.1 * perf(tooling): retry a memory dump that carries no renderer tile memory One reading of seven came back without `cc/tile_memory`: the dump's trace events reach the buffer shortly after the request resolves, and a process can skip a dump it is busy for. The capture now waits briefly before ending the trace and retries up to three times before a reading stays null. Co-Authored-By: Claude Fable 5.1 * perf(tooling): run the compositing report with motion enabled With the OS set to reduced motion the hero neither rotates nor renders its pause button, so the report's click timed out before the summary was written. The report now emulates `prefers-reduced-motion: no-preference`, which also keeps the idle and crossfade readings on the same animation set on every machine. Co-Authored-By: Claude Fable 5.1 * perf(tooling): sample the crossfade frozen, count split stderr lines, drop stale layer samples Review findings on the pushed head, plus the CI failure: - A layer that vanished between the LayerTree snapshot and its reasons query was kept with empty reasons; without an owner node it would pass for a synthesized mask. Such a snapshot is now discarded and retaken. - The crossfade reading sampled layers, owners and memory across the 700 ms fade, so the row depended on how long the sampling took. The page's animations are frozen 300 ms into the fade while the reading is taken, resumed afterwards, and the incoming slide's zoom is allowed to finish before the scrolled reading. - A stderr chunk can end mid-line; the warning counter now keeps the unfinished line for the next chunk. - The mock server's launch spec lists the new Playwright config. Co-Authored-By: Claude Fable 5.1 --------- Co-authored-by: Claude Fable 5.1 --- .codex/skills/iptvnator-theme-style/SKILL.md | 14 + .codex/skills/iptvnator-ui-design/SKILL.md | 3 + README.md | 3 + .../playwright.compositing.config.ts | 39 ++ apps/electron-backend-e2e/project.json | 10 + .../src/compositing.e2e.ts | 137 +++++++ .../src/compositing/compositing.report.ts | 285 +++++++++++++ .../src/dashboard-compositing.e2e.ts | 157 -------- .../src/performance/compositing-probe.spec.ts | 208 ++++++++++ .../src/performance/compositing-probe.ts | 381 ++++++++++++++++++ .../performance/compositing-report.spec.ts | 118 ++++++ .../src/performance/compositing-report.ts | 103 +++++ .../src/app/project-config.spec.ts | 3 + docs/architecture/performance-journeys.md | 62 +++ docs/architecture/validation-map.md | 6 +- docs/architecture/workspace-shell.md | 2 +- docs/maintenance/agent-context-map.md | 1 + package.json | 1 + 18 files changed, 1374 insertions(+), 159 deletions(-) create mode 100644 apps/electron-backend-e2e/playwright.compositing.config.ts create mode 100644 apps/electron-backend-e2e/src/compositing.e2e.ts create mode 100644 apps/electron-backend-e2e/src/compositing/compositing.report.ts delete mode 100644 apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts create mode 100644 apps/electron-backend-e2e/src/performance/compositing-probe.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/compositing-probe.ts create mode 100644 apps/electron-backend-e2e/src/performance/compositing-report.spec.ts create mode 100644 apps/electron-backend-e2e/src/performance/compositing-report.ts diff --git a/.codex/skills/iptvnator-theme-style/SKILL.md b/.codex/skills/iptvnator-theme-style/SKILL.md index 3fd04d688..0ff05e5e4 100644 --- a/.codex/skills/iptvnator-theme-style/SKILL.md +++ b/.codex/skills/iptvnator-theme-style/SKILL.md @@ -57,6 +57,20 @@ consumers currently use relative `@use` paths to the needed partial. cap in rules that set that family. Read the guidelines' Typography section before changing bundled fonts. +## Rendering Cost + +- A composited layer the size of the content area costs about 24 MB of + tile memory at 2x against Chromium's 512 MB budget; contract in + `docs/architecture/performance-journeys.md` (Compositing budget). +- No `border-radius`, `mask` or `clip-path` on a scroller or another + ancestor of `backdrop-filter`, render-surface `filter`, running + animations, `isolation: isolate` or `will-change`: Blink then clips each + through a mask layer the size of the clipped area. Paint the corner, as + `.workspace-content-frame` does. +- Adding or changing those properties: run `pnpm run perf:compositing` + before and after, quote the numbers in the PR, and extend + `compositing.e2e.ts` in `apps/electron-backend-e2e` for a new route. + ## Validation Run the affected consumer's Nx lint/test/build target. Inspect light and dark diff --git a/.codex/skills/iptvnator-ui-design/SKILL.md b/.codex/skills/iptvnator-ui-design/SKILL.md index 83eecc133..3554b796d 100644 --- a/.codex/skills/iptvnator-ui-design/SKILL.md +++ b/.codex/skills/iptvnator-ui-design/SKILL.md @@ -42,3 +42,6 @@ not UI or util. Run the focused component/unit target and the closest Playwright workflow for a visible change. Electron CDP is a fallback for Electron-only gaps, not a replacement for an available E2E flow. Record any uncovered visual state. +A change that adds `backdrop-filter`, `filter`, animations or a rounded +scroller also runs `pnpm run perf:compositing` (see the theme/style skill's +Rendering Cost section) and quotes the numbers. diff --git a/README.md b/README.md index ae7f09d20..95f767353 100644 --- a/README.md +++ b/README.md @@ -399,6 +399,9 @@ $ pnpm run perf:journeys The journeys, their counters and the summary written under `dist/performance/journeys/` are described in [docs/architecture/performance-journeys.md](docs/architecture/performance-journeys.md). +`pnpm run perf:compositing` measures the renderer's tile memory and composited +layers on the dashboard, the detail pages, Live TV and settings (the +"compositing budget" section of the same document). ## Disclaimer diff --git a/apps/electron-backend-e2e/playwright.compositing.config.ts b/apps/electron-backend-e2e/playwright.compositing.config.ts new file mode 100644 index 000000000..07bd66db2 --- /dev/null +++ b/apps/electron-backend-e2e/playwright.compositing.config.ts @@ -0,0 +1,39 @@ +import { workspaceRoot } from '@nx/devkit'; +import { defineConfig } from '@playwright/test'; + +/** + * Compositing report (`pnpm run perf:compositing`, + * docs/architecture/performance-journeys.md#compositing-budget). One worker, + * no retries. The Xtream mock runs on a dedicated loopback port so a normal + * E2E server on 3211 cannot be reused by accident; the test fixtures read the + * port from `XTREAM_MOCK_PORT`, which this config sets for the worker. + */ +const xtreamMockPort = + process.env['IPTVNATOR_COMPOSITING_XTREAM_MOCK_PORT'] ?? '3233'; +process.env['XTREAM_MOCK_PORT'] = xtreamMockPort; + +export default defineConfig({ + fullyParallel: false, + outputDir: '../../dist/test-results/electron-backend-e2e/compositing', + reporter: [['list']], + retries: 0, + testDir: './src/compositing', + testMatch: '**/*.report.ts', + timeout: 15 * 60 * 1_000, + use: { + testIdAttribute: 'data-test-id', + }, + webServer: { + command: 'node --import tsx apps/xtream-mock-server/src/main.ts', + cwd: workspaceRoot, + env: { + HOST: '127.0.0.1', + NODE_ENV: 'development', + PORT: xtreamMockPort, + TSX_TSCONFIG_PATH: 'tsconfig.base.json', + }, + reuseExistingServer: !process.env['CI'], + url: `http://127.0.0.1:${xtreamMockPort}/health`, + }, + workers: 1, +}); diff --git a/apps/electron-backend-e2e/project.json b/apps/electron-backend-e2e/project.json index 82e200a4f..b78d584df 100644 --- a/apps/electron-backend-e2e/project.json +++ b/apps/electron-backend-e2e/project.json @@ -68,6 +68,16 @@ "command": "playwright test --config=playwright.journeys.config.ts" } }, + "compositing-report": { + "dependsOn": ["electron-backend:build-e2e"], + "executor": "nx:run-commands", + "cache": false, + "parallelism": false, + "options": { + "cwd": "apps/electron-backend-e2e", + "command": "playwright test --config=playwright.compositing.config.ts" + } + }, "packaged-frame-copy-smoke": { "dependsOn": ["test-packaged-frame-copy-fixtures"], "executor": "nx:run-commands", diff --git a/apps/electron-backend-e2e/src/compositing.e2e.ts b/apps/electron-backend-e2e/src/compositing.e2e.ts new file mode 100644 index 000000000..8206ad00f --- /dev/null +++ b/apps/electron-backend-e2e/src/compositing.e2e.ts @@ -0,0 +1,137 @@ +import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + goToDashboard, + launchElectronApp, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, +} from './electron-test-fixtures'; +import { + addCurrentDetailToFavorites, + goBackFromDetail, + toggleFavoriteForChannel, +} from './dashboard-e2e-flows'; +import { + collectCompositedLayers, + describeLayerSize, + findOversizedClipMasks, + measureContentArea, + routeDeterministicArtwork, +} from './performance/compositing-probe'; +import { + fetchXtreamLiveFixture, + fetchXtreamSeriesFixture, + fetchXtreamVodFixture, + getXtreamTitle, +} from './portal-mock-fixtures'; + +test.describe('Compositing budget', () => { + // A `border-radius` on the content scroller made Blink clip every + // composited effect inside it (the rail chevrons' and hero controls' + // backdrop filters, running animations) through a synthesized mask layer + // the size of the whole content area: ~24 MB of tile memory each at 2x, + // a dozen on the dashboard, which took the renderer past Chromium's tile + // budget ("tile memory limits exceeded") and left blank tiles while + // scrolling. The corner is painted instead (workspace shell contract). + // Blink synthesizes the masks while layerizing, before any GPU work: + // under `--disable-gpu` (the Linux CI launch, software compositing and + // raster) the controls are still composited and the old radius still + // produced seven masks, so the check guards the same path everywhere. + test('synthesizes no content-area-sized clip masks on the dashboard, the detail pages and Live TV', async ({ + dataDir, + request, + }) => { + test.setTimeout(120_000); + await resetMockServers(request, ['xtream']); + const credentials = { + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }; + const live = await fetchXtreamLiveFixture(request, credentials); + const vod = await fetchXtreamVodFixture(request, credentials); + const series = await fetchXtreamSeriesFixture(request, credentials); + const app = await launchElectronApp(dataDir); + const page = app.mainWindow; + + try { + await routeDeterministicArtwork(page); + const cdp = await app.electronApp.context().newCDPSession(page); + const clipMasks = async () => { + const area = await measureContentArea(page); + const layers = await collectCompositedLayers(cdp, page); + return { + backdropFilterLayers: layers.filter((layer) => + layer.reasons.includes('BackdropFilter') + ).length, + masks: findOversizedClipMasks(layers, area).map( + describeLayerSize + ), + }; + }; + + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await openWorkspaceSection(page, 'Live TV'); + await clickCategoryByNameExact(page, live.categoryName); + await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0])); + await expect( + page.getByTestId('channel-item').first() + ).toBeVisible(); + expect((await clipMasks()).masks, 'Live TV').toEqual([]); + + await page + .getByRole('link', { exact: true, name: 'Movies' }) + .click(); + await clickCategoryByNameExact(page, vod.categoryName); + await clickFirstGridListCard(page); + await expect( + page.locator('app-content-hero .hero__backdrop-image') + ).toBeVisible({ timeout: 20_000 }); + await addCurrentDetailToFavorites(page); + const movie = await clipMasks(); + // The detail actions' ghost surfaces are backdrop-filtered. + expect(movie.backdropFilterLayers, 'movie detail').toBeGreaterThan( + 0 + ); + expect(movie.masks, 'movie detail').toEqual([]); + await goBackFromDetail(page); + + await page + .getByRole('link', { exact: true, name: 'Series' }) + .click(); + await clickCategoryByNameExact(page, series.categoryName); + await clickFirstGridListCard(page); + await expect(page.locator('app-content-hero')).toBeVisible({ + timeout: 20_000, + }); + await addCurrentDetailToFavorites(page); + expect((await clipMasks()).masks, 'series detail').toEqual([]); + + await goToDashboard(page); + await expect(page.getByTestId('dashboard-hero')).toBeVisible({ + timeout: 20_000, + }); + await expect( + page.getByTestId('dashboard-xtream-recently-added-rail') + ).toBeVisible({ timeout: 20_000 }); + const dashboard = await clipMasks(); + // The composited effects this guards: every rail has two + // backdrop-filtered chevrons, the hero its controls. Without this + // the mask check would pass on a page with nothing to clip. + expect(dashboard.backdropFilterLayers, 'dashboard').toBeGreaterThan( + 0 + ); + expect(dashboard.masks, 'dashboard').toEqual([]); + await cdp.detach(); + } finally { + await closeElectronApp(app); + } + }); +}); diff --git a/apps/electron-backend-e2e/src/compositing/compositing.report.ts b/apps/electron-backend-e2e/src/compositing/compositing.report.ts new file mode 100644 index 000000000..9186f293d --- /dev/null +++ b/apps/electron-backend-e2e/src/compositing/compositing.report.ts @@ -0,0 +1,285 @@ +import type { CDPSession, Page } from '@playwright/test'; + +import { + addXtreamPortal, + clickCategoryByNameExact, + clickFirstGridListCard, + closeElectronApp, + defaultXtreamPassword, + defaultXtreamUsername, + expect, + goToDashboard, + launchElectronApp, + openSettings, + openWorkspaceSection, + resetMockServers, + test, + waitForXtreamWorkspaceReady, + workspaceRoot, + type LaunchedElectronApp, +} from '../electron-test-fixtures'; +import { + addCurrentDetailToFavorites, + goBackFromDetail, + toggleFavoriteForChannel, +} from '../dashboard-e2e-flows'; +import { + captureTileMemory, + collectCompositedLayers, + describeLayerOwner, + describeLayerSize, + findOversizedClipMasks, + layerDevicePixels, + layerScale, + measureContentArea, + routeDeterministicArtwork, + TILE_POOL_SETTLE_MS, +} from '../performance/compositing-probe'; +import { + COMPOSITING_SUMMARY_SCHEMA_VERSION, + formatCompositingTable, + resolveCompositingSummaryPath, + writeCompositingSummary, + type CompositingRouteReading, + type CompositingSummary, +} from '../performance/compositing-report'; +import { + fetchXtreamLiveFixture, + fetchXtreamSeriesFixture, + fetchXtreamVodFixture, + getXtreamTitle, +} from '../portal-mock-fixtures'; + +/** + * Compositing report: the renderer's tile memory and composited layers on + * the routes with the most artwork and effects, one fresh profile seeded + * through the app's dialogs against the Xtream mock, at a fixed window size. + * It measures, it does not assert; the deterministic guard is + * `compositing.e2e.ts`. Contract: + * docs/architecture/performance-journeys.md#compositing-budget. + */ +const WINDOW = { height: 1000, width: 1600 }; +const LARGEST_LAYERS = 8; +/** The hero backdrop's zoom transition (`transform 8s` in its stylesheet). */ +const HERO_ZOOM_MS = 8_000; +/** Into the 700 ms crossfade, where the animations are frozen for sampling. */ +const CROSSFADE_SAMPLE_MS = 300; +const TILE_WARNING = /tile memory limits exceeded/; +const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); + +async function setWindowContentSize(app: LaunchedElectronApp): Promise { + await app.electronApp.evaluate(({ BrowserWindow }, size) => { + const [window] = BrowserWindow.getAllWindows(); + window.setPosition(0, 0); + window.setContentSize(size.width, size.height); + }, WINDOW); +} + +async function readGpuCompositing(app: LaunchedElectronApp): Promise { + const status = await app.electronApp.evaluate(({ app: electronApp }) => + electronApp.getGPUFeatureStatus() + ); + return String( + (status as unknown as Record)['gpu_compositing'] + ); +} + +async function readRoute( + cdp: CDPSession, + page: Page, + route: string +): Promise { + const area = await measureContentArea(page); + const layers = await collectCompositedLayers(cdp, page); + const largest = [...layers] + .filter((layer) => layer.drawsContent) + .sort((a, b) => layerDevicePixels(b) - layerDevicePixels(a)) + .slice(0, LARGEST_LAYERS); + const largestLayers = []; + for (const layer of largest) { + largestLayers.push({ + devicePixelsMB: Number( + ((layerDevicePixels(layer) * 4) / 1_048_576).toFixed(1) + ), + height: layer.height, + owner: await describeLayerOwner(cdp, layer), + reasons: layer.reasons, + scale: Number(layerScale(layer).x.toFixed(3)), + width: layer.width, + }); + } + const memory = await captureTileMemory(cdp); + return { + backdropFilterLayers: layers.filter((layer) => + layer.reasons.includes('BackdropFilter') + ).length, + clipMasks: findOversizedClipMasks(layers, area).map(describeLayerSize), + drawingLayers: layers.filter((layer) => layer.drawsContent).length, + imageMB: memory.imageMB, + largestLayers, + route, + tileMB: memory.tileMB, + tileResources: memory.resourceCount, + tileWarnings: 0, + totalLayers: layers.length, + url: new URL(page.url()).pathname.replace(/^.*\/dist\/apps\/web/, ''), + }; +} + +test.describe.configure({ mode: 'serial' }); + +test('compositing report', async ({ dataDir, request }) => { + await resetMockServers(request, ['xtream']); + const credentials = { + password: defaultXtreamPassword, + username: defaultXtreamUsername, + }; + const live = await fetchXtreamLiveFixture(request, credentials); + const vod = await fetchXtreamVodFixture(request, credentials); + const series = await fetchXtreamSeriesFixture(request, credentials); + + const app = await launchElectronApp(dataDir, { + env: { ELECTRON_ENABLE_LOGGING: '1' }, + }); + const page = app.mainWindow; + let tileWarnings = 0; + // A chunk can end mid-line: only complete lines are counted, the rest + // waits for the next chunk. + let stderrRemainder = ''; + app.electronApp.process().stderr?.on('data', (chunk: Buffer) => { + const lines = (stderrRemainder + chunk.toString()).split('\n'); + stderrRemainder = lines.pop() ?? ''; + tileWarnings += lines.filter((line) => TILE_WARNING.test(line)).length; + }); + const routes: CompositingRouteReading[] = []; + + try { + await routeDeterministicArtwork(page); + // The same motion on every machine: with the OS set to reduced + // motion the hero neither rotates nor renders its pause button, and + // the crossfade reading would measure a different animation set. + await page.emulateMedia({ reducedMotion: 'no-preference' }); + await setWindowContentSize(app); + await sleep(500); + const cdp = await app.electronApp.context().newCDPSession(page); + let warningsAtLastReading = 0; + const measure = async (route: string, settleMs: number) => { + await sleep(settleMs); + const reading = await readRoute(cdp, page, route); + // One boundary for the delta and the checkpoint, taken after the + // sampling: a warning logged while the layers or the dump were + // read belongs to this route, not to no route. + const warningsNow = tileWarnings; + reading.tileWarnings = warningsNow - warningsAtLastReading; + warningsAtLastReading = warningsNow; + routes.push(reading); + console.log( + `[compositing] ${route}: tile ${reading.tileMB ?? '-'} MB, ${reading.drawingLayers}/${reading.totalLayers} layers, ${reading.clipMasks.length} masks, ${reading.tileWarnings} warnings` + ); + }; + + await addXtreamPortal(page); + await waitForXtreamWorkspaceReady(page); + await openWorkspaceSection(page, 'Live TV'); + await clickCategoryByNameExact(page, live.categoryName); + await toggleFavoriteForChannel(page, getXtreamTitle(live.items[0])); + await measure('live-tv', TILE_POOL_SETTLE_MS); + + await page.getByRole('link', { exact: true, name: 'Movies' }).click(); + await clickCategoryByNameExact(page, vod.categoryName); + await clickFirstGridListCard(page); + await expect( + page.locator('app-content-hero .hero__backdrop-image') + ).toBeVisible({ timeout: 20_000 }); + await measure('movie-detail', TILE_POOL_SETTLE_MS); + await addCurrentDetailToFavorites(page); + await goBackFromDetail(page); + + await page.getByRole('link', { exact: true, name: 'Series' }).click(); + await clickCategoryByNameExact(page, series.categoryName); + await clickFirstGridListCard(page); + await expect(page.locator('app-content-hero')).toBeVisible({ + timeout: 20_000, + }); + await measure('series-detail', TILE_POOL_SETTLE_MS); + await addCurrentDetailToFavorites(page); + + await goToDashboard(page); + await expect(page.getByTestId('dashboard-hero')).toBeVisible({ + timeout: 20_000, + }); + await expect + .poll(() => page.getByTestId('dashboard-hero-dot').count(), { + timeout: 20_000, + }) + .toBeGreaterThanOrEqual(2); + // Idle means no motion: rotation paused (it would advance after 8 s, + // into another slide's zoom), the current slide's zoom finished and + // the tile pool settled. The pointer stays off the hero. + const pause = page.getByTestId('dashboard-hero-pause'); + await pause.click(); + await expect(pause).toHaveAttribute('aria-pressed', 'true'); + await page.mouse.move(1, 1); + await measure('dashboard', HERO_ZOOM_MS + TILE_POOL_SETTLE_MS); + + const dots = page.getByTestId('dashboard-hero-dot'); + const active = await dots.evaluateAll((all) => + all.findIndex((dot) => dot.getAttribute('aria-current') === 'true') + ); + // Deliberately mid-motion: the outgoing and incoming slides are + // both composited while the crossfade and the zoom run. The page's + // animations are frozen at a known point of the 700 ms fade while + // the layers and the memory are read, so the reading does not + // depend on how long the sampling takes. + await dots.nth((active + 1) % (await dots.count())).click(); + await page.mouse.move(1, 1); + await sleep(CROSSFADE_SAMPLE_MS); + await cdp.send('Animation.enable'); + await cdp.send('Animation.setPlaybackRate', { playbackRate: 0 }); + try { + await measure('dashboard-crossfade', 0); + } finally { + await cdp.send('Animation.setPlaybackRate', { playbackRate: 1 }); + await cdp.send('Animation.disable'); + } + // The incoming slide's zoom runs on after the freeze; the scrolled + // reading is taken once it has finished. + await sleep(HERO_ZOOM_MS); + + await page.mouse.move(WINDOW.width / 2, WINDOW.height / 2); + for (let step = 0; step < 8; step += 1) { + await page.mouse.wheel(0, 250); + await sleep(100); + } + await page.mouse.move(1, 1); + await measure('dashboard-scrolled', TILE_POOL_SETTLE_MS); + + await openSettings(page); + await measure('settings', TILE_POOL_SETTLE_MS); + + const summary: CompositingSummary = { + generatedAt: new Date().toISOString(), + harness: { + arch: process.arch, + electronVersion: await app.electronApp.evaluate( + () => process.versions['electron'] ?? 'unknown' + ), + gpuCompositing: await readGpuCompositing(app), + platform: process.platform, + window: { + ...WINDOW, + dpr: await page.evaluate(() => window.devicePixelRatio), + }, + }, + routes, + schemaVersion: COMPOSITING_SUMMARY_SCHEMA_VERSION, + }; + const summaryPath = resolveCompositingSummaryPath(workspaceRoot); + await writeCompositingSummary(summaryPath, summary); + console.log(formatCompositingTable(summary)); + console.log(`[compositing] summary: ${summaryPath}`); + expect(routes.length).toBeGreaterThan(0); + } finally { + await closeElectronApp(app); + } +}); diff --git a/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts b/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts deleted file mode 100644 index 156b51319..000000000 --- a/apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts +++ /dev/null @@ -1,157 +0,0 @@ -import type { CDPSession, Page } from '@playwright/test'; -import { - addXtreamPortal, - closeElectronApp, - expect, - goToDashboard, - launchElectronApp, - resetMockServers, - test, - waitForXtreamWorkspaceReady, -} from './electron-test-fixtures'; - -type CompositedLayer = { - backendNodeId?: number; - drawsContent: boolean; - height: number; - layerId: string; - width: number; -}; - -type CompositedLayerWithReasons = CompositedLayer & { - /** `LayerTree.compositingReasons` ids, e.g. `BackdropFilter`. */ - reasons: string[]; -}; - -/** - * The composited layers of the page with their compositing reasons. Blink - * fills the layer debug info (reasons, owner nodes) in its first - * layerization after the domain is enabled, so the snapshot is retried - * until a layer reports a reason. - */ -async function compositedLayers( - cdp: CDPSession, - page: Page -): Promise { - let layers: CompositedLayer[] = []; - const onChange = (event: { layers?: CompositedLayer[] }) => { - layers = event.layers ?? []; - }; - cdp.on('LayerTree.layerTreeDidChange', onChange); - let withReasons: CompositedLayerWithReasons[] = []; - const snapshot = async () => { - await page.evaluate( - () => - new Promise((resolve) => - requestAnimationFrame(() => requestAnimationFrame(resolve)) - ) - ); - const result: CompositedLayerWithReasons[] = []; - for (const layer of layers) { - let reasons: string[] = []; - try { - const answer = (await cdp.send('LayerTree.compositingReasons', { - layerId: layer.layerId, - })) as { compositingReasonIds?: string[] }; - reasons = answer.compositingReasonIds ?? []; - } catch { - // The layer went away between the snapshot and the query. - } - result.push({ ...layer, reasons }); - } - return result; - }; - try { - await cdp.send('LayerTree.enable'); - await expect - .poll(async () => { - withReasons = await snapshot(); - return withReasons.filter((layer) => layer.reasons.length > 0) - .length; - }) - .toBeGreaterThan(0); - await cdp.send('LayerTree.disable'); - } finally { - cdp.off('LayerTree.layerTreeDidChange', onChange); - } - return withReasons; -} - -test.describe('Dashboard compositing', () => { - // A `border-radius` on the content scroller made Blink clip every - // composited effect inside it (the rail chevrons' and hero controls' - // backdrop filters, running animations) through a synthesized mask layer - // the size of the whole content area: ~24 MB of tile memory each at 2x, - // a dozen on the dashboard, which took the renderer past Chromium's tile - // budget ("tile memory limits exceeded") and left blank tiles while - // scrolling. The corner is painted instead. A mask layer is synthesized, - // not painted for an element: it has no owner node and no compositing - // reason, unlike every content layer (a content layer's owner node can - // also be missing when its first paint chunk belongs to an anonymous - // box, so the node alone does not identify a mask). Blink synthesizes - // them while layerizing, before any GPU work: under `--disable-gpu` (the - // Linux CI launch, software compositing and raster) the controls are - // still composited and the old radius still produced seven masks, so the - // check guards the same path everywhere. - test('does not synthesize content-area-sized clip masks for the composited rail and hero controls', async ({ - dataDir, - request, - }) => { - await resetMockServers(request, ['xtream']); - const app = await launchElectronApp(dataDir); - const page = app.mainWindow; - - try { - await addXtreamPortal(page); - await waitForXtreamWorkspaceReady(page); - await goToDashboard(page); - await expect(page.getByTestId('dashboard-hero')).toBeVisible({ - timeout: 20_000, - }); - await expect( - page.getByTestId('dashboard-xtream-recently-added-rail') - ).toBeVisible({ timeout: 20_000 }); - // The composited effects this guards: every rail has two - // backdrop-filtered chevrons, the hero its controls. - expect(await page.locator('.rail__chev').count()).toBeGreaterThan( - 0 - ); - await expect(page.locator('.hero__controls')).toBeVisible(); - - const content = await page - .locator('main.workspace-content') - .evaluate((element) => ({ - width: element.clientWidth * window.devicePixelRatio, - height: element.clientHeight * window.devicePixelRatio, - })); - const cdp = await app.electronApp.context().newCDPSession(page); - const layers = await compositedLayers(cdp, page); - await cdp.detach(); - - // The effects really are composited here: without this the - // mask check below would pass on a page with nothing to clip. - expect( - layers.filter((layer) => - layer.reasons.includes('BackdropFilter') - ).length - ).toBeGreaterThan(0); - - const contentArea = content.width * content.height; - const clipMasks = layers.filter( - (layer) => - layer.drawsContent && - !layer.backendNodeId && - layer.reasons.length === 0 && - layer.width * layer.height >= contentArea / 2 - ); - expect( - clipMasks.map( - (layer) => - `${layer.layerId}: ${layer.width}x${layer.height}` - ) - ).toEqual([]); - } finally { - await closeElectronApp(app); - } - }); -}); diff --git a/apps/electron-backend-e2e/src/performance/compositing-probe.spec.ts b/apps/electron-backend-e2e/src/performance/compositing-probe.spec.ts new file mode 100644 index 000000000..3f3a0e606 --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/compositing-probe.spec.ts @@ -0,0 +1,208 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + describeLayerSize, + findOversizedClipMasks, + isSynthesizedClipMask, + layerDevicePixels, + layerScale, + readTileMemoryFromTrace, + type CompositedLayerWithReasons, + type TraceEvent, +} from './compositing-probe'; + +function layer( + overrides: Partial = {} +): CompositedLayerWithReasons { + return { + drawsContent: true, + height: 1874, + layerId: '317', + reasons: [], + width: 3340, + ...overrides, + }; +} + +/** A column-major 4x4 matrix scaling x and y. */ +function scaleMatrix(x: number, y: number): number[] { + return [x, 0, 0, 0, 0, y, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; +} + +const contentArea = { dpr: 2, height: 1874, width: 3340 }; + +test('layerScale reads the x and y scale of a column-major transform', () => { + assert.deepEqual(layerScale(layer()), { x: 1, y: 1 }); + assert.deepEqual( + layerScale(layer({ transform: scaleMatrix(0.96, 0.96) })), + { + x: 0.96, + y: 0.96, + } + ); + // A rotation keeps the axis length. + const rotated = layer({ + transform: [0, 1, 0, 0, -1, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], + }); + assert.deepEqual(layerScale(rotated), { x: 1, y: 1 }); +}); + +test('layerDevicePixels applies the transform scale to the bounds', () => { + assert.equal(layerDevicePixels(layer({ height: 10, width: 10 })), 100); + assert.equal( + Math.round( + layerDevicePixels( + layer({ height: 10, transform: scaleMatrix(0.5, 2), width: 10 }) + ) + ), + 100 + ); +}); + +test('a synthesized clip mask draws content without an owner node or a reason', () => { + assert.equal(isSynthesizedClipMask(layer()), true); + assert.equal(isSynthesizedClipMask(layer({ backendNodeId: 42 })), false); + assert.equal(isSynthesizedClipMask(layer({ reasons: ['Overlap'] })), false); + assert.equal(isSynthesizedClipMask(layer({ drawsContent: false })), false); + // A content layer whose first paint chunk belongs to an anonymous box + // has no owner node but keeps its reason. + assert.equal( + isSynthesizedClipMask( + layer({ backendNodeId: undefined, reasons: ['Overlap'] }) + ), + false + ); +}); + +test('findOversizedClipMasks keeps masks of at least half the content area, at their scale', () => { + const chevronMask = layer({ + height: 1955, + layerId: '330', + transform: scaleMatrix(0.96, 0.96), + width: 3482, + }); + const smallMask = layer({ height: 6, layerId: '400', width: 18 }); + const track = layer({ + backendNodeId: 7, + height: 635, + layerId: '324', + reasons: ['Overlap'], + width: 7520, + }); + const nodelessTrack = layer({ + height: 1874, + layerId: '209', + reasons: ['Overlap'], + width: 3340, + }); + const halfMinusOne = layer({ + height: 1874, + layerId: '500', + width: 1669, + }); + const masks = findOversizedClipMasks( + [layer(), chevronMask, smallMask, track, nodelessTrack, halfMinusOne], + contentArea + ); + assert.deepEqual( + masks.map((mask) => mask.layerId), + ['317', '330'] + ); + assert.deepEqual(masks.map(describeLayerSize), [ + '317: 3340x1874', + '330: 3482x1955', + ]); + assert.deepEqual( + findOversizedClipMasks([halfMinusOne], contentArea, 0.4).map( + (mask) => mask.layerId + ), + ['500'] + ); +}); + +function dump( + pid: number, + allocators: Record +): TraceEvent { + const entries = Object.entries(allocators).map(([name, bytes]) => [ + name, + bytes === null + ? {} + : { + attrs: { + size: { units: 'bytes', value: bytes.toString(16) }, + }, + }, + ]); + return { + args: { dumps: { allocators: Object.fromEntries(entries) } }, + pid, + ph: 'v', + }; +} + +const MB = 1_048_576; + +test('readTileMemoryFromTrace reports the renderer dump with the most tile memory', () => { + const reading = readTileMemoryFromTrace([ + { ph: 'X', pid: 1 }, + dump(1, { 'skia/gpu_resources': 200 * MB }), + dump(2, { + 'cc/image_memory': 118 * MB, + 'cc/tile_memory': 537 * MB, + 'cc/tile_memory/provider_1': null, + 'cc/tile_memory/provider_1/resource_1': 24 * MB, + 'cc/tile_memory/provider_1/resource_2': 24 * MB, + 'cc/tile_memory/provider_1/resource_3': 6.5 * MB, + }), + dump(3, { + 'cc/tile_memory': 12 * MB, + 'cc/tile_memory/provider_2/resource_1': 12 * MB, + }), + ]); + assert.deepEqual(reading, { + imageMB: 118, + largestResourcesMB: [24, 24, 6.5], + pid: 2, + resourceCount: 3, + tileMB: 537, + }); +}); + +test('readTileMemoryFromTrace yields nulls without a tile memory dump', () => { + assert.deepEqual( + readTileMemoryFromTrace([dump(1, { 'skia/gpu_resources': 10 * MB })]), + { + imageMB: null, + largestResourcesMB: [], + pid: null, + resourceCount: 0, + tileMB: null, + } + ); + assert.equal(readTileMemoryFromTrace([]).tileMB, null); +}); + +test('readTileMemoryFromTrace ignores sizes that are not byte counts', () => { + const reading = readTileMemoryFromTrace([ + { + args: { + dumps: { + allocators: { + 'cc/tile_memory': { + attrs: { size: { units: 'objects', value: '10' } }, + }, + 'cc/tile_memory/provider_1/resource_1': { + attrs: { size: { units: 'bytes', value: 'zz' } }, + }, + }, + }, + }, + ph: 'v', + pid: 9, + }, + ]); + assert.equal(reading.tileMB, null); + assert.equal(reading.resourceCount, 0); +}); diff --git a/apps/electron-backend-e2e/src/performance/compositing-probe.ts b/apps/electron-backend-e2e/src/performance/compositing-probe.ts new file mode 100644 index 000000000..94674a00c --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/compositing-probe.ts @@ -0,0 +1,381 @@ +import type { CDPSession, Page } from '@playwright/test'; +import { expect } from '@playwright/test'; +import sharp = require('sharp'); + +/** + * Compositing probe: the page's cc layers with their compositing reasons + * and owner nodes (Chrome DevTools Protocol `LayerTree` domain) and the + * renderer's tile memory (`Tracing` with a memory-infra dump). Shared by + * the compositing E2E guard and the `perf:compositing` report. Contract: + * docs/architecture/performance-journeys.md#compositing-budget. + */ + +export interface CompositedLayer { + backendNodeId?: number; + drawsContent: boolean; + height: number; + layerId: string; + paintCount?: number; + /** 4x4 matrix, column major; identity when absent. */ + transform?: number[]; + width: number; +} + +export interface CompositedLayerWithReasons extends CompositedLayer { + /** `LayerTree.compositingReasons` ids, e.g. `BackdropFilter`. */ + reasons: string[]; +} + +/** The clipped area a mask would have to cover, in device pixels. */ +export interface ContentArea { + dpr: number; + height: number; + width: number; +} + +export interface TileMemoryReading { + /** Decoded image memory held by cc (`cc/image_memory`). */ + imageMB: number | null; + /** The largest tile resources, in MB, largest first. */ + largestResourcesMB: number[]; + pid: number | null; + /** Number of tile resources in the renderer's resource pool. */ + resourceCount: number; + /** `cc/tile_memory` of the renderer process, null when no dump had it. */ + tileMB: number | null; +} + +/** + * The resource pool keeps a freed tile for reuse for a few seconds; a + * reading taken sooner after a change still counts the old tiles. + */ +export const TILE_POOL_SETTLE_MS = 6_500; + +/** A layer's x and y scale from its transform (identity when absent). */ +export function layerScale(layer: CompositedLayer): { x: number; y: number } { + const matrix = layer.transform; + if (!matrix || matrix.length < 6) { + return { x: 1, y: 1 }; + } + return { + x: Math.hypot(matrix[0], matrix[1]), + y: Math.hypot(matrix[4], matrix[5]), + }; +} + +/** The device pixels a layer rasterizes at its current scale. */ +export function layerDevicePixels(layer: CompositedLayer): number { + const scale = layerScale(layer); + return layer.width * scale.x * layer.height * scale.y; +} + +/** + * A clip mask Blink synthesized for a rounded clip (or a clip path) around + * a composited effect. It is not painted for any element, so it has no + * owner node and no compositing reason; every content layer carries at + * least one reason, and a content layer can lack an owner node when its + * first paint chunk belongs to an anonymous box, so the node alone does + * not identify a mask. + */ +export function isSynthesizedClipMask( + layer: CompositedLayerWithReasons +): boolean { + return ( + layer.drawsContent && !layer.backendNodeId && layer.reasons.length === 0 + ); +} + +/** + * Synthesized clip masks at least `ratio` of the content area: a rounded + * scroller gives every composited effect inside it one of these, each the + * size of the whole clipped area. + */ +export function findOversizedClipMasks( + layers: readonly CompositedLayerWithReasons[], + area: ContentArea, + ratio = 0.5 +): CompositedLayerWithReasons[] { + const threshold = area.width * area.height * ratio; + return layers.filter( + (layer) => + isSynthesizedClipMask(layer) && + layerDevicePixels(layer) >= threshold + ); +} + +export function describeLayerSize(layer: CompositedLayer): string { + return `${layer.layerId}: ${layer.width}x${layer.height}`; +} + +/** The content scroller's client area in device pixels. */ +export async function measureContentArea( + page: Page, + selector = 'main.workspace-content' +): Promise { + return page.locator(selector).evaluate((element) => ({ + dpr: window.devicePixelRatio, + height: element.clientHeight * window.devicePixelRatio, + width: element.clientWidth * window.devicePixelRatio, + })); +} + +/** + * The composited layers of the page with their compositing reasons. Blink + * fills the layer debug info (reasons, owner nodes) in its first + * layerization after the domain is enabled, so the snapshot is retried + * until a layer reports a reason. Layer bounds are in device pixels. + */ +export async function collectCompositedLayers( + cdp: CDPSession, + page: Page +): Promise { + let layers: CompositedLayer[] = []; + const onChange = (event: { layers?: CompositedLayer[] }) => { + layers = event.layers ?? []; + }; + cdp.on('LayerTree.layerTreeDidChange', onChange); + let withReasons: CompositedLayerWithReasons[] = []; + /** + * Reads every layer's reasons; null when a layer went away between the + * snapshot and its query (the tree changed under us). A vanished layer + * must not be kept with empty reasons: without an owner node it would + * pass for a synthesized mask. + */ + const snapshot = async (): Promise => { + await page.evaluate( + () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(resolve)) + ) + ); + const result: CompositedLayerWithReasons[] = []; + for (const layer of layers) { + try { + const answer = (await cdp.send('LayerTree.compositingReasons', { + layerId: layer.layerId, + })) as { compositingReasonIds?: string[] }; + result.push({ + ...layer, + reasons: answer.compositingReasonIds ?? [], + }); + } catch { + return null; + } + } + return result; + }; + try { + await cdp.send('LayerTree.enable'); + await expect + .poll(async () => { + const sample = await snapshot(); + withReasons = sample ?? []; + return withReasons.filter((layer) => layer.reasons.length > 0) + .length; + }) + .toBeGreaterThan(0); + await cdp.send('LayerTree.disable'); + } finally { + cdp.off('LayerTree.layerTreeDidChange', onChange); + } + return withReasons; +} + +/** + * `tag#id.class` of the layer's owner node, or `(no owner node)`: a + * synthesized mask has none, and so has a content layer whose first paint + * chunk belongs to an anonymous box. + */ +export async function describeLayerOwner( + cdp: CDPSession, + layer: CompositedLayer +): Promise { + if (!layer.backendNodeId) { + return '(no owner node)'; + } + try { + const described = (await cdp.send('DOM.describeNode', { + backendNodeId: layer.backendNodeId, + })) as { node: { attributes?: string[]; nodeName: string } }; + const attributes = described.node.attributes ?? []; + const idIndex = attributes.indexOf('id'); + const classIndex = attributes.indexOf('class'); + const id = idIndex >= 0 ? `#${attributes[idIndex + 1]}` : ''; + const classes = + classIndex >= 0 + ? attributes[classIndex + 1] + .trim() + .split(/\s+/) + .filter((name) => name && !name.startsWith('ng-')) + .map((name) => `.${name}`) + .join('') + : ''; + return `${described.node.nodeName.toLowerCase()}${id}${classes}`; + } catch { + return `backendNodeId:${layer.backendNodeId}`; + } +} + +interface TraceAllocator { + attrs?: Record; +} + +export interface TraceEvent { + args?: { dumps?: { allocators?: Record } }; + ph?: string; + pid?: number; +} + +function allocatorMB(allocator: TraceAllocator | undefined): number | null { + const size = allocator?.attrs?.['size']; + if (!size || size.units !== 'bytes' || typeof size.value !== 'string') { + return null; + } + const bytes = Number.parseInt(size.value, 16); + return Number.isFinite(bytes) + ? Number((bytes / 1_048_576).toFixed(1)) + : null; +} + +/** + * The renderer's tile memory from memory-infra dump events (`ph: "v"`). + * `cc/tile_memory` is the resource pool's total; its children + * `cc/tile_memory/provider_N/resource_M` are the tiles. Of several + * processes with tile memory the largest is reported. + */ +export function readTileMemoryFromTrace( + events: readonly TraceEvent[] +): TileMemoryReading { + let best: TileMemoryReading = { + imageMB: null, + largestResourcesMB: [], + pid: null, + resourceCount: 0, + tileMB: null, + }; + for (const event of events) { + const allocators = event.args?.dumps?.allocators; + if (event.ph !== 'v' || !allocators?.['cc/tile_memory']) { + continue; + } + const resources: number[] = []; + for (const [name, allocator] of Object.entries(allocators)) { + const parts = name.split('/'); + const isTile = + parts.length === 4 && + parts[0] === 'cc' && + parts[1] === 'tile_memory'; + const size = isTile ? allocatorMB(allocator) : null; + if (size !== null) { + resources.push(size); + } + } + const reading: TileMemoryReading = { + imageMB: allocatorMB(allocators['cc/image_memory']), + largestResourcesMB: resources.sort((a, b) => b - a).slice(0, 6), + pid: event.pid ?? null, + resourceCount: resources.length, + tileMB: allocatorMB(allocators['cc/tile_memory']), + }; + if ((reading.tileMB ?? 0) > (best.tileMB ?? -1)) { + best = reading; + } + } + return best; +} + +async function captureTileMemoryOnce( + cdp: CDPSession +): Promise { + const events: TraceEvent[] = []; + const onData = (event: { value?: TraceEvent[] }) => { + events.push(...(event.value ?? [])); + }; + cdp.on('Tracing.dataCollected', onData); + const complete = new Promise((resolve) => + cdp.once('Tracing.tracingComplete', () => resolve()) + ); + try { + await cdp.send('Tracing.start', { + traceConfig: { + includedCategories: ['disabled-by-default-memory-infra'], + recordMode: 'recordContinuously', + }, + transferMode: 'ReportEvents', + }); + await cdp.send('Tracing.requestMemoryDump', { + levelOfDetail: 'detailed', + }); + // The dump's trace events reach the buffer shortly after the + // request resolves; ending the trace at once can miss them. + await new Promise((resolve) => setTimeout(resolve, 250)); + await cdp.send('Tracing.end'); + await complete; + } finally { + cdp.off('Tracing.dataCollected', onData); + } + return readTileMemoryFromTrace(events); +} + +/** + * One detailed memory-infra dump of the page's renderer. A dump that + * carries no renderer tile memory (a process can skip a dump it is busy + * for) is retried a few times before the reading stays null. + */ +export async function captureTileMemory( + cdp: CDPSession, + attempts = 3 +): Promise { + let reading = await captureTileMemoryOnce(cdp); + for (let attempt = 1; attempt < attempts && reading.tileMB === null;) { + reading = await captureTileMemoryOnce(cdp); + attempt += 1; + } + return reading; +} + +const artworkCache = new Map(); + +/** + * Serves every picsum.photos request of the mock catalogs from memory: an + * opaque gradient of the requested size, so the probe never leaves the + * machine and every image loads (tile and image memory depend on an image + * being there, not on what it shows). + */ +export async function routeDeterministicArtwork(page: Page): Promise { + await page.route( + (url) => url.hostname === 'picsum.photos', + async (route) => { + const match = new URL(route.request().url()).pathname.match( + /\/seed\/([^/]+)\/(\d+)\/(\d+)/ + ); + if (!match) { + await route.fulfill({ body: '', status: 404 }); + return; + } + const [, seed, width, height] = match; + const key = `${seed}-${width}x${height}`; + let body = artworkCache.get(key); + if (body === undefined) { + let hash = 0; + for (const char of seed) { + hash = (hash * 31 + char.charCodeAt(0)) >>> 0; + } + const r = 60 + (hash % 160); + const g = 60 + ((hash >> 8) % 160); + const b = 60 + ((hash >> 16) % 160); + const svg = ``; + const generated: Buffer = await sharp(Buffer.from(svg)) + .png() + .toBuffer(); + artworkCache.set(key, generated); + body = generated; + } + await route.fulfill({ + body, + contentType: 'image/png', + status: 200, + }); + } + ); +} diff --git a/apps/electron-backend-e2e/src/performance/compositing-report.spec.ts b/apps/electron-backend-e2e/src/performance/compositing-report.spec.ts new file mode 100644 index 000000000..1ff5897db --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/compositing-report.spec.ts @@ -0,0 +1,118 @@ +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import test from 'node:test'; + +import { + COMPOSITING_SUMMARY_SCHEMA_VERSION, + formatCompositingTable, + resolveCompositingSummaryPath, + writeCompositingSummary, + type CompositingSummary, +} from './compositing-report'; + +function summary(): CompositingSummary { + return { + generatedAt: '2026-10-09T10:00:00.000Z', + harness: { + arch: 'arm64', + electronVersion: '43.3.0', + gpuCompositing: 'enabled', + platform: 'darwin', + window: { dpr: 2, height: 1000, width: 1600 }, + }, + routes: [ + { + backdropFilterLayers: 9, + clipMasks: [], + drawingLayers: 35, + imageMB: 117.4, + largestLayers: [ + { + devicePixelsMB: 38.9, + height: 3060, + owner: 'main.workspace-content', + reasons: ['Overlap'], + scale: 1, + width: 3336, + }, + ], + route: 'dashboard', + tileMB: 241.6, + tileResources: 51, + tileWarnings: 0, + totalLayers: 43, + url: '/workspace/dashboard', + }, + { + backdropFilterLayers: 0, + clipMasks: ['330: 3482x1955'], + drawingLayers: 46, + imageMB: null, + largestLayers: [], + route: 'settings', + tileMB: null, + tileResources: 0, + tileWarnings: 7, + totalLayers: 54, + url: '/workspace/settings', + }, + ], + schemaVersion: COMPOSITING_SUMMARY_SCHEMA_VERSION, + }; +} + +test('resolveCompositingSummaryPath puts one run under dist/performance/compositing', () => { + assert.equal( + resolveCompositingSummaryPath( + '/repo', + new Date('2026-10-09T10:11:12.345Z') + ), + join( + '/repo', + 'dist', + 'performance', + 'compositing', + '20261009T101112Z', + 'summary.json' + ) + ); +}); + +test('writeCompositingSummary writes the JSON once and never overwrites it', async () => { + const directory = await mkdtemp(join(tmpdir(), 'compositing-summary-')); + try { + const path = join(directory, 'nested', 'summary.json'); + await writeCompositingSummary(path, summary()); + assert.deepEqual(JSON.parse(await readFile(path, 'utf8')), summary()); + await assert.rejects(writeCompositingSummary(path, summary()), { + code: 'EEXIST', + }); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test('formatCompositingTable lists every route with its memory, masks and warnings', () => { + const table = formatCompositingTable(summary()); + const lines = table.split('\n'); + assert.match( + lines[0], + /1600x1000 @2x, gpu compositing enabled, Electron 43\.3\.0/ + ); + assert.match( + lines[1], + /route\s+tile MB\s+image MB\s+layers\s+backdrop\s+masks\s+warn/ + ); + const dashboard = lines.find((line) => line.startsWith('dashboard')); + assert.ok(dashboard); + assert.match(dashboard, /241\.6\s+117\.4\s+35\/43\s+9\s+0\s+0$/); + assert.match( + lines[lines.indexOf(dashboard) + 1], + /38\.9 MB 3336x3060 s=1 main\.workspace-content \[Overlap\]/ + ); + const settings = lines.find((line) => line.startsWith('settings')); + assert.ok(settings); + assert.match(settings, /-\s+-\s+46\/54\s+0\s+1\s+7$/); +}); diff --git a/apps/electron-backend-e2e/src/performance/compositing-report.ts b/apps/electron-backend-e2e/src/performance/compositing-report.ts new file mode 100644 index 000000000..692a978cd --- /dev/null +++ b/apps/electron-backend-e2e/src/performance/compositing-report.ts @@ -0,0 +1,103 @@ +import { mkdir, writeFile } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +import { formatJourneyOutputTimestamp } from './journey-summary'; + +/** + * Summary written by `pnpm run perf:compositing` + * (`src/compositing/compositing.report.ts`): one reading per route, with + * the renderer's tile memory and the largest composited layers. Contract: + * docs/architecture/performance-journeys.md#compositing-budget. + */ +export const COMPOSITING_SUMMARY_SCHEMA_VERSION = 1; + +export interface CompositingLayerSummary { + /** Device pixels the layer rasterizes at its scale, in MB of RGBA. */ + devicePixelsMB: number; + height: number; + /** `tag#id.class` of the owner node, or `(no owner node)`. */ + owner: string; + reasons: string[]; + scale: number; + width: number; +} + +export interface CompositingRouteReading { + /** Composited layers whose reasons include `BackdropFilter`. */ + backdropFilterLayers: number; + /** Synthesized clip masks at least half the content area (`id: WxH`). */ + clipMasks: string[]; + drawingLayers: number; + imageMB: number | null; + largestLayers: CompositingLayerSummary[]; + route: string; + tileMB: number | null; + tileResources: number; + /** "tile memory limits exceeded" lines on stderr while on the route. */ + tileWarnings: number; + totalLayers: number; + url: string; +} + +export interface CompositingSummary { + generatedAt: string; + harness: { + arch: string; + electronVersion: string; + /** `gpu_compositing` from `app.getGPUFeatureStatus()`. */ + gpuCompositing: string; + platform: string; + window: { dpr: number; height: number; width: number }; + }; + routes: CompositingRouteReading[]; + schemaVersion: number; +} + +export function resolveCompositingSummaryPath( + repositoryRoot: string, + date: Date = new Date() +): string { + return join( + repositoryRoot, + 'dist', + 'performance', + 'compositing', + formatJourneyOutputTimestamp(date), + 'summary.json' + ); +} + +/** Writes the summary; an existing file is never overwritten. */ +export async function writeCompositingSummary( + summaryPath: string, + summary: CompositingSummary +): Promise { + await mkdir(dirname(summaryPath), { recursive: true }); + await writeFile(summaryPath, `${JSON.stringify(summary, null, 2)}\n`, { + flag: 'wx', + }); +} + +function pad(value: string | number, width: number): string { + return String(value).padStart(width); +} + +/** A fixed-width table of the routes for the console. */ +export function formatCompositingTable(summary: CompositingSummary): string { + const { window } = summary.harness; + const lines = [ + `compositing budget: ${window.width}x${window.height} @${window.dpr}x, gpu compositing ${summary.harness.gpuCompositing}, Electron ${summary.harness.electronVersion}`, + `${'route'.padEnd(20)} ${pad('tile MB', 8)} ${pad('image MB', 9)} ${pad('layers', 7)} ${pad('backdrop', 9)} ${pad('masks', 6)} ${pad('warn', 5)}`, + ]; + for (const route of summary.routes) { + lines.push( + `${route.route.padEnd(20)} ${pad(route.tileMB ?? '-', 8)} ${pad(route.imageMB ?? '-', 9)} ${pad(`${route.drawingLayers}/${route.totalLayers}`, 7)} ${pad(route.backdropFilterLayers, 9)} ${pad(route.clipMasks.length, 6)} ${pad(route.tileWarnings, 5)}` + ); + for (const layer of route.largestLayers) { + lines.push( + ` ${pad(layer.devicePixelsMB.toFixed(1), 7)} MB ${layer.width}x${layer.height} s=${layer.scale} ${layer.owner} [${layer.reasons.join(',')}]` + ); + } + } + return lines.join('\n'); +} diff --git a/apps/xtream-mock-server/src/app/project-config.spec.ts b/apps/xtream-mock-server/src/app/project-config.spec.ts index 740a1ee5c..798989e6a 100644 --- a/apps/xtream-mock-server/src/app/project-config.spec.ts +++ b/apps/xtream-mock-server/src/app/project-config.spec.ts @@ -30,6 +30,9 @@ describe('Xtream mock Nx serve environment', () => { describe('Playwright mock-server launch', () => { const appsDirectory = join(process.cwd(), 'apps'); const expectedMockLaunches: Record = { + 'apps/electron-backend-e2e/playwright.compositing.config.ts': [ + 'xtream', + ], 'apps/electron-backend-e2e/playwright.config.ts': ['stalker', 'xtream'], 'apps/electron-backend-e2e/playwright.journeys.config.ts': ['xtream'], 'apps/electron-backend-e2e/playwright.xtream-performance.config.ts': [ diff --git a/docs/architecture/performance-journeys.md b/docs/architecture/performance-journeys.md index 4312a6aae..34ddc0661 100644 --- a/docs/architecture/performance-journeys.md +++ b/docs/architecture/performance-journeys.md @@ -1392,6 +1392,68 @@ threshold on Node 22 and inside Electron 43, so D2 regex prefilters were not applied. Rerun the benchmark after changing either parser or when a user reports slow imports of non-Latin playlists. +## Compositing budget + +Chromium rasterizes every composited layer into tiles and gives a desktop +renderer 512 MB of tile memory (1 GB on a display at least 3500 device pixels +wide). Tiles needed for the visible frame that do not fit log +`cc/tiles/tile_manager.cc: WARNING: tile memory limits exceeded, some content +may not draw` and leave blank tiles while scrolling. On a 2x display a layer +the size of the content area costs about 24 MB, so a dozen full-size layers +spend the budget. The expensive layers are rarely the obvious ones: a +`border-radius` on a scroller makes Blink clip every composited effect inside +it (`backdrop-filter`, `filter` with a render surface, a running transform or +opacity animation, `isolation: isolate`, `will-change`) through a mask layer +synthesized per effect, each the size of the whole clipped area, because its +shader path for rounded clips needs four equal radii on macOS and a +translation-only transform between the clip and the effect +(`PropertyTreeManager::ShaderBasedRRect`). The workspace shell therefore +paints its content corner instead of clipping it +([workspace shell](workspace-shell.md#content-surface-corner)). + +Two instruments share `src/performance/compositing-probe.ts`, which reads +the layer tree over the Chrome DevTools Protocol (`LayerTree`: bounds in +device pixels, compositing reasons and owner nodes, available only after +Blink's first layerization with the domain enabled) and the renderer's +`cc/tile_memory` from a memory-infra dump (`Tracing.requestMemoryDump`). A +synthesized clip mask is a drawing layer with no owner node and no +compositing reason; a content layer always carries a reason, and may lack an +owner node when its first paint chunk belongs to an anonymous box. + +- `src/compositing.e2e.ts` is the deterministic guard, run with the Electron + E2E suite on every platform: on Live TV, a movie detail, a series detail + and the dashboard no synthesized mask may span half the content area, and + the backdrop-filtered controls must really be composited layers, so the + check cannot pass on a page with nothing to clip. Blink synthesizes the + masks before any GPU work; under `--disable-gpu` (the Linux CI launch) the + old scroller radius still produced seven. +- `pnpm run perf:compositing` (`electron-backend-e2e:compositing-report`, + `playwright.compositing.config.ts`, Xtream mock on `127.0.0.1:3233`, + override with `IPTVNATOR_COMPOSITING_XTREAM_MOCK_PORT`) measures. One fresh + profile is seeded through the app's dialogs with a live, a movie and a + series favourite, artwork is served from memory, and the window is 1600x1000 + CSS pixels. For Live TV, the movie and series details, the dashboard (idle, + during a crossfade, scrolled) and settings it records tile and image + memory, composited layer counts, the eight largest layers with their + owners and reasons, synthesized masks and the `tile memory limits + exceeded` lines on stderr, into + `dist/performance/compositing//summary.json` and a table on + the console. The pool keeps freed tiles for a few seconds, so each reading + waits `TILE_POOL_SETTLE_MS` after its route settles. + +Megabytes depend on the window size, the device scale factor and the GPU +path, so they are evidence for a PR, not a ratchet: the Linux runner at 1x +cannot reproduce a 2x laptop. Layer and mask counts on the fixed mock are +comparable across machines; wiring them into the [ratchet](#ratchet) needs a +baseline measured on the canonical runner first. A PR that adds or changes +`backdrop-filter`, `filter`, `will-change`, `isolation`, a transform or +opacity animation, a `mask` or `clip-path`, or a `border-radius` on an +element with `overflow`, runs the report before and after and quotes the +affected routes. Reference, 2026-10-09 on a 16" MacBook Pro at 2x before and +after the painted corner: dashboard idle 537 to 242 MB, during a slide switch +598 to 303 MB, scrolled 655 to 165 MB; movie detail 110 to 89 MB; series +detail 187 to 143 MB. + ## Adding a counter 1. Produce the value from the built output or from a deterministic probe, not diff --git a/docs/architecture/validation-map.md b/docs/architecture/validation-map.md index 4ee3ee303..4d8520253 100644 --- a/docs/architecture/validation-map.md +++ b/docs/architecture/validation-map.md @@ -277,6 +277,7 @@ pnpm run perf:initial-bytes # breakdown only pnpm run perf:initial-bytes:check # measure, then compare with the committed baseline pnpm nx test performance-tools pnpm run perf:journeys # J1 launch, J2 open-source and J3 playback journeys, one dist/performance/journeys//summary.json +pnpm run perf:compositing # tile memory and composited layers per route, dist/performance/compositing//summary.json ``` `perf:initial-bytes` reads the built `dist/apps/web/index.html` and sums the @@ -306,7 +307,10 @@ not through `pnpm exec`: under `pnpm nx`, a nested `pnpm exec` can run from the workspace root instead of the target `cwd` and miss cwd-relative specs, globs and configs. The contract, what counts and how to add a counter or a journey are in the -[performance journeys](performance-journeys.md) document. +[performance journeys](performance-journeys.md) document. `perf:compositing` +measures tile memory and composited layers on the artwork-heavy routes; the +deterministic guard is `compositing.e2e.ts` in the Electron E2E suite +([compositing budget](performance-journeys.md#compositing-budget)). ## Logging diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 88045070a..e7f7239b5 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -121,7 +121,7 @@ translation-only transform between the clip and the effect, and the fallback mask is the size of the whole clipped area, about 24 MB of tile memory at 2x on a 16" display. A dozen of them took the dashboard past Chromium's 512 MB tile budget (`tile memory limits exceeded`, blank tiles while scrolling). -`apps/electron-backend-e2e/src/dashboard-compositing.e2e.ts` guards it: no +`apps/electron-backend-e2e/src/compositing.e2e.ts` guards it: no drawing layer without an owner node and without a compositing reason (a synthesized mask, unlike any content layer) may span half the content area, and the backdrop-filtered controls must really be composited, so the check diff --git a/docs/maintenance/agent-context-map.md b/docs/maintenance/agent-context-map.md index 41b6ba28c..6dd6bc085 100644 --- a/docs/maintenance/agent-context-map.md +++ b/docs/maintenance/agent-context-map.md @@ -38,6 +38,7 @@ are not prerequisites for reading repository contracts. | Web engines, controls, tracks, PiP, radio and display sleep; `libs/ui/playback` | [Player controls](../architecture/player-controls-contract.md), [inline playback/diagnostics/recovery](../architecture/embedded-inline-playback.md) | Read the contract directly | | Embedded MPV, platform engines, addon, pinned runtime and packaging; Electron native services, `tools/embedded-mpv` | [Native MPV](../architecture/embedded-mpv-native.md), [runtime build and licensing](../../tools/embedded-mpv/README.md) | Read the contract directly | | Live panels, keyboard focus, grid/layout conventions; shared UI and portal views | [UI guidelines](../architecture/iptvnator-ui-guidelines.md), [detail navigation](../architecture/portal-detail-navigation.md) | [UI design](../../.codex/skills/iptvnator-ui-design/SKILL.md), [theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) | +| Rendering cost of UI: `backdrop-filter`, `filter`, `will-change`, `isolation`, transform/opacity animations, rounded or path-clipped scrollers; tile memory | [Compositing budget](../architecture/performance-journeys.md#compositing-budget), [workspace shell](../architecture/workspace-shell.md#content-surface-corner) | [Theme/style](../../.codex/skills/iptvnator-theme-style/SKILL.md) | | Workspace routes, title bar, switcher, collections and dashboard; `libs/workspace` | [Workspace shell](../architecture/workspace-shell.md), [dashboard](../architecture/workspace-dashboard.md), [collection/detail navigation](../architecture/portal-detail-navigation.md) | UI/theme skills for visible changes | | Remote control, playback queue, channel return and shortcuts; `libs/ui/remote-control`, `apps/remote-control-web` | [Remote control](../architecture/remote-control.md) | Provider skill when queue ownership changes | | Parental lock: PIN, per-category locks, worker-side filtering; `libs/services/src/lib/parental-lock`, category/group dialogs, `apps/electron-backend/src/app/database/parental-lock-state.ts` | [Parental lock](../architecture/parental-lock.md), affected provider contract | Read the affected provider skill | diff --git a/package.json b/package.json index f7698f44c..710f87f85 100644 --- a/package.json +++ b/package.json @@ -88,6 +88,7 @@ "perf:initial-bytes": "node tools/performance/measure-initial-bytes.mjs", "perf:initial-bytes:check": "node tools/performance/measure-initial-bytes.mjs --summary dist/performance/initial-bytes.summary.json && node tools/performance/check-journey-ratchet.mjs --summary dist/performance/initial-bytes.summary.json --only launch/renderer.initialBytes", "perf:journeys": "nx run electron-backend-e2e:journeys", + "perf:compositing": "nx run electron-backend-e2e:compositing-report", "perf:ratchet:check": "node tools/performance/check-journey-ratchet.mjs --summary dist/performance/journey-summary.json", "perf:tools:test": "node --test tools/performance/measure-initial-bytes.test.mjs tools/performance/check-journey-ratchet.test.mjs tools/performance/check-baseline-direction.test.mjs tools/performance/tighten-baselines.test.mjs", "agents:validate": "node tools/skills/validate-agent-guidance.mjs",