ugly-app 0.1.820 → 0.1.821

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ugly-app",
3
- "version": "0.1.820",
3
+ "version": "0.1.821",
4
4
  "type": "module",
5
5
  "comment:files": "Allowlist what ships to npm. dist = runtime; src = sourcemap targets (dist/*.js.map reference ../../src/); templates = CLI scaffold. Everything else at repo root (.pgdata local Postgres, coverage, assets/icons sources, test/, test-results/) is excluded by omission. The !negations strip the scaffold's installed deps + cruft (templates/node_modules is 200MB+ and must never ship). package.json/README/LICENSE ship automatically.",
6
6
  "files": [
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "scripts": {
66
66
  "prebuild": "node -e \"const p=JSON.parse(require('fs').readFileSync('package.json','utf8'));require('fs').writeFileSync('src/cli/version.ts','// Auto-generated by prebuild — do not edit manually\\nexport const CLI_VERSION = '+JSON.stringify(p.version)+';\\n');\" && rm -rf dist",
67
- "build": "tsc && chmod +x dist/cli/index.js && cp src/markdown/client/*.css dist/markdown/client/ && mkdir -p dist/cli/publish/recipes && cp -R src/cli/publish/recipes/. dist/cli/publish/recipes/ && esbuild src/inspect/agent/bundle-entry.ts --bundle --format=iife --platform=browser --minify --outfile=dist/inspect/agent-bundle.js && esbuild src/inspect/host-bundle-entry.ts --bundle --format=iife --platform=browser --minify --outfile=dist/inspect/host-bundle.js",
67
+ "build": "tsc && chmod +x dist/cli/index.js && cp src/markdown/client/*.css dist/markdown/client/ && mkdir -p dist/cli/publish/recipes && cp -R src/cli/publish/recipes/. dist/cli/publish/recipes/ && esbuild src/inspect/agent/bundle-entry.ts --bundle --format=iife --platform=browser --minify --outfile=dist/inspect/agent-bundle.js --footer:js='//# sourceURL=ugly-inspect-agent.js' && esbuild src/inspect/host-bundle-entry.ts --bundle --format=iife --platform=browser --minify --outfile=dist/inspect/host-bundle.js",
68
68
  "test": "vitest run",
69
69
  "test:e2e": "E2E=1 vitest run tests/cli/e2e-integration.test.ts",
70
70
  "test:e2e:full": "pnpm exec tsx tests/e2e/full-e2e.ts",
@@ -1,2 +1,2 @@
1
1
  // Auto-generated by prebuild — do not edit manually
2
- export const CLI_VERSION = "0.1.820";
2
+ export const CLI_VERSION = "0.1.821";
@@ -28,6 +28,26 @@ const LONG_TASK_WINDOW_COUNT = 5;
28
28
  const DROPPED_FRAME_THRESHOLD = 3;
29
29
  const MAX_PENDING_ANOMALIES = 10;
30
30
  const PROFILE_DURATION_MS = 1000;
31
+ // The 1s self-profile costs ~250 stack captures. Anomalies can arrive back-to-back
32
+ // (every long task fires one), so profiling every anomaly makes the agent a
33
+ // meaningful share of the very load it is measuring. Snapshots are still captured
34
+ // each time; only the call tree is rate-limited.
35
+ const PROFILE_COOLDOWN_MS = 30_000;
36
+ // Above this, a heartbeat gap is a discontinuity (suspension / sleep / throttled tick
37
+ // racing `visibilitychange`), not event-loop lag. Comfortably above the largest block
38
+ // we still want to report as a spike.
39
+ const SUSPEND_GAP_MS = 10_000;
40
+
41
+ /**
42
+ * A hidden page's timers are throttled by the browser — Chromium clamps them to 1s
43
+ * and, after ~5 minutes, to once per MINUTE. The heartbeat measures `now - expected`,
44
+ * so an idle backgrounded tab reports tens of seconds of "event-loop lag" and a
45
+ * saturated cpuPressure. That is a property of not being on screen, not of the page.
46
+ * Observed in production: a backgrounded tab converging on exactly ~60000ms of lag.
47
+ */
48
+ function isHidden(): boolean {
49
+ return typeof document !== 'undefined' && document.hidden === true;
50
+ }
31
51
 
32
52
  // ─── Helpers ─────────────────────────────────────────────────────────────────
33
53
 
@@ -83,10 +103,37 @@ class HeartbeatDetector {
83
103
  return this.lagSamples.reduce((a, b) => a + b, 0) / this.lagSamples.length;
84
104
  }
85
105
 
106
+ /**
107
+ * Drop the accumulated window and re-baseline. Called when the page is hidden (its
108
+ * timers are about to be throttled) and when it becomes visible again (the first
109
+ * tick after a throttled gap would otherwise register as a huge stall).
110
+ */
111
+ reset(now: number): void {
112
+ this.lastTick = now;
113
+ this.lagSamples.length = 0;
114
+ this.delayedSamples.length = 0;
115
+ this.blipCount = 0;
116
+ }
117
+
86
118
  /** Returns true when a fresh anomaly is detected (pressure just crossed threshold). */
87
119
  tick(now: number): boolean {
120
+ // A throttled tick measures the browser's scheduling policy, not the page's
121
+ // event loop. Re-baseline and record nothing.
122
+ if (isHidden()) {
123
+ this.reset(now);
124
+ return false;
125
+ }
88
126
  const expected = this.lastTick + HEARTBEAT_INTERVAL_MS;
89
127
  const lag = Math.max(0, now - expected);
128
+ // A gap this large is a discontinuity — process suspension, machine sleep, or a
129
+ // throttled tick that beat `visibilitychange` back to the queue. It is not a
130
+ // stall we could act on, and recording it poisons the trailing average for the
131
+ // next ~10s. Re-baseline instead. (A genuine multi-second block below this
132
+ // threshold is still reported as a spike.)
133
+ if (lag > SUSPEND_GAP_MS) {
134
+ this.reset(now);
135
+ return false;
136
+ }
90
137
  this.lagSamples.push(lag);
91
138
  if (this.lagSamples.length > HEARTBEAT_WINDOW_SAMPLES) this.lagSamples.shift();
92
139
 
@@ -135,7 +182,19 @@ class FrameBudgetMonitor {
135
182
  private rafId = 0;
136
183
  onJank: (() => void) | null = null;
137
184
 
185
+ /** Forget the last frame time so a hidden→visible gap isn't counted as jank. */
186
+ reset(): void {
187
+ this.lastFrame = 0;
188
+ this.dropCount = 0;
189
+ }
190
+
138
191
  private tick = (now: number): void => {
192
+ // rAF is suspended while hidden; the first frame back carries the whole gap.
193
+ if (isHidden()) {
194
+ this.reset();
195
+ this.rafId = raf(this.tick);
196
+ return;
197
+ }
139
198
  const delta = now - this.lastFrame;
140
199
  if (this.lastFrame > 0 && delta > 32) {
141
200
  this.dropCount++;
@@ -185,6 +244,7 @@ export class PerfAgent {
185
244
  private pendingAnomalies: PerfSnapshot['trigger'][] = [];
186
245
  private snapshots: PerfSnapshot[] = [];
187
246
  private profiling = false;
247
+ private lastProfileAt = 0;
188
248
  private lcpMs: number | undefined;
189
249
  private fcpMs: number | undefined;
190
250
  private _startup: {
@@ -240,9 +300,7 @@ export class PerfAgent {
240
300
  this.lcpMs = e.startTime;
241
301
  }
242
302
  }
243
- // Fire on web-vitals long-task count too
244
- const ltCount = vitals.longTasks?.length ?? 0;
245
- if (ltCount + this.longTasks.count >= LONG_TASK_WINDOW_COUNT * 2) {
303
+ if (this.longTaskAnomalyDue(vitals)) {
246
304
  void this.captureAnomaly('long-task');
247
305
  }
248
306
  });
@@ -262,10 +320,39 @@ export class PerfAgent {
262
320
  }
263
321
  } catch { /* unsupported */ }
264
322
 
323
+ // Re-baseline across visibility changes. Going hidden, the next tick may be a
324
+ // minute late; coming back, that gap must not be charged to the page.
325
+ try {
326
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
327
+ document.addEventListener('visibilitychange', () => {
328
+ this.heartbeat.reset(Date.now());
329
+ this.frameBudget.reset();
330
+ });
331
+ }
332
+ } catch { /* no document (worker/test) */ }
333
+
265
334
  // Track startup milestones
266
335
  this._startup.milestones.push({ label: 'agent:install', deltaMs: Math.round(this.now() - this._startup.t0) });
267
336
  }
268
337
 
338
+ /**
339
+ * Whether the long tasks seen *recently* warrant an anomaly.
340
+ *
341
+ * The old test also added `vitals.longTasks.length` — a cumulative buffer that is
342
+ * capped at 200 but never decays. Once a page had emitted 10 long tasks in its
343
+ * lifetime (trivial for any real app) the threshold was permanently satisfied, so
344
+ * EVERY subsequent long task captured an anomaly and ran a 1s self-profile. That
345
+ * is a runaway loop: profiling generates long tasks, which schedule more profiling.
346
+ *
347
+ * Only `this.longTasks` is consulted — it prunes to a trailing 10s window. `vitals`
348
+ * stays in the signature because its entries are stamped with a different clock
349
+ * (`deps.now()`), so they cannot be windowed against this one.
350
+ */
351
+ private longTaskAnomalyDue(_vitals: import('../protocol.js').WebVitals): boolean {
352
+ if (isHidden()) return false;
353
+ return this.longTasks.count >= LONG_TASK_WINDOW_COUNT;
354
+ }
355
+
269
356
  /** Mark a startup milestone (call from outside). */
270
357
  mark(label: string): void {
271
358
  this._startup.milestones.push({
@@ -300,13 +387,23 @@ export class PerfAgent {
300
387
  async captureAnomaly(trigger: PerfSnapshot['trigger']): Promise<void> {
301
388
  if (this.pendingAnomalies.length >= MAX_PENDING_ANOMALIES) return;
302
389
  if (this.profiling && trigger !== 'page-unload') return;
390
+ // A hidden page's detectors measure browser throttling, not the page. `page-unload`
391
+ // is exempt: a tab is often hidden at the moment it is torn down.
392
+ if (trigger !== 'page-unload' && isHidden()) return;
303
393
 
304
394
  this.profiling = true;
305
395
  const heap = heapMb();
306
396
  const vitals = this.vitalsReader();
307
397
 
398
+ // The profile is the expensive part (~250 stack captures over 1s). Snapshot every
399
+ // anomaly, but sample at most once per cooldown so the agent can't become the
400
+ // dominant cost on a page that is emitting anomalies continuously.
401
+ const nowMs = Date.now();
402
+ const mayProfile = trigger !== 'page-unload' && nowMs - this.lastProfileAt >= PROFILE_COOLDOWN_MS;
403
+
308
404
  let callTree: PerfSnapshot['callTree'] = null;
309
- if (trigger !== 'page-unload') {
405
+ if (mayProfile) {
406
+ this.lastProfileAt = nowMs;
310
407
  try {
311
408
  const profile = await selfProfile(PROFILE_DURATION_MS, this.now);
312
409
  // Flatten the top 10 self-time frames
@@ -326,13 +423,20 @@ export class PerfAgent {
326
423
  walk(profile.root);
327
424
  frames.sort((a, b) => b.selfMs - a.selfMs);
328
425
  const totalSelf = frames.reduce((s, f) => s + f.selfMs, 0);
329
- callTree = frames.slice(0, 10).map((f) => ({
330
- functionName: f.fn,
331
- url: f.url,
332
- line: f.line,
333
- col: f.col,
334
- selfPercent: totalSelf > 0 ? Math.round((f.selfMs / totalSelf) * 100) : 0,
335
- }));
426
+ // No page frames sampled. This is the common case for a single synchronous
427
+ // block: the sampler runs on a timer, and timers do not run *during* the
428
+ // block, so by the time it profiles, the page is idle again. Report `null`
429
+ // rather than an empty tree — an empty array reads as "profiled, nothing hot",
430
+ // which is a different (and misleading) claim.
431
+ callTree = frames.length === 0
432
+ ? null
433
+ : frames.slice(0, 10).map((f) => ({
434
+ functionName: f.fn,
435
+ url: f.url,
436
+ line: f.line,
437
+ col: f.col,
438
+ selfPercent: totalSelf > 0 ? Math.round((f.selfMs / totalSelf) * 100) : 0,
439
+ }));
336
440
  } catch {
337
441
  callTree = null;
338
442
  }
@@ -0,0 +1,148 @@
1
+ import { afterEach, describe, expect, it, vi } from 'vitest';
2
+ import { PerfAgent } from './perfAgent.js';
3
+ import type { WebVitals } from '../protocol.js';
4
+
5
+ const NO_VITALS: WebVitals = { longTasks: [] } as unknown as WebVitals;
6
+
7
+ function makeAgent(vitals: () => WebVitals = () => NO_VITALS): PerfAgent {
8
+ let t = 0;
9
+ return new PerfAgent(() => (t += 1), vitals);
10
+ }
11
+
12
+ /** Present a `document` with the given visibility to the agent's `isHidden()`. */
13
+ function setHidden(hidden: boolean): void {
14
+ vi.stubGlobal('document', {
15
+ hidden,
16
+ getElementsByTagName: () => [],
17
+ addEventListener: () => {},
18
+ });
19
+ }
20
+
21
+ function heartbeatOf(agent: PerfAgent): { tick(now: number): boolean; reset(now: number): void } {
22
+ return (agent as unknown as { heartbeat: { tick(now: number): boolean; reset(now: number): void } }).heartbeat;
23
+ }
24
+
25
+ /** Drive the heartbeat with ticks `gapMs` apart. */
26
+ function drive(agent: PerfAgent, gapMs: number, ticks: number): void {
27
+ const hb = heartbeatOf(agent);
28
+ let now = 0;
29
+ for (let i = 0; i < ticks; i++) {
30
+ now += gapMs;
31
+ hb.tick(now);
32
+ }
33
+ }
34
+
35
+ afterEach(() => vi.unstubAllGlobals());
36
+
37
+ describe('hidden-tab timer throttling must not read as CPU pressure', () => {
38
+ it('a hidden page clamped to 1 tick/min is not "under pressure"', () => {
39
+ setHidden(true);
40
+ const agent = makeAgent();
41
+ // Chromium intensive throttling. The page is IDLE — it just is not on screen.
42
+ // Production showed this converging on ~60000ms of reported "lag".
43
+ drive(agent, 60_000, 60);
44
+ const v = agent.vitals();
45
+ expect(v.eventLoopLagMs).toBeLessThan(1000);
46
+ expect(v.cpuPressure).toBeLessThan(0.5);
47
+ expect(v.pendingAnomalies).toEqual([]);
48
+ });
49
+
50
+ it('a hidden page clamped to 1s is not "under pressure" either', () => {
51
+ setHidden(true);
52
+ const agent = makeAgent();
53
+ drive(agent, 1000, 60);
54
+ expect(agent.vitals().cpuPressure).toBeLessThan(0.5);
55
+ });
56
+
57
+ it('does not charge the page for the hidden→visible gap', () => {
58
+ setHidden(true);
59
+ const agent = makeAgent();
60
+ drive(agent, 60_000, 5);
61
+ // Becomes visible; the next tick lands a minute after the last throttled one.
62
+ setHidden(false);
63
+ const hb = heartbeatOf(agent);
64
+ const anomaly = hb.tick(300_000 + 60_000);
65
+ expect(anomaly).toBe(false);
66
+ expect(agent.vitals().cpuPressure).toBeLessThan(0.5);
67
+ });
68
+
69
+ it('still detects a REAL stall on a visible page', () => {
70
+ setHidden(false);
71
+ const agent = makeAgent();
72
+ const hb = heartbeatOf(agent);
73
+ let now = 0;
74
+ for (let i = 0; i < 20; i++) { now += 200; hb.tick(now); }
75
+ now += 3000; // a genuine 3s synchronous block
76
+ expect(hb.tick(now)).toBe(true);
77
+ });
78
+ });
79
+
80
+ describe('long-task anomaly uses a decaying window', () => {
81
+ function longTaskDue(agent: PerfAgent, vitals: WebVitals): boolean {
82
+ return (agent as unknown as { longTaskAnomalyDue(v: WebVitals): boolean })
83
+ .longTaskAnomalyDue.call(agent, vitals);
84
+ }
85
+ function addLongTasks(agent: PerfAgent, n: number): void {
86
+ const c = (agent as unknown as { longTasks: { add(ts: number, dur: number): void } }).longTasks;
87
+ for (let i = 0; i < n; i++) c.add(Date.now(), 60);
88
+ }
89
+
90
+ it('ignores the never-decaying cumulative vitals buffer', () => {
91
+ setHidden(false);
92
+ // 12 long tasks emitted long ago. Previously `vitals.longTasks.length >= 10` made
93
+ // the threshold permanently true, so every later long task ran a 1s self-profile.
94
+ const stale: WebVitals = {
95
+ longTasks: Array.from({ length: 12 }, () => ({ ts: 0, durationMs: 60 })),
96
+ } as unknown as WebVitals;
97
+ const agent = makeAgent(() => stale);
98
+ expect(longTaskDue(agent, stale)).toBe(false);
99
+ });
100
+
101
+ it('fires on a real burst inside the trailing window', () => {
102
+ setHidden(false);
103
+ const agent = makeAgent();
104
+ addLongTasks(agent, 5);
105
+ expect(longTaskDue(agent, NO_VITALS)).toBe(true);
106
+ });
107
+
108
+ it('never fires while hidden', () => {
109
+ setHidden(true);
110
+ const agent = makeAgent();
111
+ addLongTasks(agent, 20);
112
+ expect(longTaskDue(agent, NO_VITALS)).toBe(false);
113
+ });
114
+ });
115
+
116
+ describe('self-profile is rate limited', () => {
117
+ it('captures every anomaly but only profiles once per cooldown', async () => {
118
+ setHidden(false);
119
+ const agent = makeAgent();
120
+
121
+ await agent.captureAnomaly('cpu-pressure');
122
+ await agent.captureAnomaly('cpu-pressure');
123
+ await agent.captureAnomaly('cpu-pressure');
124
+
125
+ const trees: (unknown | null)[] = [];
126
+ for (;;) {
127
+ const s = agent.takeSnapshot();
128
+ if (!s) break;
129
+ trees.push(s.callTree);
130
+ }
131
+ expect(trees.length).toBe(3); // every anomaly is still recorded
132
+ expect(trees.filter((t) => t !== null).length).toBe(1); // only one profile
133
+ });
134
+
135
+ it('does not capture an anomaly at all while hidden', async () => {
136
+ setHidden(true);
137
+ const agent = makeAgent();
138
+ await agent.captureAnomaly('cpu-pressure');
139
+ expect(agent.takeSnapshot()).toBeNull();
140
+ });
141
+
142
+ it('always captures page-unload, even hidden', async () => {
143
+ setHidden(true);
144
+ const agent = makeAgent();
145
+ await agent.captureAnomaly('page-unload');
146
+ expect(agent.takeSnapshot()).not.toBeNull();
147
+ });
148
+ });
@@ -37,11 +37,26 @@ function schedule(fn: () => void): void {
37
37
  else fn();
38
38
  }
39
39
 
40
+ /**
41
+ * Frames belonging to the inspect agent itself. The `profile.(js|ts)` test only ever
42
+ * matched an unbundled dev build: in the shipped IIFE every module collapses into one
43
+ * file, so the sampler's own frames survived the filter and it profiled ITSELF —
44
+ * production call trees were a single minified frame with 100% self time, useless for
45
+ * finding the real hot path. The bundle now carries a `sourceURL` footer so its frames
46
+ * are attributable.
47
+ *
48
+ * Bare `<anonymous>` is deliberately NOT filtered: page code run through `eval` /
49
+ * `executeJavaScript` reports that way and is exactly what we want to surface.
50
+ */
51
+ function isAgentFrame(url: string): boolean {
52
+ return /profile\.(js|ts)/.test(url) || /ugly-inspect-agent/.test(url);
53
+ }
54
+
40
55
  function captureFrames(): SourceLoc[] {
41
56
  // Frames are leaf→root; reverse to root→leaf and drop our own profiler frames.
42
57
  const frames = parseStack(new Error().stack, 1);
43
58
  return frames
44
- .filter((f) => !/profile\.(js|ts)/.test(f.url))
59
+ .filter((f) => !isAgentFrame(f.url))
45
60
  .map((f) => ({ url: f.url, line: f.line, col: f.col, functionName: f.functionName }))
46
61
  .reverse();
47
62
  }