@atolljs/devtools 0.1.7 → 0.1.9
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/README.md +408 -71
- package/app/api.js +68 -0
- package/app/fw-logos.js +14 -0
- package/app/index.html +161 -2
- package/app/main.js +897 -222
- package/app/panels/actions.js +324 -0
- package/app/panels/audit-rules.js +728 -0
- package/app/panels/audits.js +438 -0
- package/app/panels/inspect-island.js +351 -0
- package/app/panels/memory-values.js +287 -0
- package/app/panels/performance.js +304 -0
- package/app/panels/reactivity.js +271 -0
- package/app/panels/recorder.js +344 -0
- package/app/panels/recording-format.js +166 -0
- package/app/panels/route.d.ts +44 -0
- package/app/panels/route.js +264 -0
- package/app/panels/shell.js +789 -0
- package/app/panels/toast.js +38 -0
- package/dist/bin.js +704 -5
- package/dist/chunk-57NGHQUL.js +344 -0
- package/dist/chunk-6E54FXKS.js +495 -0
- package/dist/chunk-WIL2P7HG.js +661 -0
- package/dist/client.d.ts +7 -0
- package/dist/control.d.ts +4 -0
- package/dist/index.js +2 -1
- package/dist/node.js +2 -1
- package/dist/nodeWs.d.ts +1 -0
- package/dist/otel.d.ts +35 -0
- package/dist/otel.js +87 -0
- package/dist/otelMap.d.ts +232 -0
- package/dist/otelTransport.d.ts +69 -0
- package/dist/overlay.d.ts +16 -2
- package/dist/probes.d.ts +7 -0
- package/dist/protocol.d.ts +46 -4
- package/dist/server.d.ts +19 -0
- package/dist/server.js +61 -3
- package/package.json +6 -2
- package/src/bin.ts +27 -2
- package/src/broadcast.ts +6 -0
- package/src/client.ts +26 -2
- package/src/control.ts +51 -0
- package/src/nodeWs.ts +7 -1
- package/src/otel.ts +140 -0
- package/src/otelMap.ts +661 -0
- package/src/otelTransport.ts +286 -0
- package/src/overlay.ts +239 -47
- package/src/probes.ts +155 -0
- package/src/protocol.ts +52 -4
- package/src/server.ts +83 -3
- package/dist/chunk-4N3NBFL3.js +0 -474
|
@@ -0,0 +1,728 @@
|
|
|
1
|
+
// Audit rules for the devtools dashboard: a PURE module (no DOM, no api,
|
|
2
|
+
// no clock reads). `runAudits(snapshot)` turns windowed aggregates into
|
|
3
|
+
// findings; ./audits.js builds the snapshot from the live event stream and
|
|
4
|
+
// renders the result. Tests feed synthetic snapshots straight in.
|
|
5
|
+
//
|
|
6
|
+
// Time basis: every `t` in a snapshot is the DASHBOARD clock
|
|
7
|
+
// (performance.now() at ingest), never an event's `at` — emit threads have
|
|
8
|
+
// unrelated performance.now() epochs, so rate windows only make sense on
|
|
9
|
+
// the receiving clock. `snapshot.now` is that clock at snapshot time.
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {'error' | 'warn' | 'info'} Severity
|
|
13
|
+
* @typedef {{ kind: 'worker' | 'island' | 'pool' | 'session', key: string, label: string }} Entity
|
|
14
|
+
* @typedef {{
|
|
15
|
+
* id: string, rule: string, severity: Severity, title: string, detail: string,
|
|
16
|
+
* fix: string, docs?: { label: string, href: string }, entities?: Entity[],
|
|
17
|
+
* }} Finding
|
|
18
|
+
*
|
|
19
|
+
* @typedef {{ t: number, v: number }} Sample dashboard-clock sample
|
|
20
|
+
* @typedef {{
|
|
21
|
+
* id: string, name?: string, runtime?: 'browser' | 'node', closed?: boolean,
|
|
22
|
+
* env?: { crossOriginIsolated?: boolean, sharedArrayBuffer?: boolean,
|
|
23
|
+
* hardwareConcurrency?: number, userAgent?: string, node?: string },
|
|
24
|
+
* heap?: number, heapLimit?: number,
|
|
25
|
+
* longframes: { t: number, ms: number, blockingMs: number, scripts?: { src?: string, fn?: string, ms: number }[], hidden?: boolean }[],
|
|
26
|
+
* frames: { t: number, fps: number, dropped: number }[],
|
|
27
|
+
* }} SessionAgg
|
|
28
|
+
* @typedef {{
|
|
29
|
+
* key: string, sid: string, poolId: string, label?: string,
|
|
30
|
+
* initSeen: boolean, poolSize?: number, concurrency?: number, dedicated?: boolean,
|
|
31
|
+
* dead?: boolean, firstAt: number, taskEvents: number,
|
|
32
|
+
* dispatches: { t: number, waitMs: number }[],
|
|
33
|
+
* runs: { t: number, runMs: number }[],
|
|
34
|
+
* backlog: number, backlogSeries: Sample[],
|
|
35
|
+
* }} PoolAgg key = `${sid}|${poolId}`
|
|
36
|
+
* @typedef {{
|
|
37
|
+
* key: string, sid: string, poolId: string, taskId: string,
|
|
38
|
+
* settles: { t: number, outcome: string, error?: string }[],
|
|
39
|
+
* argBytes: Sample[], resultBytes: Sample[],
|
|
40
|
+
* }} TaskAgg key = `${sid}|${poolId}|${taskId}`
|
|
41
|
+
* @typedef {{
|
|
42
|
+
* key: string, sid: string, poolId: string, slot: number,
|
|
43
|
+
* respawns: number[], heap?: number, heapLimit?: number, lastError?: string,
|
|
44
|
+
* }} WorkerAgg key = `${sid}|${poolId}|${slot}`
|
|
45
|
+
* @typedef {{
|
|
46
|
+
* key: string, sid: string, instance: string, app?: string, framework?: string,
|
|
47
|
+
* poolId?: string, mounted: boolean, ended: boolean,
|
|
48
|
+
* batches: { t: number, count: number, bytes?: number, replayMs?: number }[],
|
|
49
|
+
* }} IslandAgg key = `${sid}|${instance}`
|
|
50
|
+
* @typedef {{ key: string, sid: string, path: string, perSec: Sample[] }} MemFieldAgg
|
|
51
|
+
* perSec: t = second start (ms), v = writes
|
|
52
|
+
* @typedef {{ sid: string, t: number, url: string, method: string, status?: number, ms: number, error?: string }} FetchAgg
|
|
53
|
+
* @typedef {{
|
|
54
|
+
* now: number, sessions: SessionAgg[], pools: PoolAgg[], tasks: TaskAgg[],
|
|
55
|
+
* workers: WorkerAgg[], islands: IslandAgg[], memFields: MemFieldAgg[], fetches: FetchAgg[],
|
|
56
|
+
* }} AuditSnapshot
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/* ── thresholds ──────────────────────────────────────────────────────────── */
|
|
60
|
+
|
|
61
|
+
/** Default analysis window: most rules look at the last 60s of live data. */
|
|
62
|
+
export const WINDOW_MS = 60_000;
|
|
63
|
+
/** Task args/results whose p95 or average clone size exceeds this are "large". */
|
|
64
|
+
export const LARGE_MESSAGE_BYTES = 64 * 1024;
|
|
65
|
+
/** Calls needed before a payload-size verdict. */
|
|
66
|
+
export const MIN_MESSAGE_SAMPLES = 3;
|
|
67
|
+
/** Island op batches (estimated clone bytes, p95) above this are "large". */
|
|
68
|
+
export const ISLAND_BATCH_BYTES = 128 * 1024;
|
|
69
|
+
/** Main-thread replay p95 above one 60Hz frame budget is "slow". */
|
|
70
|
+
export const ISLAND_REPLAY_MS = 16;
|
|
71
|
+
/** Batches needed before an island batch verdict. */
|
|
72
|
+
export const MIN_ISLAND_BATCHES = 3;
|
|
73
|
+
/** Saturation: queue wait p95 must exceed this AND the run p95. */
|
|
74
|
+
export const SATURATION_WAIT_MS = 50;
|
|
75
|
+
/** Dispatches needed before a wait-vs-run verdict. */
|
|
76
|
+
export const MIN_DISPATCHES = 20;
|
|
77
|
+
/** Saturation: a backlog at least this deep that grew by BACKLOG_GROWTH… */
|
|
78
|
+
export const BACKLOG_MIN = 20;
|
|
79
|
+
export const BACKLOG_GROWTH = 10;
|
|
80
|
+
/** …over this window. */
|
|
81
|
+
export const BACKLOG_WINDOW_MS = 10_000;
|
|
82
|
+
/** Underused: busy fraction (sum runMs / observed time × workers) below this. */
|
|
83
|
+
export const UNDERUSED_UTILIZATION = 0.05;
|
|
84
|
+
/** Underused: the pool must have been observed at least this long. */
|
|
85
|
+
export const UNDERUSED_MIN_OBSERVED_MS = 30_000;
|
|
86
|
+
/** Total blocking time in the window: warn at / error at. */
|
|
87
|
+
export const TBT_WARN_MS = 300;
|
|
88
|
+
export const TBT_ERROR_MS = 1000;
|
|
89
|
+
/** Background work: script ms in hidden-page long frames over the window. */
|
|
90
|
+
export const BG_WORK_MS = 1000;
|
|
91
|
+
/** Low fps: average below this over FPS_WINDOW_MS (needs MIN_FPS_SAMPLES). */
|
|
92
|
+
export const LOW_FPS = 45;
|
|
93
|
+
export const FPS_WINDOW_MS = 10_000;
|
|
94
|
+
export const MIN_FPS_SAMPLES = 3;
|
|
95
|
+
/** Error rate (error + crashed) per task id: warn at / error at. */
|
|
96
|
+
export const ERROR_RATE_WARN = 0.1;
|
|
97
|
+
export const ERROR_RATE_ERROR = 0.5;
|
|
98
|
+
/** Timeout rate per task id: warn at. */
|
|
99
|
+
export const TIMEOUT_RATE_WARN = 0.05;
|
|
100
|
+
/** Settled calls needed before a rate verdict. */
|
|
101
|
+
export const MIN_SETTLES = 5;
|
|
102
|
+
/** Crash loop: this many respawns of one slot within CRASH_LOOP_WINDOW_MS. */
|
|
103
|
+
export const CRASH_LOOP_RESPAWNS = 3;
|
|
104
|
+
export const CRASH_LOOP_WINDOW_MS = 60_000;
|
|
105
|
+
/** Heap usage / limit: warn at / error at. */
|
|
106
|
+
export const HEAP_WARN = 0.8;
|
|
107
|
+
export const HEAP_ERROR = 0.95;
|
|
108
|
+
/** Write storm: average writes/s for one field over WRITE_STORM_WINDOW_MS. */
|
|
109
|
+
export const WRITE_STORM_PER_SEC = 1000;
|
|
110
|
+
export const WRITE_STORM_WINDOW_MS = 5_000;
|
|
111
|
+
/** Slow fetches: p95 above this (needs MIN_FETCHES). */
|
|
112
|
+
export const FETCH_SLOW_MS = 2000;
|
|
113
|
+
export const MIN_FETCHES = 3;
|
|
114
|
+
|
|
115
|
+
/* ── docs links (GitHub blobs of this repo's docs/*.md) ──────────────────── */
|
|
116
|
+
|
|
117
|
+
const GH = 'https://github.com/jwhenry3/atolljs/blob/main/docs/';
|
|
118
|
+
const DOCS = {
|
|
119
|
+
coi: { label: 'Cross-origin isolation', href: `${GH}cross-origin-isolation.md` },
|
|
120
|
+
sharedMemory: { label: 'Shared memory contracts', href: `${GH}shared-memory.md` },
|
|
121
|
+
connectors: { label: 'Shared memory: connectors', href: `${GH}shared-memory.md#connectors` },
|
|
122
|
+
islands: { label: 'Islands', href: `${GH}islands.md` },
|
|
123
|
+
islandOptions: { label: 'mountIsland options', href: `${GH}islands.md#mountisland-options` },
|
|
124
|
+
howMany: { label: 'How many workers', href: `${GH}tasks-and-pool.md#how-many-workers` },
|
|
125
|
+
config: { label: 'connectWorker config', href: `${GH}tasks-and-pool.md#connectworker-config` },
|
|
126
|
+
tasks: { label: 'Worker pool & tasks', href: `${GH}tasks-and-pool.md` },
|
|
127
|
+
devtoolsInvariants: { label: 'Devtools: invariants', href: `${GH}devtools.md#invariants` },
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/* ── rule catalog (the UI's "what data each rule needs" list) ───────────── */
|
|
131
|
+
|
|
132
|
+
/** @type {{ id: string, title: string, needs: string }[]} */
|
|
133
|
+
export const RULES = [
|
|
134
|
+
{ id: 'coi-islands', title: 'Islands on a non-isolated page', needs: 'session env.crossOriginIsolated (hello) + island:mount' },
|
|
135
|
+
{ id: 'large-args', title: 'Large task arguments', needs: 'task:dispatch argBytes' },
|
|
136
|
+
{ id: 'large-results', title: 'Large task results', needs: 'task:settle resultBytes' },
|
|
137
|
+
{ id: 'island-batch-bytes', title: 'Large island op batches', needs: 'island:ops bytes' },
|
|
138
|
+
{ id: 'island-slow-replay', title: 'Slow island replay', needs: 'island:ops replayMs' },
|
|
139
|
+
{ id: 'pool-saturated', title: 'Pool saturation', needs: 'task:dispatch waitMs, task:settle runMs, task:enqueue/dispatch backlog' },
|
|
140
|
+
{ id: 'pool-underused', title: 'Underused pool', needs: 'pool:init poolSize + task:settle runMs over ≥30s' },
|
|
141
|
+
{ id: 'pool-oversized', title: 'More workers than cores', needs: 'pool:init poolSize + session env.hardwareConcurrency' },
|
|
142
|
+
{ id: 'main-jank', title: 'Main-thread blocking', needs: 'runtime:longframe (visible page)' },
|
|
143
|
+
{ id: 'background-work', title: 'Heavy work while hidden', needs: 'runtime:longframe hidden' },
|
|
144
|
+
{ id: 'low-fps', title: 'Low frame rate', needs: 'runtime:frames' },
|
|
145
|
+
{ id: 'task-errors', title: 'High task error rate', needs: 'task:settle outcome' },
|
|
146
|
+
{ id: 'task-timeouts', title: 'Task timeouts', needs: 'task:settle outcome' },
|
|
147
|
+
{ id: 'queue-full', title: 'Queue-full rejections', needs: "task:settle outcome 'queue-full'" },
|
|
148
|
+
{ id: 'crash-loop', title: 'Worker crash loop', needs: 'worker:respawn' },
|
|
149
|
+
{ id: 'heap-pressure', title: 'Heap near limit', needs: 'runtime:memory heapLimitBytes (Chromium performance.memory)' },
|
|
150
|
+
{ id: 'devtools-late', title: 'Pool spawned before devtools', needs: 'task:* without pool:init' },
|
|
151
|
+
{ id: 'memory-write-storm', title: 'Shared-memory write storm', needs: 'memory:write' },
|
|
152
|
+
{ id: 'fetch-failing', title: 'Failing requests', needs: 'net:fetch status/error' },
|
|
153
|
+
{ id: 'fetch-slow', title: 'Slow requests', needs: 'net:fetch ms' },
|
|
154
|
+
{ id: 'island-untagged', title: 'Island without a framework tag', needs: 'island:mount framework' },
|
|
155
|
+
];
|
|
156
|
+
|
|
157
|
+
/* ── helpers ─────────────────────────────────────────────────────────────── */
|
|
158
|
+
|
|
159
|
+
const SEV_ORDER = { error: 0, warn: 1, info: 2 };
|
|
160
|
+
|
|
161
|
+
/** Nearest-rank percentile; undefined for an empty list. */
|
|
162
|
+
export function percentile(values, p) {
|
|
163
|
+
if (!values.length) return undefined;
|
|
164
|
+
const s = [...values].sort((a, b) => a - b);
|
|
165
|
+
return s[Math.min(s.length - 1, Math.max(0, Math.ceil((p / 100) * s.length) - 1))];
|
|
166
|
+
}
|
|
167
|
+
const avg = (values) => (values.length ? values.reduce((a, b) => a + b, 0) / values.length : 0);
|
|
168
|
+
const within = (arr, now, ms) => arr.filter((x) => now - x.t <= ms);
|
|
169
|
+
|
|
170
|
+
export const fmtBytes = (b) =>
|
|
171
|
+
b >= 1 << 20 ? `${(b / (1 << 20)).toFixed(1)}MB` : b >= 1024 ? `${(b / 1024).toFixed(1)}KB` : `${Math.round(b)}B`;
|
|
172
|
+
const ms = (n) => `${n >= 100 ? Math.round(n) : n.toFixed(1)}ms`;
|
|
173
|
+
const pct = (f) => `${Math.round(f * 100)}%`;
|
|
174
|
+
const plural = (n, word) => `${n} ${word}${n === 1 ? '' : /(s|x|z|ch|sh)$/.test(word) ? 'es' : 's'}`;
|
|
175
|
+
|
|
176
|
+
const poolLabel = (p) => (p?.label && p.label !== p.poolId ? `${p.label} (${p.poolId})` : p?.poolId ?? '?');
|
|
177
|
+
const sessLabel = (s) => s?.name ?? s?.id ?? '?';
|
|
178
|
+
|
|
179
|
+
const poolEntity = (sid, poolId, label) => ({ kind: 'pool', key: `${sid}|${poolId}`, label: label ?? poolId });
|
|
180
|
+
const sessionEntity = (s) => ({ kind: 'session', key: s.id, label: sessLabel(s) });
|
|
181
|
+
|
|
182
|
+
/* ── rules ───────────────────────────────────────────────────────────────── */
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Run every rule over a snapshot. Findings sort error → warn → info, then
|
|
186
|
+
* by rule order. `opts.muted` (Set or array of rule ids) drops those rules.
|
|
187
|
+
* @param {AuditSnapshot} snap
|
|
188
|
+
* @param {{ muted?: Iterable<string> }} [opts]
|
|
189
|
+
* @returns {Finding[]}
|
|
190
|
+
*/
|
|
191
|
+
export function runAudits(snap, opts = {}) {
|
|
192
|
+
const muted = new Set(opts.muted ?? []);
|
|
193
|
+
const now = snap.now;
|
|
194
|
+
const sessions = new Map((snap.sessions ?? []).map((s) => [s.id, s]));
|
|
195
|
+
const pools = new Map((snap.pools ?? []).map((p) => [p.key, p]));
|
|
196
|
+
const out = [];
|
|
197
|
+
const add = (f) => { if (!muted.has(f.rule)) out.push(f); };
|
|
198
|
+
|
|
199
|
+
/* coi-islands ─ islands mounted on a page without crossOriginIsolated */
|
|
200
|
+
for (const s of sessions.values()) {
|
|
201
|
+
if (s.runtime === 'node' || s.env?.crossOriginIsolated !== false) continue;
|
|
202
|
+
const live = (snap.islands ?? []).filter((i) => i.sid === s.id && i.mounted && !i.ended);
|
|
203
|
+
if (!live.length) continue;
|
|
204
|
+
const noSab = s.env.sharedArrayBuffer === false;
|
|
205
|
+
add({
|
|
206
|
+
id: `coi-islands:${s.id}`,
|
|
207
|
+
rule: 'coi-islands',
|
|
208
|
+
severity: 'warn',
|
|
209
|
+
title: `${plural(live.length, 'island')} polling: page is not cross-origin isolated`,
|
|
210
|
+
detail:
|
|
211
|
+
`${sessLabel(s)} reports crossOriginIsolated = false${noSab ? ' and no SharedArrayBuffer' : ''} with ` +
|
|
212
|
+
`${plural(live.length, 'mounted island')} (${live.slice(0, 6).map((i) => i.instance).join(', ')}${live.length > 6 ? ', …' : ''}). ` +
|
|
213
|
+
'The push doorbell needs SharedArrayBuffer, so these islands drain ops on the 50ms poll, and no pool on this page ' +
|
|
214
|
+
'can bind a defineSharedMemory contract.',
|
|
215
|
+
fix:
|
|
216
|
+
'Serve the page with `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` ' +
|
|
217
|
+
'(worker script responses need the same COEP; hosts that cannot set headers can use a header-injecting service worker). ' +
|
|
218
|
+
"If isolation is not possible, make the fallback explicit: `mountIsland({ mode: 'poll' })` or " +
|
|
219
|
+
'`connectIslandWorker({ doorbell: false })`.',
|
|
220
|
+
docs: DOCS.coi,
|
|
221
|
+
entities: [sessionEntity(s), ...live.slice(0, 8).map((i) => ({ kind: 'island', key: i.key, label: i.instance }))],
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/* large-args / large-results ─ per task id payload size */
|
|
226
|
+
for (const t of snap.tasks ?? []) {
|
|
227
|
+
const p = pools.get(`${t.sid}|${t.poolId}`);
|
|
228
|
+
for (const [rule, series, what] of [
|
|
229
|
+
['large-args', t.argBytes, 'arguments'],
|
|
230
|
+
['large-results', t.resultBytes, 'results'],
|
|
231
|
+
]) {
|
|
232
|
+
const vals = within(series ?? [], now, WINDOW_MS).map((x) => x.v);
|
|
233
|
+
if (vals.length < MIN_MESSAGE_SAMPLES) continue;
|
|
234
|
+
const p95 = percentile(vals, 95);
|
|
235
|
+
const mean = avg(vals);
|
|
236
|
+
if (p95 <= LARGE_MESSAGE_BYTES && mean <= LARGE_MESSAGE_BYTES) continue;
|
|
237
|
+
add({
|
|
238
|
+
id: `${rule}:${t.key}`,
|
|
239
|
+
rule,
|
|
240
|
+
severity: 'warn',
|
|
241
|
+
title: `Large ${what} for task "${t.taskId}"`,
|
|
242
|
+
detail:
|
|
243
|
+
`p95 ${fmtBytes(p95)}, avg ${fmtBytes(mean)}, max ${fmtBytes(Math.max(...vals))} over ${plural(vals.length, 'call')} ` +
|
|
244
|
+
`in the last ${WINDOW_MS / 1000}s on ${poolLabel(p ?? { poolId: t.poolId })} (threshold ${fmtBytes(LARGE_MESSAGE_BYTES)}). ` +
|
|
245
|
+
`Every call structured-clones these ${what} across postMessage.`,
|
|
246
|
+
fix:
|
|
247
|
+
'Keep bulk data in shared memory: declare it in a `defineSharedMemory` contract (e.g. `field.float64Array({ length })` ' +
|
|
248
|
+
'or `field.list({ schema, count })`), pass it as `connectWorker({ sharedMemory })`, and ' +
|
|
249
|
+
(what === 'arguments'
|
|
250
|
+
? 'send only ids/indices as task arguments.'
|
|
251
|
+
: 'have the worker write its output into the contract and return a small summary.'),
|
|
252
|
+
docs: DOCS.sharedMemory,
|
|
253
|
+
entities: [poolEntity(t.sid, t.poolId, p?.label)],
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/* island-batch-bytes / island-slow-replay */
|
|
259
|
+
for (const i of snap.islands ?? []) {
|
|
260
|
+
const recent = within(i.batches ?? [], now, WINDOW_MS);
|
|
261
|
+
const ent = [{ kind: 'island', key: i.key, label: i.instance }];
|
|
262
|
+
const bytes = recent.filter((b) => b.bytes !== undefined).map((b) => b.bytes);
|
|
263
|
+
if (bytes.length >= MIN_ISLAND_BATCHES) {
|
|
264
|
+
const p95 = percentile(bytes, 95);
|
|
265
|
+
if (p95 > ISLAND_BATCH_BYTES) {
|
|
266
|
+
add({
|
|
267
|
+
id: `island-batch-bytes:${i.key}`,
|
|
268
|
+
rule: 'island-batch-bytes',
|
|
269
|
+
severity: 'warn',
|
|
270
|
+
title: `Large op batches from island ${i.instance}`,
|
|
271
|
+
detail:
|
|
272
|
+
`p95 batch ${fmtBytes(p95)} (max ${fmtBytes(Math.max(...bytes))}) over ${plural(bytes.length, 'batch')}, ` +
|
|
273
|
+
`${Math.round(avg(recent.map((b) => b.count)))} ops per batch on average (threshold ${fmtBytes(ISLAND_BATCH_BYTES)}). ` +
|
|
274
|
+
'Op batches always ride postMessage, even with the doorbell.',
|
|
275
|
+
fix:
|
|
276
|
+
'Render less per commit: window long lists (mount only visible rows), avoid rebuilding whole subtrees on ' +
|
|
277
|
+
'`updateProps`, and keep bulk data in the worker (or a `defineSharedMemory` contract) instead of serializing it into the DOM.',
|
|
278
|
+
docs: DOCS.islands,
|
|
279
|
+
entities: ent,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
const replay = recent.filter((b) => b.replayMs !== undefined).map((b) => b.replayMs);
|
|
284
|
+
if (replay.length >= MIN_ISLAND_BATCHES) {
|
|
285
|
+
const p95 = percentile(replay, 95);
|
|
286
|
+
if (p95 > ISLAND_REPLAY_MS) {
|
|
287
|
+
add({
|
|
288
|
+
id: `island-slow-replay:${i.key}`,
|
|
289
|
+
rule: 'island-slow-replay',
|
|
290
|
+
severity: 'warn',
|
|
291
|
+
title: `Slow main-thread replay for island ${i.instance}`,
|
|
292
|
+
detail:
|
|
293
|
+
`Replay p95 ${ms(p95)} (max ${ms(Math.max(...replay))}) over ${plural(replay.length, 'batch')}, ` +
|
|
294
|
+
`${Math.round(avg(recent.map((b) => b.count)))} ops per batch on average: over the ${ISLAND_REPLAY_MS}ms frame budget.`,
|
|
295
|
+
fix:
|
|
296
|
+
'Replay cost scales with op count: emit fewer DOM mutations per commit (windowed lists, stable keys, no full re-renders ' +
|
|
297
|
+
'on `updateProps`). `mountIsland({ onOps: (ops, elapsedMs) => … })` reports per-batch replay cost while you tune.',
|
|
298
|
+
docs: DOCS.islandOptions,
|
|
299
|
+
entities: ent,
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/* pool rules: saturation, underuse, oversize, devtools-late */
|
|
306
|
+
for (const p of pools.values()) {
|
|
307
|
+
const s = sessions.get(p.sid);
|
|
308
|
+
const ent = [poolEntity(p.sid, p.poolId, p.label)];
|
|
309
|
+
const hc = s?.env?.hardwareConcurrency;
|
|
310
|
+
|
|
311
|
+
if (!p.initSeen && p.taskEvents > 0) {
|
|
312
|
+
add({
|
|
313
|
+
id: `devtools-late:${p.key}`,
|
|
314
|
+
rule: 'devtools-late',
|
|
315
|
+
severity: 'warn',
|
|
316
|
+
title: `Pool ${p.poolId} was spawned before devtools was enabled`,
|
|
317
|
+
detail:
|
|
318
|
+
`${plural(p.taskEvents, 'task event')} for ${p.poolId} but no pool:init. Its workers received devtools: false in the ` +
|
|
319
|
+
'INIT handshake, so worker-side events (heap, fetches, memory writes, logs) never forward. ' +
|
|
320
|
+
"(Also seen when the dashboard joined after the app's replay tail dropped pool:init.)",
|
|
321
|
+
fix:
|
|
322
|
+
'Call `initDevtools()` before any pool spawns: in the browser, before the first task call (or `start()`) on any ' +
|
|
323
|
+
"client; in Node, from a module imported first by the entry (`import './devtools'`), since ESM evaluates imports before the entry body.",
|
|
324
|
+
docs: DOCS.devtoolsInvariants,
|
|
325
|
+
entities: [...ent, ...(s ? [sessionEntity(s)] : [])],
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
if (!p.initSeen || p.dead) continue;
|
|
329
|
+
const size = p.poolSize ?? 0;
|
|
330
|
+
|
|
331
|
+
if (hc && size > hc) {
|
|
332
|
+
add({
|
|
333
|
+
id: `pool-oversized:${p.key}`,
|
|
334
|
+
rule: 'pool-oversized',
|
|
335
|
+
severity: 'warn',
|
|
336
|
+
title: `Pool ${poolLabel(p)} has more workers than cores`,
|
|
337
|
+
detail: `${size} workers on a machine reporting hardwareConcurrency = ${hc}. Extra workers contend for the same cores and each carries its own heap.`,
|
|
338
|
+
fix: `Use \`connectWorker({ workers: 'auto' })\` (navigator.hardwareConcurrency) or a count ≤ ${hc}.`,
|
|
339
|
+
docs: DOCS.howMany,
|
|
340
|
+
entities: ent,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
if (p.dedicated) continue; // no queue: waitMs is always 0, utilization of one thread is not a sizing signal
|
|
345
|
+
|
|
346
|
+
const disp = within(p.dispatches ?? [], now, WINDOW_MS);
|
|
347
|
+
const runs = within(p.runs ?? [], now, WINDOW_MS);
|
|
348
|
+
const waitP95 = percentile(disp.map((d) => d.waitMs), 95) ?? 0;
|
|
349
|
+
const runP95 = percentile(runs.map((r) => r.runMs), 95) ?? 0;
|
|
350
|
+
const series = within(p.backlogSeries ?? [], now, BACKLOG_WINDOW_MS);
|
|
351
|
+
const growth = series.length ? p.backlog - Math.min(...series.map((x) => x.v)) : 0;
|
|
352
|
+
const waitSat = disp.length >= MIN_DISPATCHES && waitP95 > SATURATION_WAIT_MS && waitP95 > runP95;
|
|
353
|
+
const backlogSat = p.backlog >= BACKLOG_MIN && growth >= BACKLOG_GROWTH;
|
|
354
|
+
if (waitSat || backlogSat) {
|
|
355
|
+
const conc = p.concurrency ?? 1;
|
|
356
|
+
const suggest = hc ? Math.min(size * 2, hc) : size * 2;
|
|
357
|
+
const atCores = hc !== undefined && size >= hc;
|
|
358
|
+
add({
|
|
359
|
+
id: `pool-saturated:${p.key}`,
|
|
360
|
+
rule: 'pool-saturated',
|
|
361
|
+
severity: 'warn',
|
|
362
|
+
title: `Pool ${poolLabel(p)} is saturated`,
|
|
363
|
+
detail:
|
|
364
|
+
(disp.length
|
|
365
|
+
? `Queue wait p95 ${ms(waitP95)} vs run p95 ${ms(runP95)} over ${plural(disp.length, 'dispatch')} in the last ${WINDOW_MS / 1000}s. `
|
|
366
|
+
: '') +
|
|
367
|
+
`Backlog: ${plural(p.backlog, 'queued call')}${growth > 0 ? ` (+${growth} in ${BACKLOG_WINDOW_MS / 1000}s)` : ''}. ` +
|
|
368
|
+
`${size} workers × concurrency ${conc}.`,
|
|
369
|
+
fix: atCores
|
|
370
|
+
? `The pool already matches the ${hc} reported cores, so more workers won't speed up CPU-bound tasks: shorten each task or ` +
|
|
371
|
+
'split work into more, smaller calls (a pool never splits one call; fan out with `Promise.all`). If tasks await I/O, raise ' +
|
|
372
|
+
'`concurrency`. Bound the queue with `maxQueue` so overload rejects fast (`PoolQueueFullError`).'
|
|
373
|
+
: `Add workers: \`connectWorker({ workers: ${suggest} })\`${hc ? ` (${hc} cores reported; \`'auto'\` uses navigator.hardwareConcurrency)` : ''}. ` +
|
|
374
|
+
`If tasks await I/O, raise \`concurrency\` (in-flight cap per worker, now ${conc}). Bound the queue with \`maxQueue\` ` +
|
|
375
|
+
'so overload rejects fast (`PoolQueueFullError`) instead of growing.',
|
|
376
|
+
docs: DOCS.config,
|
|
377
|
+
entities: ent,
|
|
378
|
+
});
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
const observed = Math.min(WINDOW_MS, now - p.firstAt);
|
|
382
|
+
if (size > 1 && observed >= UNDERUSED_MIN_OBSERVED_MS && !(waitP95 > SATURATION_WAIT_MS) && p.backlog === 0) {
|
|
383
|
+
const busy = runs.reduce((a, r) => a + r.runMs, 0);
|
|
384
|
+
const util = busy / (observed * size);
|
|
385
|
+
if (util < UNDERUSED_UTILIZATION) {
|
|
386
|
+
add({
|
|
387
|
+
id: `pool-underused:${p.key}`,
|
|
388
|
+
rule: 'pool-underused',
|
|
389
|
+
severity: 'info',
|
|
390
|
+
title: `Pool ${poolLabel(p)} is mostly idle`,
|
|
391
|
+
detail:
|
|
392
|
+
`${size} workers were ${(util * 100).toFixed(1)}% busy over the last ${Math.round(observed / 1000)}s ` +
|
|
393
|
+
`(${plural(runs.length, 'task')}, ${ms(busy)} total run time; threshold ${pct(UNDERUSED_UTILIZATION)}).`,
|
|
394
|
+
fix:
|
|
395
|
+
`\`connectWorker({ workers: 1 })\` builds a dedicated worker: no pool, no queue, one thread's memory instead of ${size}. ` +
|
|
396
|
+
'Keep the pool only if independent heavy calls must overlap during bursts.',
|
|
397
|
+
docs: DOCS.howMany,
|
|
398
|
+
entities: ent,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/* task-errors / task-timeouts / queue-full (per pool) */
|
|
405
|
+
const queueFull = new Map(); // poolKey → { n, tasks: Set }
|
|
406
|
+
for (const t of snap.tasks ?? []) {
|
|
407
|
+
const settles = within(t.settles ?? [], now, WINDOW_MS);
|
|
408
|
+
const p = pools.get(`${t.sid}|${t.poolId}`);
|
|
409
|
+
const ent = [poolEntity(t.sid, t.poolId, p?.label)];
|
|
410
|
+
const qf = settles.filter((x) => x.outcome === 'queue-full').length;
|
|
411
|
+
if (qf) {
|
|
412
|
+
const pk = `${t.sid}|${t.poolId}`;
|
|
413
|
+
const q = queueFull.get(pk) ?? { sid: t.sid, poolId: t.poolId, n: 0, tasks: new Set() };
|
|
414
|
+
q.n += qf; q.tasks.add(t.taskId);
|
|
415
|
+
queueFull.set(pk, q);
|
|
416
|
+
}
|
|
417
|
+
if (settles.length < MIN_SETTLES) continue;
|
|
418
|
+
const failed = settles.filter((x) => x.outcome === 'error' || x.outcome === 'crashed');
|
|
419
|
+
const errRate = failed.length / settles.length;
|
|
420
|
+
if (errRate >= ERROR_RATE_WARN) {
|
|
421
|
+
const last = [...failed].reverse().find((x) => x.error)?.error;
|
|
422
|
+
const crashed = failed.filter((x) => x.outcome === 'crashed').length;
|
|
423
|
+
add({
|
|
424
|
+
id: `task-errors:${t.key}`,
|
|
425
|
+
rule: 'task-errors',
|
|
426
|
+
severity: errRate >= ERROR_RATE_ERROR ? 'error' : 'warn',
|
|
427
|
+
title: `Task "${t.taskId}" fails ${pct(errRate)} of calls`,
|
|
428
|
+
detail:
|
|
429
|
+
`${failed.length} of ${settles.length} settled calls in the last ${WINDOW_MS / 1000}s ended in error` +
|
|
430
|
+
`${crashed ? ` (${crashed} crashed)` : ''} on ${poolLabel(p ?? { poolId: t.poolId })}` +
|
|
431
|
+
`${last ? `. Last error: "${last.slice(0, 160)}"` : '.'}`,
|
|
432
|
+
fix:
|
|
433
|
+
`Drill into "${t.taskId}" in Tasks for the failing calls. \`argsSchema\`/\`resultSchema\` validation rejects at the worker boundary; ` +
|
|
434
|
+
'`crashed` means the worker died mid-call (`WorkerCrashedError`), see the worker inspector.',
|
|
435
|
+
docs: DOCS.tasks,
|
|
436
|
+
entities: ent,
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
const timeouts = settles.filter((x) => x.outcome === 'timeout').length;
|
|
440
|
+
const toRate = timeouts / settles.length;
|
|
441
|
+
if (timeouts && toRate >= TIMEOUT_RATE_WARN) {
|
|
442
|
+
add({
|
|
443
|
+
id: `task-timeouts:${t.key}`,
|
|
444
|
+
rule: 'task-timeouts',
|
|
445
|
+
severity: 'warn',
|
|
446
|
+
title: `Task "${t.taskId}" times out on ${pct(toRate)} of calls`,
|
|
447
|
+
detail: `${timeouts} of ${settles.length} settled calls in the last ${WINDOW_MS / 1000}s hit TaskTimeoutError on ${poolLabel(p ?? { poolId: t.poolId })}.`,
|
|
448
|
+
fix:
|
|
449
|
+
'The timeout counts from enqueue (queue wait + run). If waits dominate, add workers (`connectWorker({ workers })`); ' +
|
|
450
|
+
'otherwise raise the budget per call with `client.with({ timeout })` or the `taskTimeout` default.',
|
|
451
|
+
docs: DOCS.config,
|
|
452
|
+
entities: ent,
|
|
453
|
+
});
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
for (const [pk, q] of queueFull) {
|
|
457
|
+
const p = pools.get(pk);
|
|
458
|
+
add({
|
|
459
|
+
id: `queue-full:${pk}`,
|
|
460
|
+
rule: 'queue-full',
|
|
461
|
+
severity: 'warn',
|
|
462
|
+
title: `Pool ${poolLabel(p ?? { poolId: q.poolId })} rejected ${plural(q.n, 'call')}: queue full`,
|
|
463
|
+
detail: `${q.n} PoolQueueFullError rejections in the last ${WINDOW_MS / 1000}s (tasks: ${[...q.tasks].slice(0, 5).join(', ')}).`,
|
|
464
|
+
fix:
|
|
465
|
+
'`maxQueue` is the backpressure point: callers get `PoolQueueFullError` once it fills. Raise `maxQueue`, add workers ' +
|
|
466
|
+
'(`connectWorker({ workers })`), or shed load upstream.',
|
|
467
|
+
docs: DOCS.config,
|
|
468
|
+
entities: [poolEntity(q.sid, q.poolId, p?.label)],
|
|
469
|
+
});
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
/* worker rules: crash-loop, heap-pressure */
|
|
473
|
+
for (const w of snap.workers ?? []) {
|
|
474
|
+
const label = `${w.poolId}#${w.slot}`;
|
|
475
|
+
const ent = [{ kind: 'worker', key: w.key, label }];
|
|
476
|
+
const respawns = (w.respawns ?? []).filter((t) => now - t <= CRASH_LOOP_WINDOW_MS);
|
|
477
|
+
if (respawns.length >= CRASH_LOOP_RESPAWNS) {
|
|
478
|
+
add({
|
|
479
|
+
id: `crash-loop:${w.key}`,
|
|
480
|
+
rule: 'crash-loop',
|
|
481
|
+
severity: 'error',
|
|
482
|
+
title: `Worker ${label} is crash-looping`,
|
|
483
|
+
detail:
|
|
484
|
+
`${respawns.length} respawns in the last ${CRASH_LOOP_WINDOW_MS / 1000}s` +
|
|
485
|
+
`${w.lastError ? `. Last error: "${w.lastError.slice(0, 160)}"` : '.'}`,
|
|
486
|
+
fix:
|
|
487
|
+
'Each crash rejects in-flight calls with `WorkerCrashedError` and spawns a replacement (`respawn` defaults to true). ' +
|
|
488
|
+
'Open the worker inspector for the error; `connectWorker({ respawn: false })` stops the loop while debugging.',
|
|
489
|
+
docs: DOCS.config,
|
|
490
|
+
entities: ent,
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
if (w.heap !== undefined && w.heapLimit) {
|
|
494
|
+
const f = w.heap / w.heapLimit;
|
|
495
|
+
if (f >= HEAP_WARN) {
|
|
496
|
+
add({
|
|
497
|
+
id: `heap-pressure:${w.key}`,
|
|
498
|
+
rule: 'heap-pressure',
|
|
499
|
+
severity: f >= HEAP_ERROR ? 'error' : 'warn',
|
|
500
|
+
title: `Worker ${label} heap at ${pct(f)}`,
|
|
501
|
+
detail: `${fmtBytes(w.heap)} used of a ${fmtBytes(w.heapLimit)} limit (warn ${pct(HEAP_WARN)}, error ${pct(HEAP_ERROR)}).`,
|
|
502
|
+
fix:
|
|
503
|
+
'Bound worker-side caches, and move large datasets held as JS objects into a `defineSharedMemory` contract ' +
|
|
504
|
+
'(fixed-width, allocated once and shared instead of copied per worker).',
|
|
505
|
+
docs: DOCS.sharedMemory,
|
|
506
|
+
entities: ent,
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
for (const s of sessions.values()) {
|
|
512
|
+
if (s.heap === undefined || !s.heapLimit) continue;
|
|
513
|
+
const f = s.heap / s.heapLimit;
|
|
514
|
+
if (f < HEAP_WARN) continue;
|
|
515
|
+
add({
|
|
516
|
+
id: `heap-pressure:${s.id}`,
|
|
517
|
+
rule: 'heap-pressure',
|
|
518
|
+
severity: f >= HEAP_ERROR ? 'error' : 'warn',
|
|
519
|
+
title: `${sessLabel(s)} main-thread heap at ${pct(f)}`,
|
|
520
|
+
detail: `${fmtBytes(s.heap)} used of a ${fmtBytes(s.heapLimit)} limit (warn ${pct(HEAP_WARN)}, error ${pct(HEAP_ERROR)}).`,
|
|
521
|
+
fix:
|
|
522
|
+
'Check the Memory view for the largest contexts; keep big datasets in a worker or a `defineSharedMemory` contract ' +
|
|
523
|
+
'rather than as main-thread objects, and bound caches.',
|
|
524
|
+
docs: DOCS.sharedMemory,
|
|
525
|
+
entities: [sessionEntity(s)],
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
/* main-jank / low-fps */
|
|
530
|
+
for (const s of sessions.values()) {
|
|
531
|
+
const all = within(s.longframes ?? [], now, WINDOW_MS);
|
|
532
|
+
const lf = all.filter((f) => !f.hidden);
|
|
533
|
+
const bg = all.filter((f) => f.hidden);
|
|
534
|
+
const bgScript = bg.reduce((a, f) => a + (f.scripts ? f.scripts.reduce((n, sc) => n + sc.ms, 0) : f.ms), 0);
|
|
535
|
+
if (bgScript >= BG_WORK_MS) {
|
|
536
|
+
add({
|
|
537
|
+
id: `background-work:${s.id}`,
|
|
538
|
+
rule: 'background-work',
|
|
539
|
+
severity: 'info',
|
|
540
|
+
title: `${ms(bgScript)} of main-thread script while the page was hidden`,
|
|
541
|
+
detail:
|
|
542
|
+
`${plural(bg.length, 'long frame')} in the last ${WINDOW_MS / 1000}s ran while ${sessLabel(s)} was in the background ` +
|
|
543
|
+
`(threshold ${BG_WORK_MS}ms of script). Hidden tabs throttle rendering, not work: this costs battery and delays worker replies.`,
|
|
544
|
+
fix:
|
|
545
|
+
'Pause timers and polling on `visibilitychange` when `document.hidden`, or move the periodic work into a worker task (`connectWorker`).',
|
|
546
|
+
docs: DOCS.tasks,
|
|
547
|
+
entities: [sessionEntity(s)],
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
const tbt = lf.reduce((a, f) => a + (f.blockingMs ?? 0), 0);
|
|
551
|
+
if (lf.length && tbt >= TBT_WARN_MS) {
|
|
552
|
+
const scripts = new Map();
|
|
553
|
+
for (const f of lf) for (const sc of f.scripts ?? []) {
|
|
554
|
+
const k = [sc.fn, sc.src].filter(Boolean).join(' @ ') || '(anonymous)';
|
|
555
|
+
scripts.set(k, (scripts.get(k) ?? 0) + sc.ms);
|
|
556
|
+
}
|
|
557
|
+
const top = [...scripts.entries()].sort((a, b) => b[1] - a[1])[0];
|
|
558
|
+
add({
|
|
559
|
+
id: `main-jank:${s.id}`,
|
|
560
|
+
rule: 'main-jank',
|
|
561
|
+
severity: tbt >= TBT_ERROR_MS ? 'error' : 'warn',
|
|
562
|
+
title: `Main thread blocked ${ms(tbt)} in the last ${WINDOW_MS / 1000}s`,
|
|
563
|
+
detail:
|
|
564
|
+
`${plural(lf.length, 'long frame')}, longest ${ms(Math.max(...lf.map((f) => f.ms)))}, total blocking time ${ms(tbt)} ` +
|
|
565
|
+
`(warn ≥${TBT_WARN_MS}ms, error ≥${TBT_ERROR_MS}ms) in ${sessLabel(s)}` +
|
|
566
|
+
`${top ? `. Top script: ${top[0]} (${ms(top[1])})` : '.'}`,
|
|
567
|
+
fix:
|
|
568
|
+
'Move synchronous work off the main thread: a `defineWorker` method called through `connectWorker`, or render the ' +
|
|
569
|
+
'heavy subtree as a worker island (`mountIsland`).',
|
|
570
|
+
docs: DOCS.tasks,
|
|
571
|
+
entities: [sessionEntity(s)],
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
const fr = within(s.frames ?? [], now, FPS_WINDOW_MS);
|
|
575
|
+
if (fr.length >= MIN_FPS_SAMPLES) {
|
|
576
|
+
const fps = avg(fr.map((f) => f.fps));
|
|
577
|
+
if (fps < LOW_FPS) {
|
|
578
|
+
const dropped = fr.reduce((a, f) => a + (f.dropped ?? 0), 0);
|
|
579
|
+
add({
|
|
580
|
+
id: `low-fps:${s.id}`,
|
|
581
|
+
rule: 'low-fps',
|
|
582
|
+
severity: 'warn',
|
|
583
|
+
title: `Low frame rate: ${Math.round(fps)} fps`,
|
|
584
|
+
detail: `${sessLabel(s)} averaged ${fps.toFixed(1)} fps with ${plural(dropped, 'dropped frame')} over the last ${fr.length}s (threshold ${LOW_FPS} fps).`,
|
|
585
|
+
fix:
|
|
586
|
+
'Find the blocking work in Performance (long frames); move it into a worker task (`connectWorker`) or a worker island ' +
|
|
587
|
+
'(`mountIsland`) so the main thread only replays results.',
|
|
588
|
+
docs: DOCS.tasks,
|
|
589
|
+
entities: [sessionEntity(s)],
|
|
590
|
+
});
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
/* memory-write-storm */
|
|
596
|
+
for (const m of snap.memFields ?? []) {
|
|
597
|
+
// Only complete seconds inside the window: the current second is partial.
|
|
598
|
+
const cur = Math.floor(now / 1000) * 1000;
|
|
599
|
+
const secs = (m.perSec ?? []).filter((x) => x.t < cur && cur - x.t <= WRITE_STORM_WINDOW_MS);
|
|
600
|
+
if (!secs.length) continue;
|
|
601
|
+
const total = secs.reduce((a, x) => a + x.v, 0);
|
|
602
|
+
const rate = total / (WRITE_STORM_WINDOW_MS / 1000);
|
|
603
|
+
if (rate < WRITE_STORM_PER_SEC) continue;
|
|
604
|
+
add({
|
|
605
|
+
id: `memory-write-storm:${m.key}`,
|
|
606
|
+
rule: 'memory-write-storm',
|
|
607
|
+
severity: 'warn',
|
|
608
|
+
title: `Shared-memory write storm on ${m.path}`,
|
|
609
|
+
detail:
|
|
610
|
+
`${Math.round(rate)} writes/s on average over the last ${WRITE_STORM_WINDOW_MS / 1000}s ` +
|
|
611
|
+
`(peak ${Math.max(...secs.map((x) => x.v))}/s; threshold ${WRITE_STORM_PER_SEC}/s). ` +
|
|
612
|
+
"Each write bumps the field's version counter, which observers react to.",
|
|
613
|
+
fix:
|
|
614
|
+
'Coalesce writes: update once per frame or per batch instead of per item. For `field.list`, `writeAt(i, record)` ' +
|
|
615
|
+
'is pure memory access: write the whole batch, then call `commit()` once.',
|
|
616
|
+
docs: DOCS.connectors,
|
|
617
|
+
entities: [{ kind: 'session', key: m.sid, label: sessLabel(sessions.get(m.sid) ?? { id: m.sid }) }],
|
|
618
|
+
});
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
/* fetch-failing / fetch-slow (grouped by method + path) */
|
|
622
|
+
const groups = new Map();
|
|
623
|
+
for (const f of within(snap.fetches ?? [], now, WINDOW_MS)) {
|
|
624
|
+
const k = `${f.sid}|${f.method} ${String(f.url).split('?')[0]}`;
|
|
625
|
+
const g = groups.get(k) ?? { sid: f.sid, label: `${f.method} ${String(f.url).split('?')[0]}`, list: [] };
|
|
626
|
+
g.list.push(f);
|
|
627
|
+
groups.set(k, g);
|
|
628
|
+
}
|
|
629
|
+
for (const [k, g] of groups) {
|
|
630
|
+
const s = sessions.get(g.sid) ?? { id: g.sid };
|
|
631
|
+
const failed = g.list.filter((f) => f.error || (f.status !== undefined && f.status >= 400));
|
|
632
|
+
if (failed.length) {
|
|
633
|
+
const statuses = [...new Set(failed.filter((f) => f.status !== undefined).map((f) => f.status))];
|
|
634
|
+
const err = failed.find((f) => f.error)?.error;
|
|
635
|
+
add({
|
|
636
|
+
id: `fetch-failing:${k}`,
|
|
637
|
+
rule: 'fetch-failing',
|
|
638
|
+
severity: 'info',
|
|
639
|
+
title: `Failing requests: ${g.label}`,
|
|
640
|
+
detail:
|
|
641
|
+
`${failed.length} of ${g.list.length} failed in the last ${WINDOW_MS / 1000}s` +
|
|
642
|
+
`${statuses.length ? ` (status ${statuses.join(', ')})` : ''}${err ? `; network error "${err.slice(0, 120)}"` : ''}.`,
|
|
643
|
+
fix: 'Open the request in Network for headers and body previews; fetches issued inside workers are attributed to their pool slot.',
|
|
644
|
+
entities: [sessionEntity(s)],
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
const times = g.list.filter((f) => !f.error).map((f) => f.ms);
|
|
648
|
+
if (times.length >= MIN_FETCHES) {
|
|
649
|
+
const p95 = percentile(times, 95);
|
|
650
|
+
if (p95 > FETCH_SLOW_MS) {
|
|
651
|
+
add({
|
|
652
|
+
id: `fetch-slow:${k}`,
|
|
653
|
+
rule: 'fetch-slow',
|
|
654
|
+
severity: 'info',
|
|
655
|
+
title: `Slow requests: ${g.label}`,
|
|
656
|
+
detail: `p95 ${ms(p95)} to response headers over ${plural(times.length, 'request')} (threshold ${FETCH_SLOW_MS}ms).`,
|
|
657
|
+
fix:
|
|
658
|
+
'Check the timing breakdown in Network (queue, dns, tcp, tls, wait, download); cross-origin stages need a ' +
|
|
659
|
+
'`Timing-Allow-Origin` response header.',
|
|
660
|
+
entities: [sessionEntity(s)],
|
|
661
|
+
});
|
|
662
|
+
}
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/* island-untagged (one finding per session) */
|
|
667
|
+
for (const s of sessions.values()) {
|
|
668
|
+
const untagged = (snap.islands ?? []).filter((i) => i.sid === s.id && i.mounted && !i.ended && !i.framework);
|
|
669
|
+
if (!untagged.length) continue;
|
|
670
|
+
add({
|
|
671
|
+
id: `island-untagged:${s.id}`,
|
|
672
|
+
rule: 'island-untagged',
|
|
673
|
+
severity: 'info',
|
|
674
|
+
title: `${plural(untagged.length, 'island')} without a framework tag`,
|
|
675
|
+
detail:
|
|
676
|
+
`${untagged.slice(0, 6).map((i) => i.instance).join(', ')}${untagged.length > 6 ? ', …' : ''} mounted with no framework tag ` +
|
|
677
|
+
'and the worker reported none: expected for imperative (proxy-document) apps; the app map draws them as plain workers.',
|
|
678
|
+
fix:
|
|
679
|
+
"Pass the tag on the mount (`mountIsland({ framework: 'react', … })`, observability only), or register the component " +
|
|
680
|
+
'through its package adapter (`reactIslandApp`, `vueIslandApp`, …), which reports its renderer.',
|
|
681
|
+
docs: DOCS.islandOptions,
|
|
682
|
+
entities: untagged.slice(0, 8).map((i) => ({ kind: 'island', key: i.key, label: i.instance })),
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
const ruleIdx = new Map(RULES.map((r, i) => [r.id, i]));
|
|
687
|
+
return out.sort(
|
|
688
|
+
(a, b) => SEV_ORDER[a.severity] - SEV_ORDER[b.severity] || (ruleIdx.get(a.rule) ?? 99) - (ruleIdx.get(b.rule) ?? 99),
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* Which rules currently have the input data they need (the UI marks the
|
|
694
|
+
* rest as "waiting for data"). Pure, same snapshot as runAudits.
|
|
695
|
+
* @param {AuditSnapshot} snap
|
|
696
|
+
* @returns {Record<string, boolean>}
|
|
697
|
+
*/
|
|
698
|
+
export function ruleCoverage(snap) {
|
|
699
|
+
const tasks = snap.tasks ?? [];
|
|
700
|
+
const islands = snap.islands ?? [];
|
|
701
|
+
const pools = snap.pools ?? [];
|
|
702
|
+
const sessions = snap.sessions ?? [];
|
|
703
|
+
const anyBatch = (k) => islands.some((i) => (i.batches ?? []).some((b) => b[k] !== undefined));
|
|
704
|
+
const anySettle = tasks.some((t) => (t.settles ?? []).length);
|
|
705
|
+
return {
|
|
706
|
+
'coi-islands': sessions.some((s) => s.env?.crossOriginIsolated !== undefined) && islands.length > 0,
|
|
707
|
+
'large-args': tasks.some((t) => (t.argBytes ?? []).length),
|
|
708
|
+
'large-results': tasks.some((t) => (t.resultBytes ?? []).length),
|
|
709
|
+
'island-batch-bytes': anyBatch('bytes'),
|
|
710
|
+
'island-slow-replay': anyBatch('replayMs'),
|
|
711
|
+
'pool-saturated': pools.some((p) => !p.dedicated && (p.dispatches ?? []).length),
|
|
712
|
+
'pool-underused': pools.some((p) => !p.dedicated && (p.poolSize ?? 0) > 1),
|
|
713
|
+
'pool-oversized': pools.some((p) => p.initSeen) && sessions.some((s) => s.env?.hardwareConcurrency),
|
|
714
|
+
'main-jank': sessions.some((s) => (s.longframes ?? []).some((f) => !f.hidden)),
|
|
715
|
+
'background-work': sessions.some((s) => (s.longframes ?? []).some((f) => f.hidden)),
|
|
716
|
+
'low-fps': sessions.some((s) => (s.frames ?? []).length),
|
|
717
|
+
'task-errors': anySettle,
|
|
718
|
+
'task-timeouts': anySettle,
|
|
719
|
+
'queue-full': anySettle,
|
|
720
|
+
'crash-loop': (snap.workers ?? []).length > 0,
|
|
721
|
+
'heap-pressure': (snap.workers ?? []).some((w) => w.heapLimit) || sessions.some((s) => s.heapLimit),
|
|
722
|
+
'devtools-late': pools.length > 0,
|
|
723
|
+
'memory-write-storm': (snap.memFields ?? []).length > 0,
|
|
724
|
+
'fetch-failing': (snap.fetches ?? []).length > 0,
|
|
725
|
+
'fetch-slow': (snap.fetches ?? []).length > 0,
|
|
726
|
+
'island-untagged': islands.length > 0,
|
|
727
|
+
};
|
|
728
|
+
}
|