@mpgd/runtime-diagnostics 0.1.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/README.md +252 -0
- package/dist/index.d.ts +399 -0
- package/dist/index.js +631 -0
- package/package.json +49 -0
package/README.md
ADDED
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
# `@mpgd/runtime-diagnostics`
|
|
2
|
+
|
|
3
|
+
Engine-independent frame performance diagnostics: record per-frame timing
|
|
4
|
+
samples, estimate why hitches happened using optional browser observations,
|
|
5
|
+
and read bounded aggregates — all without importing an engine, DOM, or
|
|
6
|
+
network stack.
|
|
7
|
+
|
|
8
|
+
The package is deliberately headless. It ships:
|
|
9
|
+
|
|
10
|
+
- sample types for frames, long tasks, long animation frames, and resource
|
|
11
|
+
loads;
|
|
12
|
+
- `FrameHitchRecorder<TSample>`, which aggregates frames, keeps a bounded
|
|
13
|
+
history, and estimates hitch causes;
|
|
14
|
+
- `createLongAnimationFrameScriptSamples()`, the sanitizing boundary for
|
|
15
|
+
observer script attributions;
|
|
16
|
+
- `readUsedHeapBytes()`, a structural reader for the Chromium heap extension.
|
|
17
|
+
|
|
18
|
+
Games extend `FrameHitchSample` with their own numeric context (for example
|
|
19
|
+
entity counts or phase labels) through the generic parameter; the recorder
|
|
20
|
+
never inspects or clones those fields.
|
|
21
|
+
|
|
22
|
+
## What stays with the consumer
|
|
23
|
+
|
|
24
|
+
The recorder never touches the platform. Installing observers
|
|
25
|
+
(`PerformanceObserver` for long tasks and long animation frames,
|
|
26
|
+
`PerformanceObserver`-backed resource timing), reading
|
|
27
|
+
`document.visibilityState`, timing engine update/render callbacks, producing
|
|
28
|
+
heap deltas, rendering debug overlays, and uploading or persisting reports are
|
|
29
|
+
all consumer responsibilities. Importing this package causes no observer,
|
|
30
|
+
timer, DOM, or network side effects, which is why the tests run in plain Node.
|
|
31
|
+
|
|
32
|
+
## Time and attribution contract
|
|
33
|
+
|
|
34
|
+
- Every `atMs` / `startAtMs` timestamp and duration is in milliseconds on one
|
|
35
|
+
monotonic, non-decreasing clock chosen by the consumer (for example
|
|
36
|
+
`performance.now()`). The recorder never reads a clock, so tests drive it
|
|
37
|
+
with synthetic timestamps.
|
|
38
|
+
- A frame sample describes one inter-frame gap. `frameDeltaMs` is that gap;
|
|
39
|
+
`previousRenderWorkMs` is the **previous** frame's render callback, which
|
|
40
|
+
executed just before this frame became current and is therefore attributed
|
|
41
|
+
to this gap; `updateWorkMs` is this frame's own update callback ending at
|
|
42
|
+
`atMs`. Misattributing the previous render to the previous sample's gap —
|
|
43
|
+
or this frame's update to the previous gap — would double-count work, so
|
|
44
|
+
the recorder keeps the two windows distinct.
|
|
45
|
+
- A **foreground frame** is one that was not hidden, did not cross a
|
|
46
|
+
visibility transition, and was not estimated as a scheduler interruption.
|
|
47
|
+
Only foreground frames feed the averages, foreground worsts, and hitch
|
|
48
|
+
diagnoses.
|
|
49
|
+
- A **visibility interruption** (`hidden: true` or
|
|
50
|
+
`visibilityInterrupted: true`) is an observed fact supplied by the consumer.
|
|
51
|
+
A **scheduler interruption** is an estimate: a gap of at least
|
|
52
|
+
`SCHEDULER_INTERRUPTION_THRESHOLD_MS` (1 s) whose diagnosis found no
|
|
53
|
+
explanatory update, render, or observation work. Both are counted
|
|
54
|
+
(`interruptionCount`, `schedulerInterruptionCount`) and their excluded time
|
|
55
|
+
is reported (`interruptedFrameMs`, `worstInterruptedFrameMs`) so averages
|
|
56
|
+
never silently drop a stall.
|
|
57
|
+
- Cumulative counters (`frameCount`, `hitchCount`, interruption and
|
|
58
|
+
observation counts, `memoryReclamationHitchCount`, worsts, and the totals
|
|
59
|
+
behind averages) are never trimmed by history eviction. Retained histories
|
|
60
|
+
(`hitches`, long tasks, long animation frames, resource loads) each hold at
|
|
61
|
+
most `historyLimit` samples, and `hitchCauseCounts` is computed over the
|
|
62
|
+
retained hitches only — after eviction it shrinks while `hitchCount` does
|
|
63
|
+
not.
|
|
64
|
+
- Diagnoses are recomputed at `snapshot()` time from the observations still
|
|
65
|
+
retained, so an observation arriving after a **retained hitch** can relabel
|
|
66
|
+
that hitch's cause until either sample is evicted. Estimated scheduler
|
|
67
|
+
interruptions are the exception: the estimate is decided at record time from
|
|
68
|
+
the evidence available then and is final — the excluded gap stays visible
|
|
69
|
+
through `interruptionCount`, `schedulerInterruptionCount`, and
|
|
70
|
+
`interruptedFrameMs` instead of being silently reclassified. Out-of-order
|
|
71
|
+
observations are accepted: counts and worsts are order-independent, `last*`
|
|
72
|
+
fields keep the newest sample by `atMs`, and eviction drops the oldest by
|
|
73
|
+
event time so a late buffered sample cannot evict newer evidence.
|
|
74
|
+
- `reset()` clears every aggregate and every retained sample — pre-reset
|
|
75
|
+
observations cannot leak into the next window — except `resetCount`, which
|
|
76
|
+
increments monotonically so consumers can observe an accepted reset without
|
|
77
|
+
timing guesses.
|
|
78
|
+
|
|
79
|
+
## Causes are estimates
|
|
80
|
+
|
|
81
|
+
`diagnose()` orders heuristic evidence; it never confirms a root cause:
|
|
82
|
+
|
|
83
|
+
- `game-update` / `phaser-render`: update or previous-render work is at least
|
|
84
|
+
`workThresholdMs` (20 ms) and at least 35% of the gap. `phaser-render`
|
|
85
|
+
names the consuming engine's render callback (the kit's primary engine is
|
|
86
|
+
Phaser); the classification itself is engine-independent.
|
|
87
|
+
- `memory-reclamation`: a 100–1000 ms gap with a heap drop of at least 10 MiB
|
|
88
|
+
(`HEAP_RECLAMATION_THRESHOLD_BYTES`). A heap drop alone does not prove a GC
|
|
89
|
+
pause caused the hitch; multi-second gaps are excluded because collectors
|
|
90
|
+
also run while the renderer is suspended.
|
|
91
|
+
- `main-thread-long-task` / `browser-rendering`: a retained observation
|
|
92
|
+
overlapping the frame window with tolerance. Overlap does not prove the
|
|
93
|
+
observation blocked this frame.
|
|
94
|
+
- `resource-load`: an overlapping resource timing entry. Resource timing
|
|
95
|
+
measures network/decode work, not main-thread blocking.
|
|
96
|
+
- `scheduler-or-compositor`: the residual estimate when nothing else explains
|
|
97
|
+
the gap — including long animation frames whose reported blocking, script,
|
|
98
|
+
and style/layout work are under 10% of a multi-second gap (renderer
|
|
99
|
+
suspension). The absence of observations never proves the game performed
|
|
100
|
+
well.
|
|
101
|
+
|
|
102
|
+
## Input validation
|
|
103
|
+
|
|
104
|
+
All constructor options and samples are validated before any aggregate
|
|
105
|
+
changes, so a rejected input never leaves partially updated state. The
|
|
106
|
+
recorder throws `Error` with a field-qualified message when it receives:
|
|
107
|
+
|
|
108
|
+
- non-finite, negative, or NaN/Infinity durations and timestamps;
|
|
109
|
+
- thresholds that are not finite positive numbers;
|
|
110
|
+
- a `historyLimit` that is not an integer in
|
|
111
|
+
`[0, MAX_FRAME_HITCH_HISTORY_LIMIT]` (1 000); `0` is valid and retains no
|
|
112
|
+
samples while every counter keeps working;
|
|
113
|
+
- observation intervals where `atMs` precedes `startAtMs`;
|
|
114
|
+
- script attribution arrays longer than
|
|
115
|
+
`MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES` (8), or raw timing arrays longer
|
|
116
|
+
than `MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS` (1 024).
|
|
117
|
+
|
|
118
|
+
`heapDeltaBytes` only requires a finite number: negative deltas are normal
|
|
119
|
+
collection signals and are not validated like durations.
|
|
120
|
+
|
|
121
|
+
## Example
|
|
122
|
+
|
|
123
|
+
A toy game wires its own clocks and observers, then records:
|
|
124
|
+
|
|
125
|
+
```ts
|
|
126
|
+
import {
|
|
127
|
+
FrameHitchRecorder,
|
|
128
|
+
createLongAnimationFrameScriptSamples,
|
|
129
|
+
readUsedHeapBytes,
|
|
130
|
+
type FrameHitchSample,
|
|
131
|
+
} from '@mpgd/runtime-diagnostics';
|
|
132
|
+
|
|
133
|
+
interface ToyFrameSample extends FrameHitchSample {
|
|
134
|
+
readonly enemies: number;
|
|
135
|
+
readonly phase: string;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// The game owns clock access, observer installation, and visibility state.
|
|
139
|
+
const recorder = new FrameHitchRecorder<ToyFrameSample>({
|
|
140
|
+
hitchThresholdMs: 50,
|
|
141
|
+
workThresholdMs: 20,
|
|
142
|
+
historyLimit: 12,
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
let previousHeapBytes = readUsedHeapBytes(performance);
|
|
146
|
+
|
|
147
|
+
// The game times its own callbacks and reads its own visibility state.
|
|
148
|
+
function onFrame(frame: {
|
|
149
|
+
atMs: number;
|
|
150
|
+
frameDeltaMs: number;
|
|
151
|
+
updateWorkMs: number;
|
|
152
|
+
previousRenderWorkMs: number;
|
|
153
|
+
visibilityInterrupted: boolean;
|
|
154
|
+
}): void {
|
|
155
|
+
const heapBytes = readUsedHeapBytes(performance);
|
|
156
|
+
recorder.record({
|
|
157
|
+
atMs: frame.atMs,
|
|
158
|
+
enemies: countEnemies(),
|
|
159
|
+
frameDeltaMs: frame.frameDeltaMs,
|
|
160
|
+
hidden: document.visibilityState === 'hidden',
|
|
161
|
+
heapBytes,
|
|
162
|
+
heapDeltaBytes: heapBytes !== undefined && previousHeapBytes !== undefined
|
|
163
|
+
? heapBytes - previousHeapBytes
|
|
164
|
+
: undefined,
|
|
165
|
+
phase: currentPhase(),
|
|
166
|
+
previousRenderWorkMs: frame.previousRenderWorkMs,
|
|
167
|
+
updateWorkMs: frame.updateWorkMs,
|
|
168
|
+
visibilityInterrupted: frame.visibilityInterrupted,
|
|
169
|
+
});
|
|
170
|
+
previousHeapBytes = heapBytes;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// The game maps PerformanceObserver entries to samples itself; the shapes
|
|
174
|
+
// below are already the recorder's input types, not browser types.
|
|
175
|
+
function onLongAnimationFrame(observed: {
|
|
176
|
+
startTime: number;
|
|
177
|
+
duration: number;
|
|
178
|
+
blockingDuration: number;
|
|
179
|
+
renderDuration: number;
|
|
180
|
+
scriptDuration: number;
|
|
181
|
+
scriptInvocations: number;
|
|
182
|
+
styleAndLayoutDuration: number;
|
|
183
|
+
scripts: readonly {
|
|
184
|
+
duration?: number;
|
|
185
|
+
forcedStyleAndLayoutDuration?: number;
|
|
186
|
+
invoker?: string;
|
|
187
|
+
invokerType?: string;
|
|
188
|
+
pauseDuration?: number;
|
|
189
|
+
sourceFunctionName?: string;
|
|
190
|
+
sourceURL?: string;
|
|
191
|
+
}[];
|
|
192
|
+
hidden: boolean;
|
|
193
|
+
}): void {
|
|
194
|
+
recorder.recordLongAnimationFrame({
|
|
195
|
+
atMs: observed.startTime + observed.duration,
|
|
196
|
+
blockingDurationMs: observed.blockingDuration,
|
|
197
|
+
durationMs: observed.duration,
|
|
198
|
+
hidden: observed.hidden,
|
|
199
|
+
renderDurationMs: observed.renderDuration,
|
|
200
|
+
scriptDurationMs: observed.scriptDuration,
|
|
201
|
+
scriptInvocations: observed.scriptInvocations,
|
|
202
|
+
scripts: createLongAnimationFrameScriptSamples(
|
|
203
|
+
observed.scripts,
|
|
204
|
+
(sourceUrl) => sanitizeSourceUrl(sourceUrl),
|
|
205
|
+
),
|
|
206
|
+
startAtMs: observed.startTime,
|
|
207
|
+
styleAndLayoutDurationMs: observed.styleAndLayoutDuration,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Read a snapshot for a debug overlay or a bug report attachment.
|
|
212
|
+
const snapshot = recorder.snapshot();
|
|
213
|
+
console.log(snapshot.averageFrameMs, snapshot.hitchCauseCounts, snapshot.hitches.at(-1));
|
|
214
|
+
|
|
215
|
+
// Start a fresh window; only `resetCount` survives.
|
|
216
|
+
recorder.reset();
|
|
217
|
+
```
|
|
218
|
+
|
|
219
|
+
The example's `onFrame` / `onLongAnimationFrame` signatures are illustrative:
|
|
220
|
+
the consumer decides how engine callbacks and `PerformanceObserver` entries
|
|
221
|
+
map onto the recorder's input types. This package deliberately defines no
|
|
222
|
+
browser types beyond the structural `HeapMemorySource`.
|
|
223
|
+
|
|
224
|
+
## Ownership of samples
|
|
225
|
+
|
|
226
|
+
Recorded samples and snapshot results are shared by reference at the
|
|
227
|
+
container level: `snapshot()` returns fresh arrays and shallow copies of
|
|
228
|
+
samples, so mutating the array, the cause counts, or a sample's numeric base
|
|
229
|
+
fields cannot corrupt the recorder. Nested game-context objects inside a
|
|
230
|
+
sample are **not** deep-cloned; treat every sample you hand to `record()` and
|
|
231
|
+
every object inside a snapshot as read-only from that point on.
|
|
232
|
+
|
|
233
|
+
## Memory bounds
|
|
234
|
+
|
|
235
|
+
Every retained list is capped at `historyLimit` (default 12, at most
|
|
236
|
+
`MAX_FRAME_HITCH_HISTORY_LIMIT` = 1 000). Worst values and averages are
|
|
237
|
+
running accumulators, not lists. `snapshot()` correlates each retained hitch
|
|
238
|
+
against each retained observation, so its cost grows with
|
|
239
|
+
`hitches × observations`; the 1 000 cap bounds the worst case to a few
|
|
240
|
+
million comparisons while the default configuration stays trivial. Long
|
|
241
|
+
animation frame samples carry at most eight sanitized script attributions,
|
|
242
|
+
and raw observer attribution objects are never stored — `sourceLabel` is the
|
|
243
|
+
explicit sanitization boundary that keeps URLs, query strings, and tokens out
|
|
244
|
+
of diagnostic reports.
|
|
245
|
+
|
|
246
|
+
## Package status
|
|
247
|
+
|
|
248
|
+
Published to npm as `@mpgd/runtime-diagnostics@0.1.0` via an initial local
|
|
249
|
+
registration under the maintainer's auth, with a `.sampo/config.toml`
|
|
250
|
+
release-group entry for automated releases. Subsequent versions ship through
|
|
251
|
+
Sampo changesets once the package's npm Trusted Publishing/OIDC entry for
|
|
252
|
+
`.github/workflows/release.yml` is registered on npmjs.com.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine-independent frame performance diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* This module records game-supplied frame samples, estimates causes for frame
|
|
5
|
+
* hitches using optional browser observations, and aggregates bounded
|
|
6
|
+
* snapshots. It installs no observers, reads no platform globals, starts no
|
|
7
|
+
* timers, and performs no I/O: wiring `performance`, `PerformanceObserver`,
|
|
8
|
+
* `document.visibilityState`, engine events, domain fields, and report sinks
|
|
9
|
+
* is the consumer's responsibility.
|
|
10
|
+
*
|
|
11
|
+
* Time contract: every timestamp (`atMs`, `startAtMs`) and duration is in
|
|
12
|
+
* milliseconds on one monotonic, non-decreasing clock supplied by the consumer
|
|
13
|
+
* (for example `performance.now()`). The recorder never reads a clock itself,
|
|
14
|
+
* so tests can drive it with synthetic timestamps.
|
|
15
|
+
*/
|
|
16
|
+
export interface FrameHitchSample {
|
|
17
|
+
/**
|
|
18
|
+
* Completion time of this frame's update work, in milliseconds on the
|
|
19
|
+
* consumer's monotonic clock. Must not be negative.
|
|
20
|
+
*/
|
|
21
|
+
readonly atMs: number;
|
|
22
|
+
/**
|
|
23
|
+
* Gap since the previous frame at the moment this frame ran. This gap is
|
|
24
|
+
* the frame this sample describes: it contains the previous frame's render
|
|
25
|
+
* callback, browser presentational work, idle time, and this frame's update
|
|
26
|
+
* callback.
|
|
27
|
+
*/
|
|
28
|
+
readonly frameDeltaMs: number;
|
|
29
|
+
/** True when the page was hidden while this frame was sampled. */
|
|
30
|
+
readonly hidden: boolean;
|
|
31
|
+
/** Optional total used-JS-heap reading taken at this frame, in bytes. */
|
|
32
|
+
readonly heapBytes?: number;
|
|
33
|
+
/**
|
|
34
|
+
* Optional change in used JS heap since the previous reading, in bytes.
|
|
35
|
+
* Negative values are normal (allocation churn reversal and collection) and
|
|
36
|
+
* are deliberately not validated like durations.
|
|
37
|
+
*/
|
|
38
|
+
readonly heapDeltaBytes?: number;
|
|
39
|
+
/**
|
|
40
|
+
* Duration of the previous frame's render callback. It is attributed to
|
|
41
|
+
* this sample's `frameDeltaMs` gap because that render executed just
|
|
42
|
+
* before this frame became current — not to the previous sample's gap.
|
|
43
|
+
*/
|
|
44
|
+
readonly previousRenderWorkMs: number;
|
|
45
|
+
/** Duration of this frame's update callback, ending at `atMs`. */
|
|
46
|
+
readonly updateWorkMs: number;
|
|
47
|
+
/** True when this sample's gap crossed a document visibility transition. */
|
|
48
|
+
readonly visibilityInterrupted: boolean;
|
|
49
|
+
}
|
|
50
|
+
export declare const FRAME_HITCH_CAUSES: readonly ['browser-rendering', 'game-update', 'main-thread-long-task', 'memory-reclamation', 'phaser-render', 'resource-load', 'scheduler-or-compositor'];
|
|
51
|
+
export type FrameHitchCause = typeof FRAME_HITCH_CAUSES[number];
|
|
52
|
+
/**
|
|
53
|
+
* Estimated explanation for one hitch. Every cause is a heuristic verdict
|
|
54
|
+
* computed from the recorded evidence, never a confirmed root cause: see each
|
|
55
|
+
* threshold constant below for what a cause does and does not prove.
|
|
56
|
+
*/
|
|
57
|
+
export interface FrameHitchDiagnosis {
|
|
58
|
+
readonly atMs: number;
|
|
59
|
+
readonly cause: FrameHitchCause;
|
|
60
|
+
readonly frameDeltaMs: number;
|
|
61
|
+
readonly relatedLongAnimationFrameMs?: number;
|
|
62
|
+
readonly relatedLongTaskMs?: number;
|
|
63
|
+
readonly relatedResourceLoadMs?: number;
|
|
64
|
+
}
|
|
65
|
+
/** Frames whose gap reaches this threshold are hitches. */
|
|
66
|
+
export declare const FRAME_HITCH_THRESHOLD_MS = 50;
|
|
67
|
+
/**
|
|
68
|
+
* Update or render work reaching this threshold marks a hitch even when the
|
|
69
|
+
* frame gap itself stayed below `FRAME_HITCH_THRESHOLD_MS`.
|
|
70
|
+
*/
|
|
71
|
+
export declare const FRAME_WORK_THRESHOLD_MS = 20;
|
|
72
|
+
/**
|
|
73
|
+
* Heap-reclamation estimation only applies to gaps of at least this length;
|
|
74
|
+
* shorter gaps with heap drops stay attributed to whatever other evidence
|
|
75
|
+
* exists, because incidental minor collections are common in healthy frames.
|
|
76
|
+
*/
|
|
77
|
+
export declare const HEAP_RECLAMATION_FRAME_THRESHOLD_MS = 100;
|
|
78
|
+
/**
|
|
79
|
+
* A heap drop at or below this value (that is, a large collection) is
|
|
80
|
+
* evidence for `memory-reclamation` — it does not prove a major GC pause was
|
|
81
|
+
* the hitch's root cause.
|
|
82
|
+
*/
|
|
83
|
+
export declare const HEAP_RECLAMATION_THRESHOLD_BYTES: number;
|
|
84
|
+
/**
|
|
85
|
+
* Gaps of at least this length with no explanatory game, render, or
|
|
86
|
+
* observation work are estimated as `scheduler-or-compositor` interruptions.
|
|
87
|
+
*/
|
|
88
|
+
export declare const SCHEDULER_INTERRUPTION_THRESHOLD_MS = 1000;
|
|
89
|
+
/** Default retained history length per sample list. */
|
|
90
|
+
export declare const FRAME_HITCH_HISTORY_LIMIT = 12;
|
|
91
|
+
/** Upper bound accepted for `historyLimit`. `snapshot()` cost grows with retained hitches times retained observations, so the bound caps the worst case rather than the defaults. */
|
|
92
|
+
export declare const MAX_FRAME_HITCH_HISTORY_LIMIT = 1000;
|
|
93
|
+
export interface LongTaskSample {
|
|
94
|
+
/** Completion time of the task, in milliseconds. Must not precede `startAtMs`. */
|
|
95
|
+
readonly atMs: number;
|
|
96
|
+
/** Task duration in milliseconds. */
|
|
97
|
+
readonly durationMs: number;
|
|
98
|
+
/** True when the task executed while the page was hidden. Hidden tasks are ignored. */
|
|
99
|
+
readonly hidden: boolean;
|
|
100
|
+
/** Consumer-sanitized diagnostic label for the task source. */
|
|
101
|
+
readonly name: string;
|
|
102
|
+
/** Start time of the task, in milliseconds. */
|
|
103
|
+
readonly startAtMs: number;
|
|
104
|
+
}
|
|
105
|
+
export interface LongAnimationFrameSample {
|
|
106
|
+
/** Completion time of the animation frame, in milliseconds. */
|
|
107
|
+
readonly atMs: number;
|
|
108
|
+
/** Blocking duration in milliseconds, as reported by the observer. */
|
|
109
|
+
readonly blockingDurationMs: number;
|
|
110
|
+
/** Total animation frame duration in milliseconds. */
|
|
111
|
+
readonly durationMs: number;
|
|
112
|
+
/** True when the frame executed while the page was hidden. Hidden frames are ignored. */
|
|
113
|
+
readonly hidden: boolean;
|
|
114
|
+
/** Render duration in milliseconds, as reported by the observer. */
|
|
115
|
+
readonly renderDurationMs: number;
|
|
116
|
+
/** Script duration in milliseconds, as reported by the observer. */
|
|
117
|
+
readonly scriptDurationMs: number;
|
|
118
|
+
/** Number of script invocations, as reported by the observer. */
|
|
119
|
+
readonly scriptInvocations: number;
|
|
120
|
+
/**
|
|
121
|
+
* Bounded, already-sanitized script attributions. Retain at most
|
|
122
|
+
* `MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES` entries; raw observer
|
|
123
|
+
* attribution objects must never be stored here.
|
|
124
|
+
*/
|
|
125
|
+
readonly scripts?: readonly LongAnimationFrameScriptSample[];
|
|
126
|
+
/** Start time of the animation frame, in milliseconds. */
|
|
127
|
+
readonly startAtMs: number;
|
|
128
|
+
/** Style and layout duration in milliseconds, as reported by the observer. */
|
|
129
|
+
readonly styleAndLayoutDurationMs: number;
|
|
130
|
+
}
|
|
131
|
+
export interface LongAnimationFrameScriptSample {
|
|
132
|
+
readonly durationMs: number;
|
|
133
|
+
readonly forcedStyleAndLayoutDurationMs: number;
|
|
134
|
+
readonly invoker: string;
|
|
135
|
+
readonly invokerType: string;
|
|
136
|
+
readonly pauseDurationMs: number;
|
|
137
|
+
readonly sourceFunctionName: string;
|
|
138
|
+
/** Sanitized label produced by the consumer's `sourceLabel` callback. */
|
|
139
|
+
readonly sourceUrl: string;
|
|
140
|
+
}
|
|
141
|
+
/** Structural subset of a `PerformanceScriptTiming` entry as reported by observers. */
|
|
142
|
+
export interface LongAnimationFrameScriptTiming {
|
|
143
|
+
readonly duration?: number;
|
|
144
|
+
readonly forcedStyleAndLayoutDuration?: number;
|
|
145
|
+
readonly invoker?: string;
|
|
146
|
+
readonly invokerType?: string;
|
|
147
|
+
readonly pauseDuration?: number;
|
|
148
|
+
readonly sourceFunctionName?: string;
|
|
149
|
+
readonly sourceURL?: string;
|
|
150
|
+
}
|
|
151
|
+
/** Maximum script attributions retained per long animation frame sample. */
|
|
152
|
+
export declare const MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES = 8;
|
|
153
|
+
/** Maximum raw script timings accepted by `createLongAnimationFrameScriptSamples`. */
|
|
154
|
+
export declare const MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS = 1024;
|
|
155
|
+
/**
|
|
156
|
+
* Retain only the longest script attributions so diagnostic reports stay
|
|
157
|
+
* predictably bounded. `sourceLabel` is the sanitization boundary: raw
|
|
158
|
+
* observer URLs (which may carry query strings or tokens) must be reduced to
|
|
159
|
+
* a safe label here and are never stored verbatim.
|
|
160
|
+
*/
|
|
161
|
+
export declare function createLongAnimationFrameScriptSamples(scripts: readonly LongAnimationFrameScriptTiming[], sourceLabel: (sourceUrl: string) => string): readonly LongAnimationFrameScriptSample[];
|
|
162
|
+
export interface ResourceLoadSample {
|
|
163
|
+
/** Completion time of the load, in milliseconds. Must not precede `startAtMs`. */
|
|
164
|
+
readonly atMs: number;
|
|
165
|
+
/** Load duration in milliseconds. */
|
|
166
|
+
readonly durationMs: number;
|
|
167
|
+
/** True when the load completed while the page was hidden. Hidden loads are ignored. */
|
|
168
|
+
readonly hidden: boolean;
|
|
169
|
+
/** Consumer-sanitized diagnostic label for the resource. */
|
|
170
|
+
readonly name: string;
|
|
171
|
+
/** Start time of the load, in milliseconds. */
|
|
172
|
+
readonly startAtMs: number;
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Aggregated view over one recording window. Counters marked cumulative are
|
|
176
|
+
* never trimmed by history eviction; fields backed by retained history reflect
|
|
177
|
+
* only the samples still held (see `hitchCauseCounts`).
|
|
178
|
+
*/
|
|
179
|
+
export interface FramePerformanceSnapshot<TSample extends FrameHitchSample = FrameHitchSample> {
|
|
180
|
+
/** Mean frame delta over foreground frames only; interrupted frames are excluded. */
|
|
181
|
+
readonly averageFrameMs: number;
|
|
182
|
+
/** Mean previous-frame render work over foreground frames only. */
|
|
183
|
+
readonly averageRenderWorkMs: number;
|
|
184
|
+
/** Mean update work over foreground frames only. */
|
|
185
|
+
readonly averageUpdateWorkMs: number;
|
|
186
|
+
/** Frames sampled while active and uninterrupted (frameCount minus interruptionCount). */
|
|
187
|
+
readonly foregroundFrameCount: number;
|
|
188
|
+
/** Cumulative count of every sampled frame, including interrupted frames. */
|
|
189
|
+
readonly frameCount: number;
|
|
190
|
+
/** Cumulative hitch count; unlike `hitches` it never shrinks on eviction. */
|
|
191
|
+
readonly hitchCount: number;
|
|
192
|
+
/**
|
|
193
|
+
* Shallow copies of retained hitch samples. Mutating the array or the
|
|
194
|
+
* numeric base fields of a returned sample cannot corrupt the recorder;
|
|
195
|
+
* nested game-context objects are shared references and must be treated as
|
|
196
|
+
* read-only.
|
|
197
|
+
*/
|
|
198
|
+
readonly hitches: readonly TSample[];
|
|
199
|
+
/**
|
|
200
|
+
* Cause counts over the retained hitch history only; eviction shrinks these
|
|
201
|
+
* counts while `hitchCount` stays cumulative for the window.
|
|
202
|
+
*/
|
|
203
|
+
readonly hitchCauseCounts: Readonly<Record<FrameHitchCause, number>>;
|
|
204
|
+
/** Cumulative sum of frame deltas over frames excluded from the foreground averages. */
|
|
205
|
+
readonly interruptedFrameMs: number;
|
|
206
|
+
/** Cumulative count of hidden, visibility-interrupted, and estimated scheduler-interrupted frames. */
|
|
207
|
+
readonly interruptionCount: number;
|
|
208
|
+
/** Shallow copy of the most recent frame by `atMs`; omitted before the first frame. */
|
|
209
|
+
readonly lastFrame?: TSample;
|
|
210
|
+
/** Diagnosis recomputed for the newest retained hitch; omitted when none is retained. */
|
|
211
|
+
readonly lastHitchDiagnosis?: FrameHitchDiagnosis;
|
|
212
|
+
/** Shallow copy of the newest visible long animation frame; omitted when none was recorded. */
|
|
213
|
+
readonly lastLongAnimationFrame?: LongAnimationFrameSample;
|
|
214
|
+
/** Shallow copy of the newest visible long task; omitted when none was recorded. */
|
|
215
|
+
readonly lastLongTask?: LongTaskSample;
|
|
216
|
+
/** Shallow copy of the newest visible resource load; omitted when none was recorded. */
|
|
217
|
+
readonly lastResourceLoad?: ResourceLoadSample;
|
|
218
|
+
/** Cumulative visible long animation frame count. */
|
|
219
|
+
readonly longAnimationFrameCount: number;
|
|
220
|
+
/** Cumulative visible long task count. */
|
|
221
|
+
readonly longTaskCount: number;
|
|
222
|
+
/**
|
|
223
|
+
* Cumulative memory-reclamation diagnoses; unlike
|
|
224
|
+
* `hitchCauseCounts['memory-reclamation']` this is never history-trimmed.
|
|
225
|
+
*/
|
|
226
|
+
readonly memoryReclamationHitchCount: number;
|
|
227
|
+
/** Monotonic marker incremented by every `reset()`; the only field reset never clears. */
|
|
228
|
+
readonly resetCount: number;
|
|
229
|
+
/** Cumulative visible resource load count. */
|
|
230
|
+
readonly resourceLoadCount: number;
|
|
231
|
+
/** Cumulative count of estimated scheduler-or-compositor interruptions. */
|
|
232
|
+
readonly schedulerInterruptionCount: number;
|
|
233
|
+
/** Worst foreground frame delta; interrupted frames are excluded from this worst. */
|
|
234
|
+
readonly worstFrameMs: number;
|
|
235
|
+
/** Diagnosis of the worst foreground hitch; omitted when the worst frame stayed under the hitch threshold. */
|
|
236
|
+
readonly worstHitchDiagnosis?: FrameHitchDiagnosis;
|
|
237
|
+
/** Worst frame delta among interrupted frames (hidden, visibility, or estimated scheduler). */
|
|
238
|
+
readonly worstInterruptedFrameMs: number;
|
|
239
|
+
/** Worst visible long animation frame duration. */
|
|
240
|
+
readonly worstLongAnimationFrameMs: number;
|
|
241
|
+
/** Worst visible long task duration. */
|
|
242
|
+
readonly worstLongTaskMs: number;
|
|
243
|
+
/** Worst visible resource load duration. */
|
|
244
|
+
readonly worstResourceLoadMs: number;
|
|
245
|
+
/** Worst previous-frame render work over foreground frames. */
|
|
246
|
+
readonly worstRenderWorkMs: number;
|
|
247
|
+
/** Worst update work over foreground frames. */
|
|
248
|
+
readonly worstUpdateWorkMs: number;
|
|
249
|
+
}
|
|
250
|
+
/** Constructor settings for `FrameHitchRecorder`. */
|
|
251
|
+
export interface FrameHitchRecorderOptions {
|
|
252
|
+
/** Frame gaps at or above this threshold count as hitches. Must be finite and positive. */
|
|
253
|
+
readonly hitchThresholdMs?: number;
|
|
254
|
+
/** Update or render work at or above this threshold can mark a hitch on its own. Must be finite and positive. */
|
|
255
|
+
readonly workThresholdMs?: number;
|
|
256
|
+
/**
|
|
257
|
+
* Maximum retained samples per history list. `0` retains no samples while
|
|
258
|
+
* every cumulative counter keeps working. Must be an integer in
|
|
259
|
+
* `[0, MAX_FRAME_HITCH_HISTORY_LIMIT]`.
|
|
260
|
+
*/
|
|
261
|
+
readonly historyLimit?: number;
|
|
262
|
+
}
|
|
263
|
+
export declare class FrameHitchRecorder<TSample extends FrameHitchSample = FrameHitchSample> {
|
|
264
|
+
private static readonly browserWorkShareLimit;
|
|
265
|
+
private static readonly explanatoryWorkRatio;
|
|
266
|
+
private static readonly relatedSampleOverlapToleranceMs;
|
|
267
|
+
private static readonly scriptDominanceFloorMs;
|
|
268
|
+
private frameCount;
|
|
269
|
+
private hitchCount;
|
|
270
|
+
private readonly hitches;
|
|
271
|
+
private interruptionCount;
|
|
272
|
+
private lastFrame;
|
|
273
|
+
private readonly longAnimationFrames;
|
|
274
|
+
private lastLongAnimationFrame;
|
|
275
|
+
private lastLongTask;
|
|
276
|
+
private lastResourceLoad;
|
|
277
|
+
private longAnimationFrameCount;
|
|
278
|
+
private readonly longTasks;
|
|
279
|
+
private longTaskCount;
|
|
280
|
+
private memoryReclamationHitchCount;
|
|
281
|
+
private resetCount;
|
|
282
|
+
private resourceLoadCount;
|
|
283
|
+
private readonly resourceLoads;
|
|
284
|
+
private schedulerInterruptionCount;
|
|
285
|
+
private totalFrameMs;
|
|
286
|
+
private totalInterruptedFrameMs;
|
|
287
|
+
private totalRenderWorkMs;
|
|
288
|
+
private totalUpdateWorkMs;
|
|
289
|
+
private worstFrameMs;
|
|
290
|
+
private worstFrameSample;
|
|
291
|
+
private worstInterruptedFrameMs;
|
|
292
|
+
private worstLongAnimationFrameMs;
|
|
293
|
+
private worstLongTaskMs;
|
|
294
|
+
private worstResourceLoadMs;
|
|
295
|
+
private worstRenderWorkMs;
|
|
296
|
+
private worstUpdateWorkMs;
|
|
297
|
+
private readonly hitchThresholdMs;
|
|
298
|
+
private readonly workThresholdMs;
|
|
299
|
+
private readonly historyLimit;
|
|
300
|
+
constructor(options?: FrameHitchRecorderOptions);
|
|
301
|
+
/**
|
|
302
|
+
* Record one frame sample.
|
|
303
|
+
*
|
|
304
|
+
* Frames that were hidden, crossed a visibility transition, or are
|
|
305
|
+
* estimated scheduler interruptions are counted in `interruptionCount`
|
|
306
|
+
* (with their time in `interruptedFrameMs`) and excluded from foreground
|
|
307
|
+
* averages, hitches, and foreground worsts — they are never silently
|
|
308
|
+
* dropped. The scheduler estimate is decided at record time from the
|
|
309
|
+
* observations retained at that moment and is final: unlike retained
|
|
310
|
+
* hitches, an observation arriving later does not reclassify it, so the
|
|
311
|
+
* excluded gap stays visible through the interruption counters.
|
|
312
|
+
*/
|
|
313
|
+
record(sample: TSample): void;
|
|
314
|
+
/**
|
|
315
|
+
* Record one long animation frame observation. Hidden samples are validated
|
|
316
|
+
* and then ignored. Out-of-order samples are accepted: counts, worsts, and
|
|
317
|
+
* histories account for them, `last*` keeps the newest sample by `atMs`, and
|
|
318
|
+
* eviction drops the oldest by `atMs` so a late buffered sample cannot evict
|
|
319
|
+
* newer evidence. Because diagnoses are recomputed at `snapshot()` time from
|
|
320
|
+
* the observations still retained, a sample arriving after a retained hitch
|
|
321
|
+
* can retroactively change that hitch's cause label until either sample is
|
|
322
|
+
* evicted. Estimated scheduler interruptions, however, are decided at record
|
|
323
|
+
* time and are not re-examined later.
|
|
324
|
+
*/
|
|
325
|
+
recordLongAnimationFrame(sample: LongAnimationFrameSample): void;
|
|
326
|
+
/** Record one long task observation. Hidden samples are validated and then ignored. */
|
|
327
|
+
recordLongTask(sample: LongTaskSample): void;
|
|
328
|
+
/** Record one resource load observation. Hidden samples are validated and then ignored. */
|
|
329
|
+
recordResourceLoad(sample: ResourceLoadSample): void;
|
|
330
|
+
/**
|
|
331
|
+
* Estimate the cause of one frame's gap.
|
|
332
|
+
*
|
|
333
|
+
* The verdict is ordered heuristic evidence, not a confirmed root cause:
|
|
334
|
+
*
|
|
335
|
+
* - `game-update` / `phaser-render`: the frame's own update work, or the
|
|
336
|
+
* previous frame's render work, is at least `workThresholdMs` and at
|
|
337
|
+
* least `explanatoryWorkRatio` of the gap. (`phaser-render` names the
|
|
338
|
+
* render callback of the consuming engine; the classification itself is
|
|
339
|
+
* engine-independent.)
|
|
340
|
+
* - `memory-reclamation`: a 100–1000 ms gap with a heap drop at or below
|
|
341
|
+
* `HEAP_RECLAMATION_THRESHOLD_BYTES`. A heap drop alone does not prove a
|
|
342
|
+
* GC pause caused the hitch, and multi-second gaps are excluded because
|
|
343
|
+
* collectors also run while the renderer is suspended.
|
|
344
|
+
* - `main-thread-long-task` / `browser-rendering`: a retained observation
|
|
345
|
+
* overlapping the frame window. Overlap does not prove the observation
|
|
346
|
+
* blocked this frame's main thread.
|
|
347
|
+
* - `scheduler-or-compositor`: the residual estimate when nothing else
|
|
348
|
+
* explains the gap, including long LoAF entries whose reported blocking,
|
|
349
|
+
* script, and style/layout work are a negligible share of the gap
|
|
350
|
+
* (renderer suspension). The absence of observations never proves the
|
|
351
|
+
* game performed well.
|
|
352
|
+
*/
|
|
353
|
+
diagnose(sample: TSample): FrameHitchDiagnosis;
|
|
354
|
+
/**
|
|
355
|
+
* Clear every aggregate and retained sample for the next recording window.
|
|
356
|
+
* `resetCount` is the only field that survives: it increments monotonically
|
|
357
|
+
* so diagnostics can observe an accepted reset without timing guesses. Old
|
|
358
|
+
* observations cannot leak into the new window because all observation
|
|
359
|
+
* histories are cleared too.
|
|
360
|
+
*/
|
|
361
|
+
reset(): void;
|
|
362
|
+
/** Monotonic reset marker; equals `snapshot().resetCount`. */
|
|
363
|
+
getResetCount(): number;
|
|
364
|
+
snapshot(): FramePerformanceSnapshot<TSample>;
|
|
365
|
+
/**
|
|
366
|
+
* Find the most explanatory observation overlapping a frame's gap window.
|
|
367
|
+
*
|
|
368
|
+
* The window is `[updateStart - frameDeltaMs, updateStart]`, where
|
|
369
|
+
* `updateStart = atMs - updateWorkMs` is where this frame's update callback
|
|
370
|
+
* began. Work inside the update callback itself is deliberately excluded:
|
|
371
|
+
* it is already measured by `updateWorkMs` and diagnosed as `game-update`,
|
|
372
|
+
* and work after `atMs` belongs to the next frame. Anchoring the gap at the
|
|
373
|
+
* update start rather than at `atMs` keeps the previous frame's render and
|
|
374
|
+
* any pre-update blocking associated with this gap without absorbing
|
|
375
|
+
* neighboring frames' work.
|
|
376
|
+
*/
|
|
377
|
+
private findRelated;
|
|
378
|
+
private isMemoryReclamationHitch;
|
|
379
|
+
/**
|
|
380
|
+
* Keep a retained sample history ordered by `atMs` and bounded, so eviction
|
|
381
|
+
* always drops the oldest sample by event time rather than by arrival
|
|
382
|
+
* order. Mostly-sorted pushes make the sort near-linear; the sort runs only
|
|
383
|
+
* when a sample is actually recorded, never per frame.
|
|
384
|
+
*/
|
|
385
|
+
private trimHistoryByAtMs;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Structural subset of `Performance` carrying the Chromium-only `memory`
|
|
389
|
+
* extension. Accepting the structural shape (instead of the DOM type) keeps
|
|
390
|
+
* this package importable in DOM-free environments; the caller owns how the
|
|
391
|
+
* source object is obtained.
|
|
392
|
+
*/
|
|
393
|
+
export interface HeapMemorySource {
|
|
394
|
+
readonly memory?: {
|
|
395
|
+
readonly usedJSHeapSize?: number;
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
/** Read the used JS heap size from a supplied source; `undefined` when unavailable or non-finite. */
|
|
399
|
+
export declare function readUsedHeapBytes(source: HeapMemorySource | undefined): number | undefined;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,631 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Engine-independent frame performance diagnostics.
|
|
3
|
+
*
|
|
4
|
+
* This module records game-supplied frame samples, estimates causes for frame
|
|
5
|
+
* hitches using optional browser observations, and aggregates bounded
|
|
6
|
+
* snapshots. It installs no observers, reads no platform globals, starts no
|
|
7
|
+
* timers, and performs no I/O: wiring `performance`, `PerformanceObserver`,
|
|
8
|
+
* `document.visibilityState`, engine events, domain fields, and report sinks
|
|
9
|
+
* is the consumer's responsibility.
|
|
10
|
+
*
|
|
11
|
+
* Time contract: every timestamp (`atMs`, `startAtMs`) and duration is in
|
|
12
|
+
* milliseconds on one monotonic, non-decreasing clock supplied by the consumer
|
|
13
|
+
* (for example `performance.now()`). The recorder never reads a clock itself,
|
|
14
|
+
* so tests can drive it with synthetic timestamps.
|
|
15
|
+
*/
|
|
16
|
+
export const FRAME_HITCH_CAUSES = [
|
|
17
|
+
'browser-rendering',
|
|
18
|
+
'game-update',
|
|
19
|
+
'main-thread-long-task',
|
|
20
|
+
'memory-reclamation',
|
|
21
|
+
'phaser-render',
|
|
22
|
+
'resource-load',
|
|
23
|
+
'scheduler-or-compositor',
|
|
24
|
+
];
|
|
25
|
+
/** Frames whose gap reaches this threshold are hitches. */
|
|
26
|
+
export const FRAME_HITCH_THRESHOLD_MS = 50;
|
|
27
|
+
/**
|
|
28
|
+
* Update or render work reaching this threshold marks a hitch even when the
|
|
29
|
+
* frame gap itself stayed below `FRAME_HITCH_THRESHOLD_MS`.
|
|
30
|
+
*/
|
|
31
|
+
export const FRAME_WORK_THRESHOLD_MS = 20;
|
|
32
|
+
/**
|
|
33
|
+
* Heap-reclamation estimation only applies to gaps of at least this length;
|
|
34
|
+
* shorter gaps with heap drops stay attributed to whatever other evidence
|
|
35
|
+
* exists, because incidental minor collections are common in healthy frames.
|
|
36
|
+
*/
|
|
37
|
+
export const HEAP_RECLAMATION_FRAME_THRESHOLD_MS = 100;
|
|
38
|
+
/**
|
|
39
|
+
* A heap drop at or below this value (that is, a large collection) is
|
|
40
|
+
* evidence for `memory-reclamation` — it does not prove a major GC pause was
|
|
41
|
+
* the hitch's root cause.
|
|
42
|
+
*/
|
|
43
|
+
export const HEAP_RECLAMATION_THRESHOLD_BYTES = -10 * 1024 * 1024;
|
|
44
|
+
/**
|
|
45
|
+
* Gaps of at least this length with no explanatory game, render, or
|
|
46
|
+
* observation work are estimated as `scheduler-or-compositor` interruptions.
|
|
47
|
+
*/
|
|
48
|
+
export const SCHEDULER_INTERRUPTION_THRESHOLD_MS = 1000;
|
|
49
|
+
/** Default retained history length per sample list. */
|
|
50
|
+
export const FRAME_HITCH_HISTORY_LIMIT = 12;
|
|
51
|
+
/** Upper bound accepted for `historyLimit`. `snapshot()` cost grows with retained hitches times retained observations, so the bound caps the worst case rather than the defaults. */
|
|
52
|
+
export const MAX_FRAME_HITCH_HISTORY_LIMIT = 1000;
|
|
53
|
+
/** Maximum script attributions retained per long animation frame sample. */
|
|
54
|
+
export const MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES = 8;
|
|
55
|
+
/** Maximum raw script timings accepted by `createLongAnimationFrameScriptSamples`. */
|
|
56
|
+
export const MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS = 1024;
|
|
57
|
+
/**
|
|
58
|
+
* Retain only the longest script attributions so diagnostic reports stay
|
|
59
|
+
* predictably bounded. `sourceLabel` is the sanitization boundary: raw
|
|
60
|
+
* observer URLs (which may carry query strings or tokens) must be reduced to
|
|
61
|
+
* a safe label here and are never stored verbatim.
|
|
62
|
+
*/
|
|
63
|
+
export function createLongAnimationFrameScriptSamples(scripts, sourceLabel) {
|
|
64
|
+
if (!Array.isArray(scripts)) {
|
|
65
|
+
throw new Error(`LongAnimationFrameScriptTiming[] must be an array (received ${describeValue(scripts)}).`);
|
|
66
|
+
}
|
|
67
|
+
if (scripts.length > MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS) {
|
|
68
|
+
throw new Error(`LongAnimationFrameScriptTiming[] length ${scripts.length} exceeds ${MAX_LONG_ANIMATION_FRAME_SCRIPT_TIMINGS}.`);
|
|
69
|
+
}
|
|
70
|
+
if (typeof sourceLabel !== 'function') {
|
|
71
|
+
throw new Error(`sourceLabel must be a function (received ${describeValue(sourceLabel)}).`);
|
|
72
|
+
}
|
|
73
|
+
for (const script of scripts) {
|
|
74
|
+
assertScriptTimingShape(script);
|
|
75
|
+
if (script.duration !== undefined) {
|
|
76
|
+
assertFiniteNonNegativeNumber(script.duration, 'LongAnimationFrameScriptTiming.duration');
|
|
77
|
+
}
|
|
78
|
+
if (script.forcedStyleAndLayoutDuration !== undefined) {
|
|
79
|
+
assertFiniteNonNegativeNumber(script.forcedStyleAndLayoutDuration, 'LongAnimationFrameScriptTiming.forcedStyleAndLayoutDuration');
|
|
80
|
+
}
|
|
81
|
+
if (script.pauseDuration !== undefined) {
|
|
82
|
+
assertFiniteNonNegativeNumber(script.pauseDuration, 'LongAnimationFrameScriptTiming.pauseDuration');
|
|
83
|
+
}
|
|
84
|
+
if (script.invoker !== undefined) {
|
|
85
|
+
assertString(script.invoker, 'LongAnimationFrameScriptTiming.invoker');
|
|
86
|
+
}
|
|
87
|
+
if (script.invokerType !== undefined) {
|
|
88
|
+
assertString(script.invokerType, 'LongAnimationFrameScriptTiming.invokerType');
|
|
89
|
+
}
|
|
90
|
+
if (script.sourceFunctionName !== undefined) {
|
|
91
|
+
assertString(script.sourceFunctionName, 'LongAnimationFrameScriptTiming.sourceFunctionName');
|
|
92
|
+
}
|
|
93
|
+
if (script.sourceURL !== undefined) {
|
|
94
|
+
assertString(script.sourceURL, 'LongAnimationFrameScriptTiming.sourceURL');
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const longestScripts = scripts
|
|
98
|
+
.map((script) => ({ durationMs: script.duration ?? 0, script }))
|
|
99
|
+
.sort((left, right) => right.durationMs - left.durationMs)
|
|
100
|
+
.slice(0, MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES);
|
|
101
|
+
return longestScripts.map(({ durationMs, script }) => {
|
|
102
|
+
const labeledSourceUrl = script.sourceURL === undefined || script.sourceURL.length === 0
|
|
103
|
+
? ''
|
|
104
|
+
: sourceLabel(script.sourceURL);
|
|
105
|
+
assertString(labeledSourceUrl, 'sourceLabel(sourceUrl) result');
|
|
106
|
+
return {
|
|
107
|
+
durationMs,
|
|
108
|
+
forcedStyleAndLayoutDurationMs: script.forcedStyleAndLayoutDuration ?? 0,
|
|
109
|
+
invoker: script.invoker ?? '',
|
|
110
|
+
invokerType: script.invokerType ?? '',
|
|
111
|
+
pauseDurationMs: script.pauseDuration ?? 0,
|
|
112
|
+
sourceFunctionName: script.sourceFunctionName ?? '',
|
|
113
|
+
sourceUrl: labeledSourceUrl,
|
|
114
|
+
};
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
export class FrameHitchRecorder {
|
|
118
|
+
static browserWorkShareLimit = 0.1;
|
|
119
|
+
static explanatoryWorkRatio = 0.35;
|
|
120
|
+
static relatedSampleOverlapToleranceMs = 16;
|
|
121
|
+
static scriptDominanceFloorMs = 20;
|
|
122
|
+
frameCount = 0;
|
|
123
|
+
hitchCount = 0;
|
|
124
|
+
hitches = [];
|
|
125
|
+
interruptionCount = 0;
|
|
126
|
+
lastFrame;
|
|
127
|
+
longAnimationFrames = [];
|
|
128
|
+
lastLongAnimationFrame;
|
|
129
|
+
lastLongTask;
|
|
130
|
+
lastResourceLoad;
|
|
131
|
+
longAnimationFrameCount = 0;
|
|
132
|
+
longTasks = [];
|
|
133
|
+
longTaskCount = 0;
|
|
134
|
+
memoryReclamationHitchCount = 0;
|
|
135
|
+
resetCount = 0;
|
|
136
|
+
resourceLoadCount = 0;
|
|
137
|
+
resourceLoads = [];
|
|
138
|
+
schedulerInterruptionCount = 0;
|
|
139
|
+
totalFrameMs = 0;
|
|
140
|
+
totalInterruptedFrameMs = 0;
|
|
141
|
+
totalRenderWorkMs = 0;
|
|
142
|
+
totalUpdateWorkMs = 0;
|
|
143
|
+
worstFrameMs = 0;
|
|
144
|
+
worstFrameSample;
|
|
145
|
+
worstInterruptedFrameMs = 0;
|
|
146
|
+
worstLongAnimationFrameMs = 0;
|
|
147
|
+
worstLongTaskMs = 0;
|
|
148
|
+
worstResourceLoadMs = 0;
|
|
149
|
+
worstRenderWorkMs = 0;
|
|
150
|
+
worstUpdateWorkMs = 0;
|
|
151
|
+
hitchThresholdMs;
|
|
152
|
+
workThresholdMs;
|
|
153
|
+
historyLimit;
|
|
154
|
+
constructor(options = {}) {
|
|
155
|
+
const hitchThresholdMs = options.hitchThresholdMs ?? FRAME_HITCH_THRESHOLD_MS;
|
|
156
|
+
const workThresholdMs = options.workThresholdMs ?? FRAME_WORK_THRESHOLD_MS;
|
|
157
|
+
const historyLimit = options.historyLimit ?? FRAME_HITCH_HISTORY_LIMIT;
|
|
158
|
+
if (!Number.isFinite(hitchThresholdMs) || hitchThresholdMs <= 0) {
|
|
159
|
+
throw new Error(`FrameHitchRecorderOptions.hitchThresholdMs must be a finite positive number (received ${describeValue(hitchThresholdMs)}).`);
|
|
160
|
+
}
|
|
161
|
+
if (!Number.isFinite(workThresholdMs) || workThresholdMs <= 0) {
|
|
162
|
+
throw new Error(`FrameHitchRecorderOptions.workThresholdMs must be a finite positive number (received ${describeValue(workThresholdMs)}).`);
|
|
163
|
+
}
|
|
164
|
+
if (!Number.isInteger(historyLimit)
|
|
165
|
+
|| historyLimit < 0
|
|
166
|
+
|| historyLimit > MAX_FRAME_HITCH_HISTORY_LIMIT) {
|
|
167
|
+
throw new Error(`FrameHitchRecorderOptions.historyLimit must be an integer between 0 and ${MAX_FRAME_HITCH_HISTORY_LIMIT} (received ${describeValue(historyLimit)}).`);
|
|
168
|
+
}
|
|
169
|
+
this.hitchThresholdMs = hitchThresholdMs;
|
|
170
|
+
this.workThresholdMs = workThresholdMs;
|
|
171
|
+
this.historyLimit = historyLimit;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Record one frame sample.
|
|
175
|
+
*
|
|
176
|
+
* Frames that were hidden, crossed a visibility transition, or are
|
|
177
|
+
* estimated scheduler interruptions are counted in `interruptionCount`
|
|
178
|
+
* (with their time in `interruptedFrameMs`) and excluded from foreground
|
|
179
|
+
* averages, hitches, and foreground worsts — they are never silently
|
|
180
|
+
* dropped. The scheduler estimate is decided at record time from the
|
|
181
|
+
* observations retained at that moment and is final: unlike retained
|
|
182
|
+
* hitches, an observation arriving later does not reclassify it, so the
|
|
183
|
+
* excluded gap stays visible through the interruption counters.
|
|
184
|
+
*/
|
|
185
|
+
record(sample) {
|
|
186
|
+
validateFrameHitchSample(sample);
|
|
187
|
+
this.frameCount += 1;
|
|
188
|
+
if (this.lastFrame === undefined || sample.atMs >= this.lastFrame.atMs) {
|
|
189
|
+
this.lastFrame = sample;
|
|
190
|
+
}
|
|
191
|
+
// Diagnose once per record: the verdict is deterministic for unchanged
|
|
192
|
+
// recorder state, and re-scanning the observation histories would bill
|
|
193
|
+
// the frame loop of an already-hitching game twice.
|
|
194
|
+
let diagnosis;
|
|
195
|
+
const schedulerCandidate = !sample.hidden
|
|
196
|
+
&& !sample.visibilityInterrupted
|
|
197
|
+
&& sample.frameDeltaMs >= SCHEDULER_INTERRUPTION_THRESHOLD_MS;
|
|
198
|
+
if (schedulerCandidate) {
|
|
199
|
+
diagnosis = this.diagnose(sample);
|
|
200
|
+
}
|
|
201
|
+
const schedulerInterrupted = schedulerCandidate
|
|
202
|
+
&& diagnosis !== undefined
|
|
203
|
+
&& diagnosis.cause === 'scheduler-or-compositor';
|
|
204
|
+
if (sample.hidden || sample.visibilityInterrupted || schedulerInterrupted) {
|
|
205
|
+
this.interruptionCount += 1;
|
|
206
|
+
this.schedulerInterruptionCount += Number(schedulerInterrupted);
|
|
207
|
+
this.totalInterruptedFrameMs += sample.frameDeltaMs;
|
|
208
|
+
this.worstInterruptedFrameMs = Math.max(this.worstInterruptedFrameMs, sample.frameDeltaMs);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
if (sample.frameDeltaMs > this.worstFrameMs) {
|
|
212
|
+
this.worstFrameMs = sample.frameDeltaMs;
|
|
213
|
+
this.worstFrameSample = sample;
|
|
214
|
+
}
|
|
215
|
+
this.worstRenderWorkMs = Math.max(this.worstRenderWorkMs, sample.previousRenderWorkMs);
|
|
216
|
+
this.worstUpdateWorkMs = Math.max(this.worstUpdateWorkMs, sample.updateWorkMs);
|
|
217
|
+
this.totalFrameMs += sample.frameDeltaMs;
|
|
218
|
+
this.totalRenderWorkMs += sample.previousRenderWorkMs;
|
|
219
|
+
this.totalUpdateWorkMs += sample.updateWorkMs;
|
|
220
|
+
if (sample.frameDeltaMs < this.hitchThresholdMs
|
|
221
|
+
&& sample.previousRenderWorkMs < this.workThresholdMs
|
|
222
|
+
&& sample.updateWorkMs < this.workThresholdMs) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
this.hitchCount += 1;
|
|
226
|
+
diagnosis ??= this.diagnose(sample);
|
|
227
|
+
if (diagnosis.cause === 'memory-reclamation') {
|
|
228
|
+
this.memoryReclamationHitchCount += 1;
|
|
229
|
+
}
|
|
230
|
+
this.hitches.push(sample);
|
|
231
|
+
this.trimHistoryByAtMs(this.hitches);
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* Record one long animation frame observation. Hidden samples are validated
|
|
235
|
+
* and then ignored. Out-of-order samples are accepted: counts, worsts, and
|
|
236
|
+
* histories account for them, `last*` keeps the newest sample by `atMs`, and
|
|
237
|
+
* eviction drops the oldest by `atMs` so a late buffered sample cannot evict
|
|
238
|
+
* newer evidence. Because diagnoses are recomputed at `snapshot()` time from
|
|
239
|
+
* the observations still retained, a sample arriving after a retained hitch
|
|
240
|
+
* can retroactively change that hitch's cause label until either sample is
|
|
241
|
+
* evicted. Estimated scheduler interruptions, however, are decided at record
|
|
242
|
+
* time and are not re-examined later.
|
|
243
|
+
*/
|
|
244
|
+
recordLongAnimationFrame(sample) {
|
|
245
|
+
validateLongAnimationFrameSample(sample);
|
|
246
|
+
if (sample.hidden) {
|
|
247
|
+
return;
|
|
248
|
+
}
|
|
249
|
+
if (this.lastLongAnimationFrame === undefined
|
|
250
|
+
|| sample.atMs >= this.lastLongAnimationFrame.atMs) {
|
|
251
|
+
this.lastLongAnimationFrame = sample;
|
|
252
|
+
}
|
|
253
|
+
this.longAnimationFrameCount += 1;
|
|
254
|
+
this.worstLongAnimationFrameMs = Math.max(this.worstLongAnimationFrameMs, sample.durationMs);
|
|
255
|
+
this.longAnimationFrames.push(sample);
|
|
256
|
+
this.trimHistoryByAtMs(this.longAnimationFrames);
|
|
257
|
+
}
|
|
258
|
+
/** Record one long task observation. Hidden samples are validated and then ignored. */
|
|
259
|
+
recordLongTask(sample) {
|
|
260
|
+
validateLongTaskSample(sample);
|
|
261
|
+
if (sample.hidden) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
if (this.lastLongTask === undefined || sample.atMs >= this.lastLongTask.atMs) {
|
|
265
|
+
this.lastLongTask = sample;
|
|
266
|
+
}
|
|
267
|
+
this.longTaskCount += 1;
|
|
268
|
+
this.worstLongTaskMs = Math.max(this.worstLongTaskMs, sample.durationMs);
|
|
269
|
+
this.longTasks.push(sample);
|
|
270
|
+
this.trimHistoryByAtMs(this.longTasks);
|
|
271
|
+
}
|
|
272
|
+
/** Record one resource load observation. Hidden samples are validated and then ignored. */
|
|
273
|
+
recordResourceLoad(sample) {
|
|
274
|
+
validateResourceLoadSample(sample);
|
|
275
|
+
if (sample.hidden) {
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
if (this.lastResourceLoad === undefined || sample.atMs >= this.lastResourceLoad.atMs) {
|
|
279
|
+
this.lastResourceLoad = sample;
|
|
280
|
+
}
|
|
281
|
+
this.resourceLoadCount += 1;
|
|
282
|
+
this.worstResourceLoadMs = Math.max(this.worstResourceLoadMs, sample.durationMs);
|
|
283
|
+
this.resourceLoads.push(sample);
|
|
284
|
+
this.trimHistoryByAtMs(this.resourceLoads);
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Estimate the cause of one frame's gap.
|
|
288
|
+
*
|
|
289
|
+
* The verdict is ordered heuristic evidence, not a confirmed root cause:
|
|
290
|
+
*
|
|
291
|
+
* - `game-update` / `phaser-render`: the frame's own update work, or the
|
|
292
|
+
* previous frame's render work, is at least `workThresholdMs` and at
|
|
293
|
+
* least `explanatoryWorkRatio` of the gap. (`phaser-render` names the
|
|
294
|
+
* render callback of the consuming engine; the classification itself is
|
|
295
|
+
* engine-independent.)
|
|
296
|
+
* - `memory-reclamation`: a 100–1000 ms gap with a heap drop at or below
|
|
297
|
+
* `HEAP_RECLAMATION_THRESHOLD_BYTES`. A heap drop alone does not prove a
|
|
298
|
+
* GC pause caused the hitch, and multi-second gaps are excluded because
|
|
299
|
+
* collectors also run while the renderer is suspended.
|
|
300
|
+
* - `main-thread-long-task` / `browser-rendering`: a retained observation
|
|
301
|
+
* overlapping the frame window. Overlap does not prove the observation
|
|
302
|
+
* blocked this frame's main thread.
|
|
303
|
+
* - `scheduler-or-compositor`: the residual estimate when nothing else
|
|
304
|
+
* explains the gap, including long LoAF entries whose reported blocking,
|
|
305
|
+
* script, and style/layout work are a negligible share of the gap
|
|
306
|
+
* (renderer suspension). The absence of observations never proves the
|
|
307
|
+
* game performed well.
|
|
308
|
+
*/
|
|
309
|
+
diagnose(sample) {
|
|
310
|
+
validateFrameHitchSample(sample);
|
|
311
|
+
const base = {
|
|
312
|
+
atMs: sample.atMs,
|
|
313
|
+
frameDeltaMs: sample.frameDeltaMs,
|
|
314
|
+
};
|
|
315
|
+
const explanatoryWorkMs = sample.frameDeltaMs * FrameHitchRecorder.explanatoryWorkRatio;
|
|
316
|
+
if (sample.updateWorkMs >= this.workThresholdMs
|
|
317
|
+
&& sample.updateWorkMs >= explanatoryWorkMs) {
|
|
318
|
+
return { ...base, cause: 'game-update' };
|
|
319
|
+
}
|
|
320
|
+
if (sample.previousRenderWorkMs >= this.workThresholdMs
|
|
321
|
+
&& sample.previousRenderWorkMs >= explanatoryWorkMs) {
|
|
322
|
+
return { ...base, cause: 'phaser-render' };
|
|
323
|
+
}
|
|
324
|
+
if (this.isMemoryReclamationHitch(sample)) {
|
|
325
|
+
return { ...base, cause: 'memory-reclamation' };
|
|
326
|
+
}
|
|
327
|
+
const relatedLongTask = this.findRelated(sample, this.longTasks);
|
|
328
|
+
if (relatedLongTask !== undefined
|
|
329
|
+
&& relatedLongTask.durationMs >= explanatoryWorkMs) {
|
|
330
|
+
return {
|
|
331
|
+
...base,
|
|
332
|
+
cause: 'main-thread-long-task',
|
|
333
|
+
relatedLongTaskMs: relatedLongTask.durationMs,
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
const relatedLongAnimationFrame = this.findRelated(sample, this.longAnimationFrames);
|
|
337
|
+
if (relatedLongAnimationFrame !== undefined) {
|
|
338
|
+
const browserWorkMs = Math.max(relatedLongAnimationFrame.blockingDurationMs, relatedLongAnimationFrame.scriptDurationMs, relatedLongAnimationFrame.styleAndLayoutDurationMs);
|
|
339
|
+
// A renderer can emit a LoAF whose duration spans an occluded or
|
|
340
|
+
// suspended interval while reporting essentially no blocking, script,
|
|
341
|
+
// style, or layout work. That is a browser scheduling interruption, not
|
|
342
|
+
// evidence that the game spent the whole gap rendering.
|
|
343
|
+
if (sample.frameDeltaMs >= SCHEDULER_INTERRUPTION_THRESHOLD_MS
|
|
344
|
+
&& browserWorkMs
|
|
345
|
+
< sample.frameDeltaMs * FrameHitchRecorder.browserWorkShareLimit) {
|
|
346
|
+
return {
|
|
347
|
+
...base,
|
|
348
|
+
cause: 'scheduler-or-compositor',
|
|
349
|
+
relatedLongAnimationFrameMs: relatedLongAnimationFrame.durationMs,
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
const scriptDominated = relatedLongAnimationFrame.scriptDurationMs
|
|
353
|
+
>= FrameHitchRecorder.scriptDominanceFloorMs
|
|
354
|
+
&& relatedLongAnimationFrame.scriptDurationMs
|
|
355
|
+
>= relatedLongAnimationFrame.renderDurationMs;
|
|
356
|
+
return {
|
|
357
|
+
...base,
|
|
358
|
+
cause: scriptDominated ? 'main-thread-long-task' : 'browser-rendering',
|
|
359
|
+
relatedLongAnimationFrameMs: relatedLongAnimationFrame.durationMs,
|
|
360
|
+
};
|
|
361
|
+
}
|
|
362
|
+
const relatedResourceLoad = this.findRelated(sample, this.resourceLoads);
|
|
363
|
+
if (relatedResourceLoad !== undefined
|
|
364
|
+
&& relatedResourceLoad.durationMs >= explanatoryWorkMs) {
|
|
365
|
+
return {
|
|
366
|
+
...base,
|
|
367
|
+
cause: 'resource-load',
|
|
368
|
+
relatedResourceLoadMs: relatedResourceLoad.durationMs,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
return { ...base, cause: 'scheduler-or-compositor' };
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Clear every aggregate and retained sample for the next recording window.
|
|
375
|
+
* `resetCount` is the only field that survives: it increments monotonically
|
|
376
|
+
* so diagnostics can observe an accepted reset without timing guesses. Old
|
|
377
|
+
* observations cannot leak into the new window because all observation
|
|
378
|
+
* histories are cleared too.
|
|
379
|
+
*/
|
|
380
|
+
reset() {
|
|
381
|
+
this.resetCount += 1;
|
|
382
|
+
this.frameCount = 0;
|
|
383
|
+
this.hitchCount = 0;
|
|
384
|
+
this.hitches.length = 0;
|
|
385
|
+
this.interruptionCount = 0;
|
|
386
|
+
this.lastFrame = undefined;
|
|
387
|
+
this.lastLongAnimationFrame = undefined;
|
|
388
|
+
this.lastLongTask = undefined;
|
|
389
|
+
this.lastResourceLoad = undefined;
|
|
390
|
+
this.longAnimationFrameCount = 0;
|
|
391
|
+
this.longAnimationFrames.length = 0;
|
|
392
|
+
this.longTasks.length = 0;
|
|
393
|
+
this.longTaskCount = 0;
|
|
394
|
+
this.memoryReclamationHitchCount = 0;
|
|
395
|
+
this.resourceLoadCount = 0;
|
|
396
|
+
this.resourceLoads.length = 0;
|
|
397
|
+
this.schedulerInterruptionCount = 0;
|
|
398
|
+
this.totalFrameMs = 0;
|
|
399
|
+
this.totalInterruptedFrameMs = 0;
|
|
400
|
+
this.totalRenderWorkMs = 0;
|
|
401
|
+
this.totalUpdateWorkMs = 0;
|
|
402
|
+
this.worstFrameMs = 0;
|
|
403
|
+
this.worstFrameSample = undefined;
|
|
404
|
+
this.worstInterruptedFrameMs = 0;
|
|
405
|
+
this.worstLongAnimationFrameMs = 0;
|
|
406
|
+
this.worstLongTaskMs = 0;
|
|
407
|
+
this.worstResourceLoadMs = 0;
|
|
408
|
+
this.worstRenderWorkMs = 0;
|
|
409
|
+
this.worstUpdateWorkMs = 0;
|
|
410
|
+
}
|
|
411
|
+
/** Monotonic reset marker; equals `snapshot().resetCount`. */
|
|
412
|
+
getResetCount() {
|
|
413
|
+
return this.resetCount;
|
|
414
|
+
}
|
|
415
|
+
snapshot() {
|
|
416
|
+
const foregroundFrameCount = this.frameCount - this.interruptionCount;
|
|
417
|
+
const averageOverForegroundFrames = (totalMs) => foregroundFrameCount === 0 ? 0 : totalMs / foregroundFrameCount;
|
|
418
|
+
const hitchDiagnoses = this.hitches.map((sample) => this.diagnose(sample));
|
|
419
|
+
const hitchCauseCounts = createEmptyHitchCauseCounts();
|
|
420
|
+
for (const diagnosis of hitchDiagnoses) {
|
|
421
|
+
hitchCauseCounts[diagnosis.cause] += 1;
|
|
422
|
+
}
|
|
423
|
+
const lastHitchDiagnosis = hitchDiagnoses.at(-1);
|
|
424
|
+
const worstHitchDiagnosis = this.worstFrameSample === undefined
|
|
425
|
+
|| this.worstFrameMs < this.hitchThresholdMs
|
|
426
|
+
? undefined
|
|
427
|
+
: this.diagnose(this.worstFrameSample);
|
|
428
|
+
return {
|
|
429
|
+
averageFrameMs: averageOverForegroundFrames(this.totalFrameMs),
|
|
430
|
+
averageRenderWorkMs: averageOverForegroundFrames(this.totalRenderWorkMs),
|
|
431
|
+
averageUpdateWorkMs: averageOverForegroundFrames(this.totalUpdateWorkMs),
|
|
432
|
+
foregroundFrameCount,
|
|
433
|
+
frameCount: this.frameCount,
|
|
434
|
+
hitchCount: this.hitchCount,
|
|
435
|
+
hitches: this.hitches.map((sample) => ({ ...sample })),
|
|
436
|
+
hitchCauseCounts,
|
|
437
|
+
interruptedFrameMs: this.totalInterruptedFrameMs,
|
|
438
|
+
interruptionCount: this.interruptionCount,
|
|
439
|
+
...(this.lastFrame === undefined ? {} : { lastFrame: { ...this.lastFrame } }),
|
|
440
|
+
...(lastHitchDiagnosis === undefined ? {} : { lastHitchDiagnosis }),
|
|
441
|
+
...(this.lastLongAnimationFrame === undefined
|
|
442
|
+
? {}
|
|
443
|
+
: { lastLongAnimationFrame: cloneLongAnimationFrameSample(this.lastLongAnimationFrame) }),
|
|
444
|
+
...(this.lastLongTask === undefined
|
|
445
|
+
? {}
|
|
446
|
+
: { lastLongTask: { ...this.lastLongTask } }),
|
|
447
|
+
...(this.lastResourceLoad === undefined
|
|
448
|
+
? {}
|
|
449
|
+
: { lastResourceLoad: { ...this.lastResourceLoad } }),
|
|
450
|
+
longAnimationFrameCount: this.longAnimationFrameCount,
|
|
451
|
+
longTaskCount: this.longTaskCount,
|
|
452
|
+
memoryReclamationHitchCount: this.memoryReclamationHitchCount,
|
|
453
|
+
resetCount: this.resetCount,
|
|
454
|
+
resourceLoadCount: this.resourceLoadCount,
|
|
455
|
+
schedulerInterruptionCount: this.schedulerInterruptionCount,
|
|
456
|
+
worstFrameMs: this.worstFrameMs,
|
|
457
|
+
...(worstHitchDiagnosis === undefined ? {} : { worstHitchDiagnosis }),
|
|
458
|
+
worstInterruptedFrameMs: this.worstInterruptedFrameMs,
|
|
459
|
+
worstLongAnimationFrameMs: this.worstLongAnimationFrameMs,
|
|
460
|
+
worstLongTaskMs: this.worstLongTaskMs,
|
|
461
|
+
worstResourceLoadMs: this.worstResourceLoadMs,
|
|
462
|
+
worstRenderWorkMs: this.worstRenderWorkMs,
|
|
463
|
+
worstUpdateWorkMs: this.worstUpdateWorkMs,
|
|
464
|
+
};
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Find the most explanatory observation overlapping a frame's gap window.
|
|
468
|
+
*
|
|
469
|
+
* The window is `[updateStart - frameDeltaMs, updateStart]`, where
|
|
470
|
+
* `updateStart = atMs - updateWorkMs` is where this frame's update callback
|
|
471
|
+
* began. Work inside the update callback itself is deliberately excluded:
|
|
472
|
+
* it is already measured by `updateWorkMs` and diagnosed as `game-update`,
|
|
473
|
+
* and work after `atMs` belongs to the next frame. Anchoring the gap at the
|
|
474
|
+
* update start rather than at `atMs` keeps the previous frame's render and
|
|
475
|
+
* any pre-update blocking associated with this gap without absorbing
|
|
476
|
+
* neighboring frames' work.
|
|
477
|
+
*/
|
|
478
|
+
findRelated(frame, samples) {
|
|
479
|
+
const updateStartedAtMs = frame.atMs - frame.updateWorkMs;
|
|
480
|
+
const frameWindowStartedAtMs = updateStartedAtMs - frame.frameDeltaMs;
|
|
481
|
+
const overlapToleranceMs = FrameHitchRecorder.relatedSampleOverlapToleranceMs;
|
|
482
|
+
let mostExplanatory;
|
|
483
|
+
for (const sample of samples) {
|
|
484
|
+
const overlaps = sample.atMs >= frameWindowStartedAtMs - overlapToleranceMs
|
|
485
|
+
&& sample.startAtMs <= updateStartedAtMs + overlapToleranceMs;
|
|
486
|
+
if (overlaps
|
|
487
|
+
&& (mostExplanatory === undefined || sample.durationMs >= mostExplanatory.durationMs)) {
|
|
488
|
+
mostExplanatory = sample;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
return mostExplanatory;
|
|
492
|
+
}
|
|
493
|
+
isMemoryReclamationHitch(sample) {
|
|
494
|
+
// A multi-second scheduler or occlusion gap can trigger collection while
|
|
495
|
+
// suspended; trust heap correlation only for sub-second gaps so that
|
|
496
|
+
// incidental collection stays an interruption.
|
|
497
|
+
return sample.frameDeltaMs >= HEAP_RECLAMATION_FRAME_THRESHOLD_MS
|
|
498
|
+
&& sample.frameDeltaMs < SCHEDULER_INTERRUPTION_THRESHOLD_MS
|
|
499
|
+
&& (sample.heapDeltaBytes ?? 0) <= HEAP_RECLAMATION_THRESHOLD_BYTES;
|
|
500
|
+
}
|
|
501
|
+
/**
|
|
502
|
+
* Keep a retained sample history ordered by `atMs` and bounded, so eviction
|
|
503
|
+
* always drops the oldest sample by event time rather than by arrival
|
|
504
|
+
* order. Mostly-sorted pushes make the sort near-linear; the sort runs only
|
|
505
|
+
* when a sample is actually recorded, never per frame.
|
|
506
|
+
*/
|
|
507
|
+
trimHistoryByAtMs(samples) {
|
|
508
|
+
const previous = samples.at(-2);
|
|
509
|
+
if (previous !== undefined && previous.atMs > samples.at(-1).atMs) {
|
|
510
|
+
samples.sort((left, right) => left.atMs - right.atMs);
|
|
511
|
+
}
|
|
512
|
+
if (samples.length > this.historyLimit) {
|
|
513
|
+
samples.splice(0, samples.length - this.historyLimit);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function createEmptyHitchCauseCounts() {
|
|
518
|
+
return Object.fromEntries(FRAME_HITCH_CAUSES.map((cause) => [cause, 0]));
|
|
519
|
+
}
|
|
520
|
+
function cloneLongAnimationFrameSample(sample) {
|
|
521
|
+
if (sample.scripts === undefined) {
|
|
522
|
+
return { ...sample };
|
|
523
|
+
}
|
|
524
|
+
return { ...sample, scripts: [...sample.scripts] };
|
|
525
|
+
}
|
|
526
|
+
/** Read the used JS heap size from a supplied source; `undefined` when unavailable or non-finite. */
|
|
527
|
+
export function readUsedHeapBytes(source) {
|
|
528
|
+
const usedHeap = source?.memory?.usedJSHeapSize;
|
|
529
|
+
return usedHeap === undefined || !Number.isFinite(usedHeap) ? undefined : usedHeap;
|
|
530
|
+
}
|
|
531
|
+
function validateFrameHitchSample(sample) {
|
|
532
|
+
assertFiniteNonNegativeNumber(sample.atMs, 'FrameHitchSample.atMs');
|
|
533
|
+
assertFiniteNonNegativeNumber(sample.frameDeltaMs, 'FrameHitchSample.frameDeltaMs');
|
|
534
|
+
assertFiniteNonNegativeNumber(sample.previousRenderWorkMs, 'FrameHitchSample.previousRenderWorkMs');
|
|
535
|
+
assertFiniteNonNegativeNumber(sample.updateWorkMs, 'FrameHitchSample.updateWorkMs');
|
|
536
|
+
assertBoolean(sample.hidden, 'FrameHitchSample.hidden');
|
|
537
|
+
assertBoolean(sample.visibilityInterrupted, 'FrameHitchSample.visibilityInterrupted');
|
|
538
|
+
if (sample.heapBytes !== undefined) {
|
|
539
|
+
assertFiniteNonNegativeNumber(sample.heapBytes, 'FrameHitchSample.heapBytes');
|
|
540
|
+
}
|
|
541
|
+
if (sample.heapDeltaBytes !== undefined) {
|
|
542
|
+
// Heap deltas are signed by design: a large negative delta is a normal
|
|
543
|
+
// collection signal, not an invalid duration.
|
|
544
|
+
assertFiniteNumber(sample.heapDeltaBytes, 'FrameHitchSample.heapDeltaBytes');
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
function validateLongTaskSample(sample) {
|
|
548
|
+
validateObservationTiming(sample, 'LongTaskSample');
|
|
549
|
+
assertBoolean(sample.hidden, 'LongTaskSample.hidden');
|
|
550
|
+
assertString(sample.name, 'LongTaskSample.name');
|
|
551
|
+
}
|
|
552
|
+
function validateLongAnimationFrameSample(sample) {
|
|
553
|
+
validateObservationTiming(sample, 'LongAnimationFrameSample');
|
|
554
|
+
assertBoolean(sample.hidden, 'LongAnimationFrameSample.hidden');
|
|
555
|
+
assertFiniteNonNegativeNumber(sample.blockingDurationMs, 'LongAnimationFrameSample.blockingDurationMs');
|
|
556
|
+
assertFiniteNonNegativeNumber(sample.renderDurationMs, 'LongAnimationFrameSample.renderDurationMs');
|
|
557
|
+
assertFiniteNonNegativeNumber(sample.scriptDurationMs, 'LongAnimationFrameSample.scriptDurationMs');
|
|
558
|
+
assertFiniteNonNegativeNumber(sample.styleAndLayoutDurationMs, 'LongAnimationFrameSample.styleAndLayoutDurationMs');
|
|
559
|
+
if (!Number.isInteger(sample.scriptInvocations) || sample.scriptInvocations < 0) {
|
|
560
|
+
throw new Error(`LongAnimationFrameSample.scriptInvocations must be a non-negative integer (received ${describeValue(sample.scriptInvocations)}).`);
|
|
561
|
+
}
|
|
562
|
+
if (sample.scripts !== undefined) {
|
|
563
|
+
if (!Array.isArray(sample.scripts)) {
|
|
564
|
+
throw new Error(`LongAnimationFrameSample.scripts must be an array (received ${describeValue(sample.scripts)}).`);
|
|
565
|
+
}
|
|
566
|
+
if (sample.scripts.length > MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES) {
|
|
567
|
+
throw new Error(`LongAnimationFrameSample.scripts length ${sample.scripts.length} exceeds ${MAX_LONG_ANIMATION_FRAME_SCRIPT_SAMPLES}.`);
|
|
568
|
+
}
|
|
569
|
+
for (const script of sample.scripts) {
|
|
570
|
+
assertScriptSampleShape(script);
|
|
571
|
+
validateLongAnimationFrameScriptSample(script);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
/** Reject non-object entries before field validation dereferences them. */
|
|
576
|
+
function assertScriptTimingShape(script) {
|
|
577
|
+
if (typeof script !== 'object' || script === null) {
|
|
578
|
+
throw new Error(`LongAnimationFrameScriptTiming entry must be an object (received ${describeValue(script)}).`);
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
/** Reject non-object entries before field validation dereferences them. */
|
|
582
|
+
function assertScriptSampleShape(script) {
|
|
583
|
+
if (typeof script !== 'object' || script === null) {
|
|
584
|
+
throw new Error(`LongAnimationFrameScriptSample entry must be an object (received ${describeValue(script)}).`);
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
function validateLongAnimationFrameScriptSample(script) {
|
|
588
|
+
assertFiniteNonNegativeNumber(script.durationMs, 'LongAnimationFrameScriptSample.durationMs');
|
|
589
|
+
assertFiniteNonNegativeNumber(script.forcedStyleAndLayoutDurationMs, 'LongAnimationFrameScriptSample.forcedStyleAndLayoutDurationMs');
|
|
590
|
+
assertFiniteNonNegativeNumber(script.pauseDurationMs, 'LongAnimationFrameScriptSample.pauseDurationMs');
|
|
591
|
+
assertString(script.invoker, 'LongAnimationFrameScriptSample.invoker');
|
|
592
|
+
assertString(script.invokerType, 'LongAnimationFrameScriptSample.invokerType');
|
|
593
|
+
assertString(script.sourceFunctionName, 'LongAnimationFrameScriptSample.sourceFunctionName');
|
|
594
|
+
assertString(script.sourceUrl, 'LongAnimationFrameScriptSample.sourceUrl');
|
|
595
|
+
}
|
|
596
|
+
function validateResourceLoadSample(sample) {
|
|
597
|
+
validateObservationTiming(sample, 'ResourceLoadSample');
|
|
598
|
+
assertBoolean(sample.hidden, 'ResourceLoadSample.hidden');
|
|
599
|
+
assertString(sample.name, 'ResourceLoadSample.name');
|
|
600
|
+
}
|
|
601
|
+
function validateObservationTiming(sample, label) {
|
|
602
|
+
assertFiniteNonNegativeNumber(sample.startAtMs, `${label}.startAtMs`);
|
|
603
|
+
assertFiniteNonNegativeNumber(sample.atMs, `${label}.atMs`);
|
|
604
|
+
assertFiniteNonNegativeNumber(sample.durationMs, `${label}.durationMs`);
|
|
605
|
+
if (sample.atMs < sample.startAtMs) {
|
|
606
|
+
throw new Error(`${label}.atMs must not precede ${label}.startAtMs.`);
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
function assertFiniteNonNegativeNumber(value, field) {
|
|
610
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
611
|
+
throw new Error(`${field} must be a finite non-negative number (received ${describeValue(value)}).`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
function assertFiniteNumber(value, field) {
|
|
615
|
+
if (!Number.isFinite(value)) {
|
|
616
|
+
throw new Error(`${field} must be a finite number (received ${describeValue(value)}).`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function assertBoolean(value, field) {
|
|
620
|
+
if (typeof value !== 'boolean') {
|
|
621
|
+
throw new Error(`${field} must be a boolean (received ${describeValue(value)}).`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
function assertString(value, field) {
|
|
625
|
+
if (typeof value !== 'string') {
|
|
626
|
+
throw new Error(`${field} must be a string (received ${describeValue(value)}).`);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
function describeValue(value) {
|
|
630
|
+
return typeof value === 'string' ? JSON.stringify(value) : String(value);
|
|
631
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@mpgd/runtime-diagnostics",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Engine-independent frame hitch recording, cause estimation, and bounded performance snapshots for headless game diagnostics.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/imjlk/mpgd-kit.git",
|
|
9
|
+
"directory": "packages/runtime-diagnostics"
|
|
10
|
+
},
|
|
11
|
+
"bugs": {
|
|
12
|
+
"url": "https://github.com/imjlk/mpgd-kit/issues"
|
|
13
|
+
},
|
|
14
|
+
"homepage": "https://github.com/imjlk/mpgd-kit#readme",
|
|
15
|
+
"keywords": [
|
|
16
|
+
"mpgd",
|
|
17
|
+
"phaser",
|
|
18
|
+
"game-development",
|
|
19
|
+
"performance",
|
|
20
|
+
"diagnostics"
|
|
21
|
+
],
|
|
22
|
+
"type": "module",
|
|
23
|
+
"sideEffects": false,
|
|
24
|
+
"main": "./dist/index.js",
|
|
25
|
+
"types": "./dist/index.d.ts",
|
|
26
|
+
"exports": {
|
|
27
|
+
".": {
|
|
28
|
+
"types": "./dist/index.d.ts",
|
|
29
|
+
"default": "./dist/index.js"
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
"files": [
|
|
33
|
+
"dist"
|
|
34
|
+
],
|
|
35
|
+
"publishConfig": {
|
|
36
|
+
"access": "public"
|
|
37
|
+
},
|
|
38
|
+
"scripts": {
|
|
39
|
+
"check": "ttsc --noEmit",
|
|
40
|
+
"test": "cd ../.. && node tools/run-ttsx.mjs packages/runtime-diagnostics/src/index.test.ts",
|
|
41
|
+
"lint": "ttsc --noEmit",
|
|
42
|
+
"format": "ttsc format",
|
|
43
|
+
"fix": "ttsc fix"
|
|
44
|
+
},
|
|
45
|
+
"devDependencies": {
|
|
46
|
+
"ttsc": "0.18.4",
|
|
47
|
+
"typescript": "7.0.2"
|
|
48
|
+
}
|
|
49
|
+
}
|