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
32 changes: 32 additions & 0 deletions .github/workflows/dashapp-perf.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
name: dashapp-perf
on:
pull_request:
paths:
- 'dash-apps/**'
- '.github/workflows/dashapp-perf.yml'
permissions:
contents: read
jobs:
gate:
runs-on: ubuntu-latest
timeout-minutes: 5
strategy:
fail-fast: false
matrix:
include:
- { app: web-retro, root: '' }
- { app: web-analog, root: '' }
- { app: web-ambient, root: '' }
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: npm
cache-dependency-path: dash-apps/_perf-gate/package.json
- run: npm ci
working-directory: dash-apps/_perf-gate
- run: node gate.js "../${{ matrix.app }}${{ matrix.root && format('/{0}', matrix.root) || '' }}" --name "${{ matrix.app }}"
working-directory: dash-apps/_perf-gate
env:
GITHUB_STEP_SUMMARY: ${{ github.step_summary }}
60 changes: 60 additions & 0 deletions dash-apps/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,63 @@

Dash apps are the sandboxed applications running inside the Dashpilot android app.
They can be either web apps running in the `WebDashView` Composable, or Rive apps running in `RiveDashView`.

## Performance requirements

Dash-apps render live driving data - even one second of stale speed is unacceptable. Web
dash-apps are benchmarked by an automated performance gate before merging.

**Which apps are gated** is declared in the CI matrix at
[`.github/workflows/dashapp-perf.yml`](../.github/workflows/dashapp-perf.yml) — the matrix
is the single source of truth. Currently gated: web-retro, web-analog, web-ambient. Not
gated: web-vanilla (WASM/Bevy 3D renderer — invisible to the jsdom tripwire) and web-expo
(React Native's bridge — not the `window.onCarStateUpdate` contract the gate replays). Adding
a new dash-app to the matrix is a one-line edit in the workflow file.

**The gate:** your app must define `window.onCarStateUpdate` and read values from `window.NativeCarState`. CI replays a deterministic 6 s drive (150 frames at 25 Hz) in
jsdom (no browser) and enforces one per-tick budget documented inline in
[`_perf-gate/gate.js`](./_perf-gate/gate.js). The gate fails when a budget is broken **three
ticks in a row** (after a 2-second warmup):

| budget | default | catches |
|---|---|---|
| child updates per parent | `max 6` | per-child update loops — apps that write attributes/text to many siblings sharing one parent every tick (the actual web-retro regression: `tape.cells.forEach(c => c.className = next)` = 8 hits on one parent); in-place single-element updates cost 1-2 |

The gate is a regression **tripwire**, not a device-fidelity measurement: it budgets the
DOM-mutation churn of each `onCarStateUpdate` tick in Node. Layout,
paint, and canvas work are invisible — a canvas- or CSS-animation-heavy regression will
pass, and the budget is an absolute jsdom record count, not head-unit time. The verdict is a
consecutive-streak (3-in-a-row) check on per-parent mutation concentration, so a single isolated stall
(one multi-hundred-mutation tick in an otherwise-clean run) does not fail the gate —
deliberately no per-tick max, because a lone tick preempted by a busy CI runner would make
the gate flaky. The streak is stricter than a p95 on clustered regressions (three bad
ticks back-to-back trips immediately) and looser on scattered spikes. It
exists to catch the failure mode that actually occurred: per-child update loops rewriting attributes on every sibling, every tick (e.g. web-retro's blind-spot tape).

**What this gate does not catch:** a wholesale `el.innerHTML = "..."` rebuild fires ONE batched MutationObserver record regardless of child count, so neither per-parent concentration nor a total record count sees it. If a wholesale-rebuild regression ever occurs, count `addedNodes.length + removedNodes.length` inside childList records — out of scope until it shows up. Also invisible: synchronous CPU cost (the previous CPU budget was removed — only a synthetic fixture ever tripped it).

`window.NativeCarState` is injected as a `Proxy` whose methods read a shared state object
the gate mutates per tick. State keys are derived from method name by convention
(`getEgoSpeed`→`egoSpeed`, `isAdasOn`→`adasOn`); the one exception (`isImperial`→`useImperial`)
lives in `gate.js`. Apps reading an unknown method get `undefined`, so their own `readNative`
fallback runs. No getter list to keep in sync with `CarStateBridge.kt` — the gate drifts with
the Kotlin naming convention automatically.

## Run the gate locally

```bash
cd dash-apps/_perf-gate
npm ci
node gate.js ../web-yourapp
```

> Exit codes: `0` PASS, `1` budget violation (a `why:` line names the failing metric),
> `2` APP_ERROR (missing `window.onCarStateUpdate` contract within 10 s, or an uncaught
> error the app tried to swallow), `3` INFRA (bad args / no `index.html`).

## Tuning map

- Change pass/fail thresholds: edit the `BUDGET_PER_PARENT` constant near the top of [`_perf-gate/gate.js`](./_perf-gate/gate.js) (it has a rationale comment).
- Change the simulated drive data: edit `buildScenario()` in [`_perf-gate/gate.js`](./_perf-gate/gate.js).
- Change `NativeCarState` injection or the key-derivation special cases: edit `keyFromMethod()` / `beforeParse` in [`_perf-gate/gate.js`](./_perf-gate/gate.js).
- Change the CI matrix (which apps run on every PR): edit `matrix.include` in [`.github/workflows/dashapp-perf.yml`](../.github/workflows/dashapp-perf.yml).
1 change: 1 addition & 0 deletions dash-apps/_perf-gate/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
node_modules
18 changes: 18 additions & 0 deletions dash-apps/_perf-gate/fixtures/churn-app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<!DOCTYPE html>
<html><body>
<div id="tape"></div>
<script>
const tape = document.getElementById("tape");
const cells = [];
for (let i = 0; i < 8; i++) {
const c = document.createElement("div");
c.className = "cell";
tape.appendChild(c);
cells.push(c);
}
window.onCarStateUpdate = function () {
const next = "cell " + (window.NativeCarState.getLeftBlinker() ? "on" : "off");
cells.forEach((c) => { c.className = next; });
};
</script>
</body></html>
13 changes: 13 additions & 0 deletions dash-apps/_perf-gate/fixtures/fast-app/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html><body>
<div id="speed" style="font-size:48px">0</div>
<div id="blinker"></div>
<script>
const speedEl = document.getElementById("speed");
const blinkerEl = document.getElementById("blinker");
window.onCarStateUpdate = function () {
speedEl.textContent = Math.round(window.NativeCarState.getEgoSpeed());
blinkerEl.textContent = window.NativeCarState.getLeftBlinker() ? "◀" : "";
};
</script>
</body></html>
114 changes: 114 additions & 0 deletions dash-apps/_perf-gate/gate.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
"use strict";

const fs = require("node:fs");
const path = require("node:path");
const { parseArgs } = require("node:util");
const { JSDOM } = require("jsdom");

// 25 Hz drive cadence; 150 frames = 6 s wall, fast for CI; first 50 (2 s) are warmup.
const STEP_MS = 40, TOTAL_FRAMES = 150, WARMUP_FRAMES = 50;
// Per-parent concentration: 8 sibling attribute writes (per-cell loop, the web-retro regression) = 8 attributed to one parent. in-place single-element updates = 1-2. Threshold 6 sits between.
const BUDGET_PER_PARENT = 6, STREAK = 3;
const CONTRACT_TIMEOUT_MS = 10000;

// derives key by convention; add a special case here only when Kotlin breaks the get/is naming pattern.
const keyFromMethod = (m) => m === "isImperial" ? "useImperial" : m.startsWith("get") ? m[3].toLowerCase() + m.slice(4) : m.startsWith("is") ? m[2].toLowerCase() + m.slice(3) : null;

// minimal scenario — oscillate speed, toggle blinkers, flip ADAS once. Render path runs every tick regardless of input variety; full signal sweep is not needed for a tripwire.
const buildScenario = () => Array.from({ length: TOTAL_FRAMES }, (_, i) => {
const t = (i * STEP_MS) / 1000;
return {
egoSpeed: 60 * (1 - Math.cos(2 * Math.PI * t / 13)),
leftBlinker: t % 4 < 1 ? 1 : 0, rightBlinker: (t + 2) % 4 < 1 ? 1 : 0,
gear: 4, adasOn: t >= 4 ? 1 : 0, madsActive: t >= 5 && t < 10 ? 1 : 0,
leftBlindSpot: t % 5 < 1 ? 1 : 0, rightBlindSpot: (t + 2.5) % 5 < 1 ? 1 : 0,
accSetSpeed: t >= 4 ? (Math.floor((t - 4) / 3) % 2 === 0 ? 90 : 110) : 0,
fusedSpeedLimit: t < 8 ? 50 : 100, useImperial: 0, darkMode: 1,
laneDepartureWarning: t % 7 < 0.2 ? 1 : 0,
};
});

// inline <script src="..."> from disk so we can use JSDOM.fromString (no HTTP server). Handles vanilla <script defer src="X"></script>; widen if a future app uses type="module" or conditional loaders.
function inlineScripts(html, appDir) {
return html.replace(/<script\b([^>]*\bsrc=["']([^"']+)["'][^>]*)><\/script>/gi, (match, attrs, src) => {
try { return "<script>" + fs.readFileSync(path.resolve(appDir, src), "utf8") + "</script>"; }
catch { return match; }
});
}

const sleep = (ms) => new Promise((r) => setTimeout(r, ms));

async function gate(appDir) {
const html = inlineScripts(fs.readFileSync(path.join(appDir, "index.html"), "utf8"), appDir);
const state = { ...buildScenario()[0] };
const errors = [];

const dom = new JSDOM(html, {
runScripts: "dangerously", pretendToBeVisual: true,
beforeParse(window) {
// Proxy NativeCarState: every method call returns current state[derivedKey]; unknown methods return undefined so app readNative fallback runs.
window.NativeCarState = new Proxy({}, { get(_t, p) { const k = keyFromMethod(p); return k === null ? undefined : () => state[k]; } });
if (!window.matchMedia) window.matchMedia = () => ({ matches: false, addEventListener() {}, removeEventListener() {}, addListener() {}, removeListener() {} });
// jsdom fires 'error' even when app's window.onerror returns true (swallowed-crash detection).
window.addEventListener("error", (e) => errors.push("uncaught error: " + ((e && e.error && e.error.message) || (e && e.message) || "unknown error")));
},
});

try {
const deadline = Date.now() + CONTRACT_TIMEOUT_MS;
while (typeof dom.window.onCarStateUpdate !== "function") {
if (errors.length) return { error: errors[0] };
if (Date.now() > deadline) return { error: "contract timeout" };
await sleep(100);
}

const observer = new dom.window.MutationObserver(() => {});
observer.observe(dom.window.document.documentElement, { childList: true, subtree: true, attributes: true, characterData: true });
observer.takeRecords();

let streak = 0, worstPerParent = 0, fail = null;
const wallStart = Date.now();
for (let i = 0; i < TOTAL_FRAMES; i++) {
Object.assign(state, buildScenario()[i]);
try { dom.window.onCarStateUpdate(); } catch (e) { errors.unshift("onCarStateUpdate threw: " + (e.message || e)); break; }
const records = observer.takeRecords();
if (i < WARMUP_FRAMES) continue;
const byParent = new Map();
for (const r of records) {
const p = r.target.parentNode;
const key = p && p.tagName ? (p.id ? "#" + p.id : p.className ? "." + p.className.split(" ")[0] : p.tagName.toLowerCase()) : "<root>";
byParent.set(key, (byParent.get(key) || 0) + 1);
}
let maxPerParent = 0, hotParent = "";
for (const [k, n] of byParent) if (n > maxPerParent) { maxPerParent = n; hotParent = k; }
if (maxPerParent > worstPerParent) worstPerParent = maxPerParent;
streak = maxPerParent >= BUDGET_PER_PARENT ? streak + 1 : 0;
if (streak >= STREAK) { fail = "may cause dashboard lag (driver sees stale speed/blinkers)\n " + hotParent + ": " + maxPerParent + " child updates per frame (max " + BUDGET_PER_PARENT + ")\n Fix: update " + hotParent + " once instead of each child"; break; }
await sleep(Math.max(0, (i + 1) * STEP_MS - (Date.now() - wallStart)));
}
if (errors.length) return { error: errors[0] };
if (fail) return { fail };
return { pass: true, worstPerParent };
} finally {
dom.window.close();
}
}

async function main() {
const { values, positionals } = parseArgs({ args: process.argv.slice(2), options: { name: { type: "string" } }, allowPositionals: true });
if (positionals.length !== 1) { console.error("usage: node gate.js <app-dir> [--name <name>]"); process.exit(3); }
const appDir = path.resolve(positionals[0]);
if (!fs.existsSync(path.join(appDir, "index.html"))) { console.error("infra: no index.html in " + appDir); process.exit(3); }
// derive display name from dir; if basename is 'dist' (web-expo), use the parent dir name.
const appName = values.name || (path.basename(appDir) === "dist" ? path.basename(path.dirname(appDir)) : path.basename(appDir));
const r = await gate(appDir);
if (r.error) { console.error("APP_ERROR " + appName + ": " + r.error); process.exit(2); }
const line = r.fail
? "FAIL " + appName + " — " + r.fail
: "PASS " + appName + " maxPerParent=" + r.worstPerParent + " budget=" + BUDGET_PER_PARENT + " streak<" + STREAK;
console.log(line);
if (process.env.GITHUB_STEP_SUMMARY) fs.appendFileSync(process.env.GITHUB_STEP_SUMMARY, line + "\n");
process.exit(r.fail ? 1 : 0);
}

main().catch((e) => { console.error("infra:", e.stack || e); process.exit(3); });
Loading
Loading