sloptimize 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +11 -0
- package/.claude-plugin/plugin.json +8 -0
- package/.mcp.json +9 -0
- package/LICENSE +21 -0
- package/README.md +263 -0
- package/bin/sloptimize.mjs +346 -0
- package/docs/DESIGN-mecharoyale-v0.md +32 -0
- package/docs/INTEGRATION.md +230 -0
- package/docs/JITTER-AND-FOOTPRINTS.md +236 -0
- package/docs/SPEC-attach.md +176 -0
- package/docs/SPEC.md +845 -0
- package/docs/USAGE.md +227 -0
- package/hooks/hooks.json +16 -0
- package/mcp/server.mjs +127 -0
- package/package.json +64 -0
- package/skills/install/SKILL.md +143 -0
- package/skills/sloptimize/SKILL.md +74 -0
- package/src/attach.mjs +197 -0
- package/src/census.js +193 -0
- package/src/classify.js +70 -0
- package/src/footprint.js +170 -0
- package/src/history.js +273 -0
- package/src/index.js +8 -0
- package/src/inject-body.js +152 -0
- package/src/motion.js +345 -0
- package/src/panel.js +530 -0
- package/src/proposals.mjs +268 -0
- package/src/recorder.js +235 -0
- package/src/watch.mjs +242 -0
package/src/history.js
ADDED
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// history.js — the timeline and the fix ledger (SPEC §8.5)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// perf.jsonl is append-only and every line is self-sufficient (build +
|
|
5
|
+
// phase + timestamp), so the whole history of a deployment IS the ledger —
|
|
6
|
+
// this module just folds it: equal time buckets (for a graph), one window
|
|
7
|
+
// per build (for "what did this bundle measure"), and fix records whose
|
|
8
|
+
// before/after are MEASURED windows of that same ledger. Nobody types a
|
|
9
|
+
// number into a report; the report is a pair of windows and their summaries.
|
|
10
|
+
//
|
|
11
|
+
// Pure and environment-free on purpose: the CLI folds it in node, the
|
|
12
|
+
// in-page panel folds the same bytes in the browser, and a test folds a
|
|
13
|
+
// fixture — one implementation, three readers.
|
|
14
|
+
|
|
15
|
+
import { footprintOf, describeFootprint } from './footprint.js';
|
|
16
|
+
|
|
17
|
+
/** A date as ms — a number passes through, a string parses; NaN for junk. */
|
|
18
|
+
function asMs(v) { return typeof v === 'number' ? v : Date.parse(v); }
|
|
19
|
+
|
|
20
|
+
/** Median of a numeric array (undefined for empty). */
|
|
21
|
+
function median(vals) {
|
|
22
|
+
if (vals.length === 0) return undefined;
|
|
23
|
+
const s = [...vals].sort((a, b) => a - b);
|
|
24
|
+
const mid = s.length >> 1;
|
|
25
|
+
return s.length % 2 ? s[mid] : +((s[mid - 1] + s[mid]) / 2).toFixed(2);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Records with a parseable timestamp, each paired with its ms. */
|
|
29
|
+
function stamped(records) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const r of records) {
|
|
32
|
+
const t = Date.parse(r?.at);
|
|
33
|
+
if (Number.isFinite(t)) out.push({ t, r });
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** The counters a heartbeat may carry (INTEGRATION.md §2: calls/triangles/
|
|
39
|
+
* programs ride the beat since 0.3 — older beats simply have none). Absent
|
|
40
|
+
* means unmeasured, never zero. */
|
|
41
|
+
const BEAT_COUNTERS = ['calls', 'triangles', 'programs'];
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Summarize one window [from, to] of the ledger: medians of the heartbeats
|
|
45
|
+
* (frame p95/median and whichever counters they carry), the hitch count,
|
|
46
|
+
* rate, worst frame and most frequent guess, and the regime that measured
|
|
47
|
+
* them. Every field is absent when the window has no evidence for it.
|
|
48
|
+
*/
|
|
49
|
+
export function summarizeWindow(records, from, to) {
|
|
50
|
+
const beats = [], counters = Object.fromEntries(BEAT_COUNTERS.map((k) => [k, []]));
|
|
51
|
+
const p95s = [], meds = [], guesses = new Map();
|
|
52
|
+
let hitches = 0, jitters = 0, worstMs, worstGuess, regime;
|
|
53
|
+
for (const { t, r } of stamped(records)) {
|
|
54
|
+
if (t < from || t > to) continue;
|
|
55
|
+
if (r.type === 'heartbeat') {
|
|
56
|
+
beats.push(r);
|
|
57
|
+
if (typeof r.p95Ms === 'number') p95s.push(r.p95Ms);
|
|
58
|
+
if (typeof r.medianFrameMs === 'number') meds.push(r.medianFrameMs);
|
|
59
|
+
for (const k of BEAT_COUNTERS) if (typeof r[k] === 'number') counters[k].push(r[k]);
|
|
60
|
+
if (r.regime && r.regime !== 'unknown') regime = r.regime;
|
|
61
|
+
} else if (r.type === 'hitch' && typeof r.frameMs === 'number') {
|
|
62
|
+
hitches++;
|
|
63
|
+
const g = r.classification?.[0]?.guess;
|
|
64
|
+
if (g) guesses.set(g, (guesses.get(g) ?? 0) + 1);
|
|
65
|
+
if (worstMs === undefined || r.frameMs > worstMs) { worstMs = r.frameMs; worstGuess = g; }
|
|
66
|
+
} else if (r.type === 'jitter') {
|
|
67
|
+
// A coordinate jump (SPEC §3.6) — counted, so a fix's before/after can
|
|
68
|
+
// say the view stopped snapping, not only that frames got shorter.
|
|
69
|
+
jitters++;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
const hours = Math.max((to - from) / 3_600_000, 1 / 60);
|
|
73
|
+
const s = { from: new Date(from).toISOString(), to: new Date(to).toISOString(), beats: beats.length, hitches,
|
|
74
|
+
hitchesPerHour: +(hitches / hours).toFixed(1) };
|
|
75
|
+
if (p95s.length) s.p95Ms = median(p95s);
|
|
76
|
+
if (meds.length) s.medianMs = median(meds);
|
|
77
|
+
for (const k of BEAT_COUNTERS) if (counters[k].length) s[k] = median(counters[k]);
|
|
78
|
+
if (jitters > 0) { s.jitters = jitters; s.jittersPerHour = +(jitters / hours).toFixed(1); }
|
|
79
|
+
if (worstMs !== undefined) { s.worstMs = +worstMs.toFixed(1); s.worstGuess = worstGuess; }
|
|
80
|
+
if (guesses.size) s.topGuess = [...guesses].sort((a, b) => b[1] - a[1])[0][0];
|
|
81
|
+
if (regime) s.regime = regime;
|
|
82
|
+
return s;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Evidence = a record that measured something: a beat, a hitch, a jitter.
|
|
86
|
+
* Arm probes and settles name a build without saying how it ran. */
|
|
87
|
+
const EVIDENCE = new Set(['heartbeat', 'hitch', 'jitter']);
|
|
88
|
+
|
|
89
|
+
/** Builds in order of first evidence, each with its measured window. */
|
|
90
|
+
function buildWindows(records) {
|
|
91
|
+
const seen = new Map();
|
|
92
|
+
for (const { t, r } of stamped(records)) {
|
|
93
|
+
if (!EVIDENCE.has(r.type) || !r.build) continue;
|
|
94
|
+
const w = seen.get(r.build);
|
|
95
|
+
if (!w) seen.set(r.build, { build: r.build, fromMs: t, toMs: t });
|
|
96
|
+
else { if (t < w.fromMs) w.fromMs = t; if (t > w.toMs) w.toMs = t; }
|
|
97
|
+
}
|
|
98
|
+
return [...seen.values()].sort((a, b) => a.fromMs - b.fromMs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** The newest build with evidence and the one before it, oldest first —
|
|
102
|
+
* the default before/after pair of a fix, because a fix ships as a build. */
|
|
103
|
+
export function latestBuilds(records) {
|
|
104
|
+
return buildWindows(records).slice(-2).map((w) => w.build);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Fold the ledger into a timeline. `buckets` equal time slices from the
|
|
109
|
+
* first to the last measured record; each carries the window summary's
|
|
110
|
+
* fields (see summarizeWindow) plus the build that ran in it, so a graph
|
|
111
|
+
* can draw p95 / draw calls / hitch spikes and mark build boundaries.
|
|
112
|
+
*/
|
|
113
|
+
export function buildHistory(records, opts = {}) {
|
|
114
|
+
const n = opts.buckets ?? 48;
|
|
115
|
+
// A date range (ms or ISO; either end open) scopes EVERYTHING below —
|
|
116
|
+
// buckets, per-build windows and the fix list — so "how much did we gain
|
|
117
|
+
// between these dates" is the same fold over fewer records.
|
|
118
|
+
const lo = opts.from !== undefined && opts.from !== null && opts.from !== '' ? asMs(opts.from) : -Infinity;
|
|
119
|
+
const hi = opts.to !== undefined && opts.to !== null && opts.to !== '' ? asMs(opts.to) : Infinity;
|
|
120
|
+
if (lo !== -Infinity || hi !== Infinity) {
|
|
121
|
+
records = records.filter((r) => { const t = Date.parse(r.at); return !Number.isFinite(t) || (t >= lo && t <= hi); });
|
|
122
|
+
}
|
|
123
|
+
// Sorted: the sink appends per post, and a post can carry a settle that
|
|
124
|
+
// was measured before the beat ahead of it in the file.
|
|
125
|
+
const measured = stamped(records).filter(({ r }) => EVIDENCE.has(r.type)).sort((a, b) => a.t - b.t);
|
|
126
|
+
const fixes = [...(opts.fixes ?? [])]
|
|
127
|
+
.filter((f) => { const t = Date.parse(f.at); return !Number.isFinite(t) || (t >= lo && t <= hi); })
|
|
128
|
+
.sort((a, b) => Date.parse(b.at) - Date.parse(a.at));
|
|
129
|
+
if (measured.length === 0) return { span: null, buckets: [], builds: [], fixes };
|
|
130
|
+
const fromMs = measured[0].t, toMs = measured[measured.length - 1].t;
|
|
131
|
+
const width = Math.max(toMs - fromMs, 1) / n;
|
|
132
|
+
const buckets = [];
|
|
133
|
+
for (let i = 0; i < n; i++) {
|
|
134
|
+
const b0 = fromMs + i * width, b1 = i === n - 1 ? toMs : fromMs + (i + 1) * width - 1;
|
|
135
|
+
const s = summarizeWindow(records, b0, b1);
|
|
136
|
+
delete s.hitchesPerHour; // a bucket is a slice, not a rate
|
|
137
|
+
delete s.jittersPerHour;
|
|
138
|
+
const inBucket = measured.filter(({ t }) => t >= b0 && t <= b1);
|
|
139
|
+
const build = inBucket.map(({ r }) => r.build).filter(Boolean).pop();
|
|
140
|
+
if (build) s.build = build;
|
|
141
|
+
buckets.push(s);
|
|
142
|
+
}
|
|
143
|
+
const builds = buildWindows(records).map((w) => ({ build: w.build, ...summarizeWindow(records, w.fromMs, w.toMs) }));
|
|
144
|
+
return { span: { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString() }, buckets, builds, fixes };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Resolve a window spec — a build name or "ISO..ISO" — against the ledger. */
|
|
148
|
+
function resolveWindow(records, spec, label) {
|
|
149
|
+
if (typeof spec === 'string' && spec.includes('..')) {
|
|
150
|
+
const [a, b] = spec.split('..').map((s) => Date.parse(s));
|
|
151
|
+
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) throw new Error(`bad ${label} window "${spec}" — want <ISO>..<ISO>`);
|
|
152
|
+
return { fromMs: a, toMs: b };
|
|
153
|
+
}
|
|
154
|
+
const w = buildWindows(records).find((x) => x.build === spec);
|
|
155
|
+
if (!w) throw new Error(`no evidence for ${label} window "${spec}" — builds with evidence: ${buildWindows(records).map((x) => x.build).join(', ') || 'none'}`);
|
|
156
|
+
return w;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The window's summary plus the per-bucket p95 series a card sparklines. */
|
|
160
|
+
function windowReport(records, w) {
|
|
161
|
+
const s = summarizeWindow(records, w.fromMs, w.toMs);
|
|
162
|
+
if (w.build) s.build = w.build;
|
|
163
|
+
const slices = 24, width = Math.max(w.toMs - w.fromMs, 1) / slices;
|
|
164
|
+
s.series = [];
|
|
165
|
+
for (let i = 0; i < slices; i++) {
|
|
166
|
+
const p = summarizeWindow(records, w.fromMs + i * width, w.fromMs + (i + 1) * width).p95Ms;
|
|
167
|
+
if (p !== undefined) s.series.push(p);
|
|
168
|
+
}
|
|
169
|
+
return s;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A fix record: what was wrong, what changed (commit), and the measured
|
|
174
|
+
* before/after — two ledger windows, by build name (default: the previous
|
|
175
|
+
* and the latest build with evidence) or by explicit time range.
|
|
176
|
+
*/
|
|
177
|
+
export function buildFix(records, opts = {}) {
|
|
178
|
+
let { before, after } = opts;
|
|
179
|
+
if (!before || !after) {
|
|
180
|
+
const pair = latestBuilds(records);
|
|
181
|
+
if (pair.length < 2) throw new Error('a fix needs two builds with evidence in the ledger (before → after); pass --before/--after explicitly (a build name, or <ISO>..<ISO>)');
|
|
182
|
+
before ??= pair[0]; after ??= pair[1];
|
|
183
|
+
}
|
|
184
|
+
const fix = {
|
|
185
|
+
type: 'fix',
|
|
186
|
+
at: opts.now ?? new Date().toISOString(),
|
|
187
|
+
title: opts.title ?? '',
|
|
188
|
+
};
|
|
189
|
+
for (const k of ['issue', 'solution', 'commit', 'files', 'footprints']) if (opts[k]) fix[k] = opts[k];
|
|
190
|
+
fix.before = windowReport(records, resolveWindow(records, before, 'before'));
|
|
191
|
+
fix.after = windowReport(records, resolveWindow(records, after, 'after'));
|
|
192
|
+
return fix;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* THE ISSUE CATALOGUE (SPEC §3.7): every incident record in `records`,
|
|
197
|
+
* grouped by footprint — the same cause across builds, sessions and days is
|
|
198
|
+
* ONE row — with how often it happened, when first and last, on which builds,
|
|
199
|
+
* how bad at worst, and which fixes were applied to it (a fix names the
|
|
200
|
+
* footprints it addresses in `footprints`). Records stamped by the writer keep
|
|
201
|
+
* their footprint; older lines are derived here, so the catalogue reaches back
|
|
202
|
+
* to before footprints existed.
|
|
203
|
+
*
|
|
204
|
+
* `from`/`to` scope the occurrences counted (ms or ISO; either end open);
|
|
205
|
+
* `now` is for `lastAgoMs`. Sorted most-frequent first; ties by most recent.
|
|
206
|
+
*/
|
|
207
|
+
export function buildIssues(records, opts = {}) {
|
|
208
|
+
const lo = opts.from !== undefined && opts.from !== null && opts.from !== '' ? asMs(opts.from) : -Infinity;
|
|
209
|
+
const hi = opts.to !== undefined && opts.to !== null && opts.to !== '' ? asMs(opts.to) : Infinity;
|
|
210
|
+
const now = opts.now ?? Date.now();
|
|
211
|
+
const groups = new Map();
|
|
212
|
+
for (const { t, r } of stamped(records)) {
|
|
213
|
+
if (t < lo || t > hi) continue;
|
|
214
|
+
if (r.automated === true && opts.includeAutomated !== true) continue; // a robot's session is not a player's issue
|
|
215
|
+
const fp = footprintOf(r);
|
|
216
|
+
if (!fp) continue;
|
|
217
|
+
let g = groups.get(fp.id);
|
|
218
|
+
if (!g) {
|
|
219
|
+
const d = describeFootprint(fp.key);
|
|
220
|
+
g = { id: fp.id, key: fp.key, type: r.type, glyph: d.glyph, label: d.label, phase: d.phase, ctx: d.ctx,
|
|
221
|
+
count: 0, firstMs: t, lastMs: t, builds: new Set(), worst: undefined, sample: undefined };
|
|
222
|
+
groups.set(fp.id, g);
|
|
223
|
+
}
|
|
224
|
+
g.count++;
|
|
225
|
+
if (t < g.firstMs) g.firstMs = t;
|
|
226
|
+
if (t > g.lastMs) { g.lastMs = t; g.sample = r.classification?.[0] ?? g.sample; }
|
|
227
|
+
if (r.build) g.builds.add(r.build);
|
|
228
|
+
const w = worstOf(r);
|
|
229
|
+
if (w !== undefined && (g.worst === undefined || w.value > g.worst.value)) g.worst = w;
|
|
230
|
+
}
|
|
231
|
+
const fixes = (opts.fixes ?? []).filter((f) => Array.isArray(f.footprints) && f.footprints.length);
|
|
232
|
+
const out = [];
|
|
233
|
+
for (const g of groups.values()) {
|
|
234
|
+
const linked = fixes.filter((f) => f.footprints.includes(g.id)).sort((a, b) => Date.parse(a.at) - Date.parse(b.at));
|
|
235
|
+
out.push({
|
|
236
|
+
id: g.id, key: g.key, type: g.type, glyph: g.glyph, label: g.label, phase: g.phase, ctx: g.ctx,
|
|
237
|
+
count: g.count,
|
|
238
|
+
first: new Date(g.firstMs).toISOString(), last: new Date(g.lastMs).toISOString(),
|
|
239
|
+
lastAgoMs: Math.max(0, now - g.lastMs),
|
|
240
|
+
builds: [...g.builds],
|
|
241
|
+
...(g.worst ? { worst: g.worst } : {}),
|
|
242
|
+
...(g.sample ? { sample: g.sample } : {}),
|
|
243
|
+
fixes: linked.map((f) => ({ id: f.id, title: f.title, at: f.at, ...(f.commit ? { commit: f.commit } : {}),
|
|
244
|
+
...(f.status ? { status: f.status } : {}), ...(f.pr ? { pr: f.pr } : {}), ...(f.mergeCommit ? { mergeCommit: f.mergeCommit } : {}) })),
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
return out.sort((a, b) => b.count - a.count || Date.parse(b.last) - Date.parse(a.last));
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/** The one number that says how bad an occurrence was, with its unit. */
|
|
251
|
+
function worstOf(r) {
|
|
252
|
+
switch (r.type) {
|
|
253
|
+
case 'hitch': return typeof r.frameMs === 'number' ? { value: r.frameMs, unit: 'ms' } : undefined;
|
|
254
|
+
case 'usermark': return typeof r.worstFrames?.[0]?.frameMs === 'number' ? { value: r.worstFrames[0].frameMs, unit: 'ms' } : undefined;
|
|
255
|
+
case 'jitter': { const v = r.kind === 'oscillation' ? r.amplitude : r.units; return typeof v === 'number' ? { value: v, unit: 'u' } : undefined; }
|
|
256
|
+
case 'warm': return typeof r.worstBatchMs === 'number' ? { value: r.worstBatchMs, unit: 'ms' } : undefined;
|
|
257
|
+
case 'gpu-stall': return typeof r.queueDoneMs === 'number' ? { value: r.queueDoneMs, unit: 'ms' } : undefined;
|
|
258
|
+
case 'gpu-settle': return typeof r.ms === 'number' ? { value: r.ms, unit: 'ms' } : undefined;
|
|
259
|
+
default: return undefined;
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** "X ago", the way a human reads a last occurrence. */
|
|
264
|
+
export function agoText(ms) {
|
|
265
|
+
if (!(ms >= 0)) return '';
|
|
266
|
+
const s = Math.round(ms / 1000);
|
|
267
|
+
if (s < 60) return `${s}s ago`;
|
|
268
|
+
const m = Math.round(s / 60);
|
|
269
|
+
if (m < 60) return `${m}m ago`;
|
|
270
|
+
const h = Math.round(m / 60);
|
|
271
|
+
if (h < 48) return `${h}h ago`;
|
|
272
|
+
return `${Math.round(h / 24)}d ago`;
|
|
273
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// sloptimize — public in-page runtime surface (SPEC §1).
|
|
2
|
+
export { createRecorder } from './recorder.js';
|
|
3
|
+
export { buildCensus } from './census.js';
|
|
4
|
+
export { classifyHitch } from './classify.js';
|
|
5
|
+
export { createMotionMonitor } from './motion.js';
|
|
6
|
+
export { footprintOf, footprintKey, describeFootprint, canonicalContext, contextOfKey, FOOTPRINT_VERSION } from './footprint.js';
|
|
7
|
+
export { buildHistory, summarizeWindow, buildFix, latestBuilds, buildIssues, agoText } from './history.js';
|
|
8
|
+
export { createPanel } from './panel.js';
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// ============================================================
|
|
2
|
+
// inject-body.js — the tier-0 in-page recorder (SPEC-attach §3)
|
|
3
|
+
// ============================================================
|
|
4
|
+
// Runs INSIDE the target page, injected over CDP before any page script.
|
|
5
|
+
// Self-contained by construction: attach.mjs concatenates classify.js
|
|
6
|
+
// (exports stripped) above this file and wraps both in an IIFE — there are
|
|
7
|
+
// no imports here, and `classifyHitch` arrives from that concatenation.
|
|
8
|
+
// Everything fails soft: a page with no WebGPU, no WebGL, or no rAF still
|
|
9
|
+
// records frame timing; a page that never renders records nothing and
|
|
10
|
+
// costs nothing.
|
|
11
|
+
//
|
|
12
|
+
// Outbound edge: `__sloptimizeEmit(jsonLine)` — a CDP binding the attach
|
|
13
|
+
// process registered. One JSON record per call; the node side owns files,
|
|
14
|
+
// clustering, and the profiler.
|
|
15
|
+
|
|
16
|
+
/* global classifyHitch, __sloptimizeEmit */
|
|
17
|
+
|
|
18
|
+
const RING = 600;
|
|
19
|
+
const frameMsRing = new Float64Array(RING);
|
|
20
|
+
let head = 0, count = 0, frameNo = 0;
|
|
21
|
+
let lastRaf = -1;
|
|
22
|
+
let medianCache = 16.7, medianStale = 0;
|
|
23
|
+
|
|
24
|
+
// Per-frame graphics-API counters, reset at each rAF boundary.
|
|
25
|
+
const gpu = { draws: 0, triangles: 0, creates: 0, uploadKB: 0 };
|
|
26
|
+
let sessionCreates = 0;
|
|
27
|
+
|
|
28
|
+
function emit(obj) {
|
|
29
|
+
try { __sloptimizeEmit(JSON.stringify(obj)); } catch { /* binding gone */ }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rollingMedian() {
|
|
33
|
+
if (--medianStale > 0) return medianCache;
|
|
34
|
+
const vals = [];
|
|
35
|
+
for (let i = 0; i < count; i++) vals.push(frameMsRing[i]);
|
|
36
|
+
vals.sort((a, b) => a - b);
|
|
37
|
+
medianCache = vals.length ? vals[Math.floor(vals.length / 2)] : 16.7;
|
|
38
|
+
medianStale = 60;
|
|
39
|
+
return medianCache;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ── Graphics-API wraps: the engine-free counters ────────────────────────────
|
|
43
|
+
try {
|
|
44
|
+
if (typeof GPURenderPassEncoder !== 'undefined') {
|
|
45
|
+
const rp = GPURenderPassEncoder.prototype;
|
|
46
|
+
const d = rp.draw, di = rp.drawIndexed;
|
|
47
|
+
rp.draw = function (v, ...a) { gpu.draws++; gpu.triangles += Math.floor((v ?? 0) / 3) * ((a[0] ?? 1)); return d.call(this, v, ...a); };
|
|
48
|
+
rp.drawIndexed = function (n, ...a) { gpu.draws++; gpu.triangles += Math.floor((n ?? 0) / 3) * ((a[0] ?? 1)); return di.call(this, n, ...a); };
|
|
49
|
+
}
|
|
50
|
+
if (typeof GPUDevice !== 'undefined') {
|
|
51
|
+
const dp = GPUDevice.prototype;
|
|
52
|
+
for (const fn of ['createRenderPipeline', 'createRenderPipelineAsync', 'createComputePipeline', 'createShaderModule']) {
|
|
53
|
+
const orig = dp[fn];
|
|
54
|
+
if (typeof orig !== 'function') continue;
|
|
55
|
+
dp[fn] = function (...a) {
|
|
56
|
+
gpu.creates++; sessionCreates++;
|
|
57
|
+
const t0 = performance.now();
|
|
58
|
+
try { return orig.apply(this, a); }
|
|
59
|
+
finally {
|
|
60
|
+
const ms = performance.now() - t0;
|
|
61
|
+
// The creation LEDGER: rare, so a stack per creation is affordable,
|
|
62
|
+
// and it is the engine-free answer to "who compiled this?" —
|
|
63
|
+
// sourcemapped, it names the construction site.
|
|
64
|
+
if (sessionCreates <= 500) {
|
|
65
|
+
emit({ type: 'gpu-create', at: new Date().toISOString(), fn, ms: +ms.toFixed(2),
|
|
66
|
+
label: a[0] && a[0].label ? String(a[0].label).slice(0, 80) : undefined,
|
|
67
|
+
stack: (new Error().stack || '').split('\n').slice(2, 7).join('\n') });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (typeof GPUQueue !== 'undefined') {
|
|
74
|
+
const wb = GPUQueue.prototype.writeBuffer;
|
|
75
|
+
GPUQueue.prototype.writeBuffer = function (...a) {
|
|
76
|
+
const data = a[2];
|
|
77
|
+
if (data && data.byteLength) gpu.uploadKB += data.byteLength / 1024;
|
|
78
|
+
return wb.apply(this, a);
|
|
79
|
+
};
|
|
80
|
+
// Queue latency: submit→done wall time for the first 300 frames — seconds
|
|
81
|
+
// here inside a frame gap = the GPU process is the stall.
|
|
82
|
+
const sub = GPUQueue.prototype.submit;
|
|
83
|
+
let probes = 0;
|
|
84
|
+
GPUQueue.prototype.submit = function (...a) {
|
|
85
|
+
const r = sub.apply(this, a);
|
|
86
|
+
if (probes < 300 && typeof this.onSubmittedWorkDone === 'function') {
|
|
87
|
+
probes++;
|
|
88
|
+
const t0 = performance.now();
|
|
89
|
+
try { this.onSubmittedWorkDone().then(() => {
|
|
90
|
+
const ms = performance.now() - t0;
|
|
91
|
+
if (ms > 50) emit({ type: 'gpu-queue-lag', at: new Date().toISOString(), ms: +ms.toFixed(1) });
|
|
92
|
+
}); } catch { /* fine */ }
|
|
93
|
+
}
|
|
94
|
+
return r;
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
// WebGL fallback counters — same shape, older API.
|
|
98
|
+
for (const ctxName of ['WebGL2RenderingContext', 'WebGLRenderingContext']) {
|
|
99
|
+
const C = globalThis[ctxName];
|
|
100
|
+
if (!C) continue;
|
|
101
|
+
const de = C.prototype.drawElements, da = C.prototype.drawArrays;
|
|
102
|
+
C.prototype.drawElements = function (m, n, ...a) { gpu.draws++; gpu.triangles += Math.floor(n / 3); return de.call(this, m, n, ...a); };
|
|
103
|
+
C.prototype.drawArrays = function (m, f, n) { gpu.draws++; gpu.triangles += Math.floor(n / 3); return da.call(this, m, f, n); };
|
|
104
|
+
}
|
|
105
|
+
} catch (e) { emit({ type: 'wrap-error', error: String(e) }); }
|
|
106
|
+
|
|
107
|
+
// ── Long tasks: the JS half of attribution the profiler completes ───────────
|
|
108
|
+
let longTaskMs = 0;
|
|
109
|
+
try {
|
|
110
|
+
new PerformanceObserver((list) => {
|
|
111
|
+
for (const e of list.getEntries()) longTaskMs += e.duration;
|
|
112
|
+
}).observe({ type: 'longtask', buffered: true });
|
|
113
|
+
} catch { /* unsupported */ }
|
|
114
|
+
|
|
115
|
+
// ── The frame loop: detection lives HERE (SPEC v2 §2) ───────────────────────
|
|
116
|
+
function tick(ts) {
|
|
117
|
+
requestAnimationFrame(tick);
|
|
118
|
+
if (lastRaf < 0) { lastRaf = ts; return; }
|
|
119
|
+
const frameMs = ts - lastRaf;
|
|
120
|
+
lastRaf = ts;
|
|
121
|
+
frameMsRing[head] = frameMs;
|
|
122
|
+
head = (head + 1) % RING;
|
|
123
|
+
if (count < RING) count++;
|
|
124
|
+
frameNo++;
|
|
125
|
+
|
|
126
|
+
const draws = gpu.draws, tris = gpu.triangles, creates = gpu.creates, upKB = gpu.uploadKB;
|
|
127
|
+
const lt = longTaskMs;
|
|
128
|
+
gpu.draws = 0; gpu.triangles = 0; gpu.creates = 0; gpu.uploadKB = 0; longTaskMs = 0;
|
|
129
|
+
|
|
130
|
+
const median = rollingMedian();
|
|
131
|
+
if (count > 60 && frameMs > Math.max(2 * median, 25)) {
|
|
132
|
+
emit({
|
|
133
|
+
type: 'hitch', at: new Date().toISOString(), frame: frameNo,
|
|
134
|
+
frameMs: +frameMs.toFixed(1), medianMs: +median.toFixed(2),
|
|
135
|
+
// insideRenderMs is unknowable at this tier without the engine; the
|
|
136
|
+
// draw share and long-task ms are the honest stand-ins, and the node
|
|
137
|
+
// side attaches profiler topFrames.
|
|
138
|
+
longTaskMs: +lt.toFixed(1),
|
|
139
|
+
delta: { calls: draws, triangles: tris, programs: creates, textures: 0, geometries: 0 },
|
|
140
|
+
gpu: { uploadKB: +upKB.toFixed(1) },
|
|
141
|
+
classification: classifyHitch({ frameMs, medianMs: median, insideRenderMs: 0, delta: { programs: creates }, spawned: 0 }),
|
|
142
|
+
tier: 0,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (frameNo % 120 === 0) {
|
|
146
|
+
emit({ type: 'profile', at: new Date().toISOString(),
|
|
147
|
+
frame: { medianMs: +median.toFixed(2) },
|
|
148
|
+
render: { calls: draws, triangles: tris }, tier: 0 });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
requestAnimationFrame(tick);
|
|
152
|
+
emit({ type: 'armed', at: new Date().toISOString(), url: location.href });
|