370 lines
14 KiB
JavaScript
370 lines
14 KiB
JavaScript
/*
|
|
* perf-probe.js — per-tab performance probe for sovereign_browser
|
|
*
|
|
* Injected by WebKitUserContentManager into every non-sovereign:// page
|
|
* (see process_info.c / nostr_inject.c wiring in main.c). Collects:
|
|
* - long tasks (PerformanceObserver 'longtask') -> cpu_busy_percent
|
|
* - resource entries (PerformanceObserver 'resource') -> net counts
|
|
* - requestAnimationFrame cadence -> fps
|
|
* - patched setTimeout/setInterval -> active timer count + top intervals
|
|
* - patched addEventListener -> event listener count
|
|
* - patched Worker -> worker count
|
|
* - performance.memory -> heap used
|
|
* - document.getElementsByTagName('*').length -> DOM node count
|
|
*
|
|
* Batches a report every 1s and POSTs it to:
|
|
* sovereign://processes/probe-report?tab_index=N&body=<urlencoded JSON>
|
|
*
|
|
* The tab_index is supplied by the browser via a <meta name="sb-tab-index">
|
|
* tag injected before this script runs (see inject_perf_probe() in
|
|
* nostr_bridge.c / main.c). If absent, the report is dropped.
|
|
*
|
|
* The probe is a no-op on sovereign:// pages (the injector skips them)
|
|
* to avoid self-noise and recursion.
|
|
*/
|
|
(function () {
|
|
'use strict';
|
|
|
|
// Honor the skip flag set by the preamble on sovereign:// / about:
|
|
// pages. The skip flag is re-evaluated on every navigation (the
|
|
// preamble runs at document_start for each new document), so we also
|
|
// reset our installed flag here to allow re-injection after a
|
|
// navigation from an internal page to a normal one.
|
|
if (window.__sbPerfProbeSkip) return;
|
|
if (window.__sbPerfProbe) return; // already running in this document
|
|
window.__sbPerfProbe = true;
|
|
|
|
// Tab index is injected by the browser via window.__sbPerfTabIndex
|
|
// (set by the preamble that runs before this script). Read it once.
|
|
var TAB_INDEX = window.__sbPerfTabIndex;
|
|
if (TAB_INDEX == null || TAB_INDEX < 0 || isNaN(TAB_INDEX)) return;
|
|
|
|
// Capture the native timers early — the heartbeat (below) needs
|
|
// origSetTimeout before the timer-patching block runs.
|
|
var origSetTimeout = window.setTimeout;
|
|
var origSetInterval = window.setInterval;
|
|
var origClearTimeout = window.clearTimeout;
|
|
var origClearInterval = window.clearInterval;
|
|
|
|
// ── Long tasks ──────────────────────────────────────────────────
|
|
var longTasks = []; // last 30s of {t, duration, name}
|
|
var longTasksLastSec = []; // tasks in the current 1s window
|
|
var longTaskSources = {}; // name -> {total_ms, count}
|
|
var longTaskSupported = false;
|
|
|
|
if (typeof PerformanceObserver !== 'undefined') {
|
|
try {
|
|
var ltObs = new PerformanceObserver(function (list) {
|
|
list.getEntries().forEach(function (e) {
|
|
var rec = {
|
|
t: performance.now(),
|
|
duration: e.duration,
|
|
name: e.name || 'unknown',
|
|
attribution: (e.attribution && e.attribution[0])
|
|
? (e.attribution[0].name + ':' + (e.attribution[0].containerName || ''))
|
|
: ''
|
|
};
|
|
longTasks.push(rec);
|
|
longTasksLastSec.push(rec);
|
|
var key = rec.name || 'unknown';
|
|
if (!longTaskSources[key]) {
|
|
longTaskSources[key] = { total_ms: 0, count: 0 };
|
|
}
|
|
longTaskSources[key].total_ms += rec.duration;
|
|
longTaskSources[key].count++;
|
|
});
|
|
});
|
|
ltObs.observe({ entryTypes: ['longtask'] });
|
|
longTaskSupported = true;
|
|
} catch (e) { /* longtask not supported */ }
|
|
}
|
|
|
|
// ── Main-thread blockage fallback ───────────────────────────────
|
|
// WebKitGTK does not fire 'longtask' entries, so we measure main-
|
|
// thread blockage directly: schedule a 0ms heartbeat and measure
|
|
// how late it fires. If the main thread is blocked by a busy loop,
|
|
// the heartbeat is delayed. The total delay in a 1s window is a
|
|
// proxy for cpu_busy_percent.
|
|
var heartbeatDelayMs = 0; // accumulated delay in the current window
|
|
var heartbeatLast = performance.now();
|
|
function heartbeat() {
|
|
var now = performance.now();
|
|
var expected = heartbeatLast + 50; // we schedule every 50ms
|
|
var delay = now - expected;
|
|
if (delay > 5) { // ignore small scheduler jitter
|
|
heartbeatDelayMs += delay;
|
|
// Record as a synthetic long task if > 50ms.
|
|
if (delay > 50) {
|
|
var rec = { t: now, duration: delay, name: 'main-thread-block',
|
|
attribution: '' };
|
|
longTasks.push(rec);
|
|
longTasksLastSec.push(rec);
|
|
var key = 'main-thread-block';
|
|
if (!longTaskSources[key]) {
|
|
longTaskSources[key] = { total_ms: 0, count: 0 };
|
|
}
|
|
longTaskSources[key].total_ms += delay;
|
|
longTaskSources[key].count++;
|
|
}
|
|
}
|
|
heartbeatLast = now;
|
|
origSetTimeout.call(window, heartbeat, 50);
|
|
}
|
|
// Always start the heartbeat. On engines where longtask fires
|
|
// reliably, the heartbeat delay will be near-zero (long tasks are
|
|
// captured by the observer). On WebKitGTK, where longtask is
|
|
// accepted but never fires, the heartbeat is the sole source of
|
|
// cpu_busy measurement. The report() function takes the max of
|
|
// both, so there's no double-counting.
|
|
origSetTimeout.call(window, heartbeat, 50);
|
|
|
|
// ── Resource entries (network) ────────────────────────────────
|
|
var netRequestsLastSec = [];
|
|
var netEndpoints = {}; // url-prefix -> count
|
|
if (typeof PerformanceObserver !== 'undefined') {
|
|
try {
|
|
var resObs = new PerformanceObserver(function (list) {
|
|
list.getEntries().forEach(function (e) {
|
|
netRequestsLastSec.push({ name: e.name, type: e.initiatorType });
|
|
var key = e.name.split('?')[0];
|
|
netEndpoints[key] = (netEndpoints[key] || 0) + 1;
|
|
});
|
|
});
|
|
resObs.observe({ entryTypes: ['resource'] });
|
|
} catch (e) { /* resource not supported */ }
|
|
}
|
|
|
|
// ── FPS via requestAnimationFrame ───────────────────────────────
|
|
var rafFrames = 0;
|
|
var fps = 0;
|
|
function rafLoop(ts) {
|
|
rafFrames++;
|
|
requestAnimationFrame(rafLoop);
|
|
}
|
|
requestAnimationFrame(rafLoop);
|
|
|
|
// ── Timer tracking (patched setTimeout/setInterval) ─────────────
|
|
var activeTimers = {}; // id -> {code, interval_ms, count}
|
|
var timerTop = {}; // "code|interval" -> count
|
|
var nextTimerId = 1;
|
|
|
|
function labelFor(fn) {
|
|
try {
|
|
var s = (fn && fn.name) ? fn.name : 'anonymous';
|
|
// Truncate long inline-function bodies.
|
|
if (s.length > 40) s = s.slice(0, 40);
|
|
return s;
|
|
} catch (e) { return 'anonymous'; }
|
|
}
|
|
|
|
// origSetTimeout / origSetInterval / origClearTimeout /
|
|
// origClearInterval were captured at the top of the IIFE (before the
|
|
// heartbeat was installed), so they reference the native timers.
|
|
|
|
window.setTimeout = function (fn, delay) {
|
|
var id = nextTimerId++;
|
|
var label = labelFor(fn);
|
|
activeTimers[id] = { code: label, interval_ms: 0, count: 0 };
|
|
var key = label + '|0';
|
|
timerTop[key] = (timerTop[key] || 0) + 1;
|
|
var args = Array.prototype.slice.call(arguments, 2);
|
|
var wrapped = function () {
|
|
delete activeTimers[id];
|
|
return fn.apply(this, args);
|
|
};
|
|
return origSetTimeout.call(this, wrapped, delay);
|
|
};
|
|
|
|
window.setInterval = function (fn, delay) {
|
|
var id = nextTimerId++;
|
|
var label = labelFor(fn);
|
|
var interval = delay | 0;
|
|
activeTimers[id] = { code: label, interval_ms: interval, count: 0 };
|
|
var key = label + '|' + interval;
|
|
timerTop[key] = (timerTop[key] || 0) + 1;
|
|
var args = Array.prototype.slice.call(arguments, 2);
|
|
var wrapped = function () {
|
|
if (activeTimers[id]) activeTimers[id].count++;
|
|
return fn.apply(this, args);
|
|
};
|
|
return origSetInterval.call(this, wrapped, delay);
|
|
};
|
|
|
|
window.clearTimeout = function (id) {
|
|
delete activeTimers[id];
|
|
return origClearTimeout.call(this, id);
|
|
};
|
|
window.clearInterval = function (id) {
|
|
delete activeTimers[id];
|
|
return origClearInterval.call(this, id);
|
|
};
|
|
|
|
// ── Event listener count (patched addEventListener) ─────────────
|
|
var eventListenerCount = 0;
|
|
var origAEL = EventTarget.prototype.addEventListener;
|
|
EventTarget.prototype.addEventListener = function (type, fn, opts) {
|
|
// Skip our own internal listeners (tagged with __sbInternal).
|
|
if (!fn || !fn.__sbInternal) eventListenerCount++;
|
|
return origAEL.call(this, type, fn, opts);
|
|
};
|
|
|
|
// ── Worker count (patched Worker constructor) ───────────────────
|
|
var workerCount = 0;
|
|
var OrigWorker = window.Worker;
|
|
if (OrigWorker) {
|
|
window.Worker = function (url, opts) {
|
|
workerCount++;
|
|
return new OrigWorker(url, opts);
|
|
};
|
|
window.Worker.prototype = OrigWorker.prototype;
|
|
}
|
|
|
|
// ── In-flight network (patched fetch + XHR.open) ────────────────
|
|
var netInFlight = 0;
|
|
var origFetch = window.fetch;
|
|
if (origFetch) {
|
|
window.fetch = function () {
|
|
netInFlight++;
|
|
var p = origFetch.apply(this, arguments);
|
|
var dec = function () { if (netInFlight > 0) netInFlight--; };
|
|
p.then(dec, dec);
|
|
return p;
|
|
};
|
|
}
|
|
var origXhrOpen = XMLHttpRequest.prototype.open;
|
|
XMLHttpRequest.prototype.open = function (method, url) {
|
|
netInFlight++;
|
|
var onEnd = function () {
|
|
if (netInFlight > 0) netInFlight--;
|
|
};
|
|
onEnd.__sbInternal = true;
|
|
this.addEventListener('loadend', onEnd);
|
|
return origXhrOpen.apply(this, arguments);
|
|
};
|
|
|
|
// ── Reporting ───────────────────────────────────────────────────
|
|
function report() {
|
|
// Trim long-task timeline to last 30s.
|
|
var now = performance.now();
|
|
while (longTasks.length && (now - longTasks[0].t) > 30000) {
|
|
longTasks.shift();
|
|
}
|
|
|
|
// cpu_busy_percent: sum of long-task durations in the last 1s
|
|
// window. If the longtask PerformanceObserver isn't supported
|
|
// (WebKitGTK), fall back to the heartbeat-delay measurement.
|
|
var busyMs = 0;
|
|
for (var i = 0; i < longTasksLastSec.length; i++) {
|
|
busyMs += longTasksLastSec[i].duration;
|
|
}
|
|
// If the heartbeat measured more blockage than the longtask
|
|
// observer (e.g. on WebKitGTK where longtask never fires), use
|
|
// the heartbeat measurement.
|
|
if (heartbeatDelayMs > busyMs) {
|
|
busyMs = heartbeatDelayMs;
|
|
}
|
|
var cpuBusy = busyMs / 10.0; // ms -> percent of 1000ms
|
|
if (cpuBusy > 100) cpuBusy = 100;
|
|
heartbeatDelayMs = 0; // reset for next window
|
|
|
|
// Top timer sources.
|
|
var timerTopArr = [];
|
|
Object.keys(timerTop).forEach(function (k) {
|
|
var parts = k.split('|');
|
|
timerTopArr.push({
|
|
code: parts[0],
|
|
interval_ms: parseInt(parts[1], 10) || 0,
|
|
count: timerTop[k]
|
|
});
|
|
});
|
|
timerTopArr.sort(function (a, b) { return b.count - a.count; });
|
|
timerTopArr = timerTopArr.slice(0, 5);
|
|
|
|
// Top network endpoints (last window).
|
|
var netTopArr = [];
|
|
Object.keys(netEndpoints).forEach(function (u) {
|
|
netTopArr.push({ url: u, count: netEndpoints[u] });
|
|
});
|
|
netTopArr.sort(function (a, b) { return b.count - a.count; });
|
|
netTopArr = netTopArr.slice(0, 5);
|
|
|
|
// Top long-task sources.
|
|
var ltSourcesArr = [];
|
|
Object.keys(longTaskSources).forEach(function (k) {
|
|
ltSourcesArr.push({
|
|
name: k,
|
|
total_ms: Math.round(longTaskSources[k].total_ms),
|
|
count: longTaskSources[k].count
|
|
});
|
|
});
|
|
ltSourcesArr.sort(function (a, b) { return b.total_ms - a.total_ms; });
|
|
ltSourcesArr = ltSourcesArr.slice(0, 8);
|
|
|
|
// Heap.
|
|
var heapMb = -1;
|
|
if (performance.memory && performance.memory.usedJSHeapSize) {
|
|
heapMb = performance.memory.usedJSHeapSize / (1024 * 1024);
|
|
}
|
|
|
|
// DOM node count.
|
|
var domNodes = -1;
|
|
try {
|
|
domNodes = document.getElementsByTagName('*').length;
|
|
} catch (e) { /* document not ready */ }
|
|
|
|
var payload = {
|
|
tab_index: TAB_INDEX,
|
|
cpu_busy_percent: Math.round(cpuBusy * 10) / 10,
|
|
fps: fps,
|
|
timer_count: Object.keys(activeTimers).length,
|
|
timer_top: timerTopArr,
|
|
net_in_flight: netInFlight,
|
|
net_requests_last_sec: netRequestsLastSec.length,
|
|
net_top_endpoints: netTopArr,
|
|
heap_used_mb: heapMb >= 0 ? Math.round(heapMb * 10) / 10 : -1,
|
|
event_listener_count: eventListenerCount,
|
|
worker_count: workerCount,
|
|
dom_node_count: domNodes,
|
|
long_tasks_last_sec: longTasksLastSec.map(function (r) {
|
|
return {
|
|
duration: Math.round(r.duration),
|
|
name: r.name,
|
|
attribution: r.attribution
|
|
};
|
|
}),
|
|
long_task_top_sources: ltSourcesArr,
|
|
long_task_timeline: longTasks.map(function (r) {
|
|
return { t: Math.round(r.t), duration: Math.round(r.duration), name: r.name };
|
|
})
|
|
};
|
|
|
|
// Reset per-window accumulators.
|
|
longTasksLastSec = [];
|
|
netRequestsLastSec = [];
|
|
netEndpoints = {};
|
|
timerTop = {};
|
|
|
|
// Send. Use sync XHR to sovereign:// (same convention as the
|
|
// window.nostr shim — WebKit's custom scheme handler doesn't
|
|
// reliably support fetch() or async XHR for sovereign:// URLs).
|
|
try {
|
|
var body = encodeURIComponent(JSON.stringify(payload));
|
|
var url = 'sovereign://processes/probe-report?tab_index=' + TAB_INDEX +
|
|
'&body=' + body;
|
|
var x = new XMLHttpRequest();
|
|
x.open('GET', url, false); // synchronous
|
|
x.send();
|
|
} catch (e) { /* swallow — probe must never break the page */ }
|
|
}
|
|
|
|
// FPS counter: count frames in each 1s window.
|
|
setInterval(function () {
|
|
fps = rafFrames;
|
|
rafFrames = 0;
|
|
}, 1000);
|
|
|
|
// Report every 1s. Use the original setInterval so we don't track
|
|
// ourselves in activeTimers.
|
|
origSetInterval.call(window, report, 1000);
|
|
})();
|