chore: add a runtime perf probe for the embedded vs full-screen WebView

The embedded tab and the full-screen browser are the same WebView in the same
`:napplet` process with byte-identical WebSettings, so a site whose JS feels
slower in the embed is being slowed by the host, not by its configuration.
`perf.html` measures which host effect it is: page visibility (a page Chromium
treats as hidden gets ~1Hz timers and no rAF), raw CPU throughput (the renderer
inherits its scheduling class from whichever process hosts the WebView — the
embed's is a plain bound service, the full-screen one is top-app), forced-layout
cost, rAF rate, long tasks, and input-delivery latency measured from the
platform's own event timestamp.

Open the same URL in both hosts and compare the summary line; the README says
what each divergence points at.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AC3ambee9KFcvHCS6HRqhS
This commit is contained in:
Claude
2026-08-09 22:44:46 +00:00
parent a0f4328f2a
commit b7f55d8697
2 changed files with 248 additions and 0 deletions
+42
View File
@@ -52,3 +52,45 @@ live only under `tools/`.
low now that the surface no longer resizes on IME show).
- `MAINTHREAD BLOCKED` / `LONGTASK` = something is stalling the WebView thread.
- `HEARTBEAT` lines changing while idle = spontaneous focus/selection drift.
## `perf.html` — why does the embed feel slower than the full-screen browser?
`index.html` profiles the IME relay. `perf.html` answers a different question:
the embedded tab and the full-screen browser are the **same WebView in the same
`:napplet` process** with byte-identical `WebSettings`, so when a site's JS feels
slower in the embed, the cause is host-induced — and this page measures which
host effect it is.
Serve the directory (above) and open **the same URL in both hosts**, then compare
the summary line at the bottom of the page:
- **`vis=hidden`** — decisive. Chromium considers the embedded page hidden, so it
clamps timers to ~1Hz and suspends `requestAnimationFrame`. Everything the site
schedules lands late; it reads as "the JS got slow". Confirmed by
`timer50` (a 50ms interval firing at 500-1000ms) and `raf` (0 fps).
- **`vis=visible` but `cpu` is 2-4× the full-screen number** — the process is
running on the little cores. The site's JS runs in the WebView *renderer*
process, whose scheduling class is inherited from its host: `:napplet` is
`top-app` when it fronts the full-screen activity, but only a bound service
(`BIND_AUTO_CREATE`, no `BIND_IMPORTANT`) when it serves the embed. Cross-check
off-device with:
```bash
adb shell dumpsys activity processes | grep -E 'napplet|sandboxed'
adb shell "cat /proc/$(adb shell pidof com.vitorpamplona.amethyst:napplet)/cgroup"
```
Expect `/top-app` with the full-screen browser open and `/foreground` (or lower)
with an embed tab open.
- **`cpu` matches but `inputDelivery` is much higher** — the gap is input routing
into the embedded window, not compute. `inputDelivery` is the time between the
platform stamping the touch and JS receiving it.
- **`layout` much higher in the embed** — layout/paint is the bottleneck (check
logcat for WebView software-rendering warnings; a non-hardware-accelerated
`SurfaceControlViewHost` window would put Chromium on the software path).
- **`focus=false` in the embed is expected** and is not itself a throttle: the
host window owns the keyboard, which is the whole reason `RemoteImeView` exists.
`longtasks` counts main-thread blocks over 50ms while the page was measuring —
high counts in the embed with a matching `cpu` number point at something else in
the process competing (e.g. parked warm tabs that are never paused).
+206
View File
@@ -0,0 +1,206 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
<title>Embed vs direct — runtime perf probe</title>
<style>
:root { color-scheme: light dark; }
body { font: 14px/1.45 system-ui, sans-serif; margin: 0; padding: 12px 12px 40px; }
h1 { font-size: 16px; margin: 0 0 4px; }
p.sub { margin: 0 0 12px; opacity: .7; font-size: 12px; }
#tap { display: block; width: 100%; height: 96px; font: 600 16px system-ui, sans-serif;
border: 2px solid currentColor; border-radius: 10px; background: transparent; color: inherit;
margin: 0 0 12px; touch-action: manipulation; }
table { border-collapse: collapse; width: 100%; font-variant-numeric: tabular-nums; }
td { padding: 4px 6px; border-bottom: 1px solid rgba(128,128,128,.3); vertical-align: top; }
td.k { opacity: .75; white-space: nowrap; }
td.v { text-align: right; font-weight: 600; white-space: nowrap; }
.bad { color: #c0392b; }
.warn { color: #b8860b; }
.ok { color: #2e7d32; }
#summary { margin-top: 12px; width: 100%; min-height: 76px; font: 11px/1.35 ui-monospace, monospace; }
#filler > div { padding: 2px 4px; border-left: 3px solid rgba(128,128,128,.25); }
button.rerun { margin-top: 10px; padding: 8px 14px; font: 14px system-ui, sans-serif; }
</style>
</head>
<body>
<h1>Runtime perf probe</h1>
<p class="sub">Open this same URL twice — once as an <b>embedded</b> tab, once in the <b>full-screen</b> browser — and compare. Both are the same WebView in the same process; any gap is host-induced.</p>
<button id="tap">Tap me a few times<br><small>measures input → JS → paint</small></button>
<table id="out"></table>
<button class="rerun" id="rerun">Re-run benchmarks</button>
<textarea id="summary" readonly aria-label="one-line summary"></textarea>
<div id="filler" aria-hidden="true"></div>
<script>
(function () {
'use strict';
// A few thousand nodes, so layout costs are representative of a real page rather than a blank one.
var filler = document.getElementById('filler');
var frag = document.createDocumentFragment();
for (var i = 0; i < 1500; i++) {
var d = document.createElement('div');
d.textContent = 'row ' + i + ' — lorem ipsum dolor sit amet consectetur';
frag.appendChild(d);
}
filler.appendChild(frag);
var rows = {};
var table = document.getElementById('out');
function put(key, value, cls) {
var tr = rows[key];
if (!tr) {
tr = rows[key] = document.createElement('tr');
var k = document.createElement('td'); k.className = 'k'; k.textContent = key;
var v = document.createElement('td'); v.className = 'v';
tr.appendChild(k); tr.appendChild(v); table.appendChild(tr);
}
var cell = tr.lastChild;
cell.textContent = value;
cell.className = 'v' + (cls ? ' ' + cls : '');
}
function median(a) { var b = a.slice().sort(function (x, y) { return x - y; }); return b[b.length >> 1]; }
// ---- environment -------------------------------------------------------
// visibilityState is the one that matters most: a page Chromium considers hidden gets its timers
// clamped to ~1Hz and requestAnimationFrame suspended entirely, which reads exactly like "the site's
// JS got slow and everything lags". hasFocus() false is EXPECTED in the embed (the host window owns
// the keyboard) and is not, by itself, a throttling trigger.
function env() {
put('visibilityState', document.visibilityState, document.visibilityState === 'visible' ? 'ok' : 'bad');
put('document.hasFocus()', String(document.hasFocus()));
put('hardwareConcurrency', String(navigator.hardwareConcurrency || '?'));
put('devicePixelRatio', String(window.devicePixelRatio));
put('viewport', window.innerWidth + '×' + window.innerHeight);
}
// ---- pure CPU: no DOM, no allocation. Isolates scheduler/core assignment. ----
function cpuMs() {
var runs = [];
for (var r = 0; r < 5; r++) {
var t0 = performance.now(), x = 0;
for (var i = 0; i < 3e6; i++) x += Math.sqrt(i) * 1.0000001;
runs.push(performance.now() - t0);
if (x === Infinity) console.log(x); // keep the loop alive
}
return median(runs);
}
// ---- forced layout: what the IME shim's caret measurement does on this DOM ----
function layoutMs() {
var runs = [];
for (var r = 0; r < 5; r++) {
var t0 = performance.now();
for (var i = 0; i < 20; i++) {
var d = document.createElement('div');
d.style.cssText = 'position:absolute;visibility:hidden;white-space:pre-wrap';
d.textContent = 'measure';
document.body.appendChild(d);
void d.offsetTop; // forces a full document layout
document.body.removeChild(d);
}
runs.push((performance.now() - t0) / 20);
}
return median(runs);
}
// ---- timer fidelity: catches background throttling (a 50ms interval firing at ~1000ms) ----
function timerFidelity(cb) {
var want = 50, ticks = [], last = performance.now();
var id = setInterval(function () {
var now = performance.now();
ticks.push(now - last);
last = now;
if (ticks.length >= 20) { clearInterval(id); cb(median(ticks), want); }
}, want);
}
// ---- rAF rate: suspended entirely on a page Chromium thinks is hidden ----
function rafRate(cb) {
var frames = 0, t0 = performance.now();
(function step() {
frames++;
if (performance.now() - t0 < 2000) requestAnimationFrame(step);
else cb(frames / ((performance.now() - t0) / 1000));
})();
}
// ---- long tasks (>50ms on the main thread) ----
var longTasks = 0, longMs = 0;
try {
new PerformanceObserver(function (list) {
list.getEntries().forEach(function (e) { longTasks++; longMs += e.duration; });
put('long tasks (>50ms)', longTasks + ' / ' + Math.round(longMs) + 'ms', longTasks ? 'warn' : 'ok');
}).observe({ entryTypes: ['longtask'] });
} catch (_) { /* not supported */ }
// ---- input latency: OS event stamp → JS handler → next paint ----
var deliver = [], toPaint = [];
document.getElementById('tap').addEventListener('pointerdown', function (e) {
var atHandler = performance.now();
// e.timeStamp shares performance.now()'s time origin for trusted events, so this is the time the
// event spent between the platform stamping it and JS seeing it — input delivery + main-thread wait.
if (e.isTrusted && e.timeStamp > 0) deliver.push(atHandler - e.timeStamp);
requestAnimationFrame(function () {
requestAnimationFrame(function () { // second rAF ≈ after the frame containing our change is presented
toPaint.push(performance.now() - atHandler);
put('input → handler',
(deliver.length ? median(deliver).toFixed(1) + ' ms' : 'n/a') + ' (n=' + deliver.length + ')',
median(deliver) > 32 ? 'bad' : median(deliver) > 16 ? 'warn' : 'ok');
put('handler → paint',
median(toPaint).toFixed(1) + ' ms (n=' + toPaint.length + ')',
median(toPaint) > 48 ? 'bad' : median(toPaint) > 32 ? 'warn' : 'ok');
summarize();
});
});
}, { passive: true });
var results = {};
function summarize() {
document.getElementById('summary').value =
'vis=' + document.visibilityState + ' focus=' + document.hasFocus() +
' cores=' + (navigator.hardwareConcurrency || '?') +
' | cpu=' + (results.cpu != null ? results.cpu.toFixed(0) + 'ms' : '-') +
' layout=' + (results.layout != null ? results.layout.toFixed(2) + 'ms' : '-') +
' timer50=' + (results.timer != null ? results.timer.toFixed(0) + 'ms' : '-') +
' raf=' + (results.raf != null ? results.raf.toFixed(0) + 'fps' : '-') +
' longtasks=' + longTasks +
' | inputDelivery=' + (deliver.length ? median(deliver).toFixed(1) + 'ms' : '-') +
' toPaint=' + (toPaint.length ? median(toPaint).toFixed(1) + 'ms' : '-');
}
function run() {
env();
put('cpu loop (3M sqrt)', 'running…');
setTimeout(function () {
results.cpu = cpuMs();
put('cpu loop (3M sqrt)', results.cpu.toFixed(0) + ' ms');
results.layout = layoutMs();
put('forced layout (1500 nodes)', results.layout.toFixed(2) + ' ms');
rafRate(function (fps) {
results.raf = fps;
put('requestAnimationFrame', fps.toFixed(0) + ' fps', fps < 30 ? 'bad' : fps < 50 ? 'warn' : 'ok');
timerFidelity(function (got, want) {
results.timer = got;
put('setInterval(' + want + 'ms) actual', got.toFixed(0) + ' ms',
got > 400 ? 'bad' : got > 80 ? 'warn' : 'ok');
summarize();
});
});
}, 50);
}
document.getElementById('rerun').addEventListener('click', run);
document.addEventListener('visibilitychange', function () {
put('visibilityState', document.visibilityState, document.visibilityState === 'visible' ? 'ok' : 'bad');
});
run();
})();
</script>
</body>
</html>