@keepsnappy/react-native 0.0.0-stage → 0.1.0-alpha.11
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 +260 -2
- package/android/build.gradle +35 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabModule.kt +445 -0
- package/android/src/main/java/expo/modules/perflab/PerfLabSampler.kt +515 -0
- package/app.plugin.js +105 -0
- package/expo-module.config.json +9 -0
- package/ios/PerfLab.podspec +29 -0
- package/ios/PerfLabHermes.h +18 -0
- package/ios/PerfLabHermes.mm +64 -0
- package/ios/PerfLabModule.swift +371 -0
- package/ios/PerfLabSampler.swift +364 -0
- package/js/babel-transformer.js +71 -0
- package/js/hook.polyfill.js +333 -0
- package/js/renderer-switch.js +16 -0
- package/js/sdk/activation.ts +375 -0
- package/js/sdk/active.ts +62 -0
- package/js/sdk/artifacts.ts +632 -0
- package/js/sdk/boot.ts +21 -0
- package/js/sdk/causes.ts +932 -0
- package/js/sdk/core.ts +2403 -0
- package/js/sdk/cpuprof.ts +133 -0
- package/js/sdk/dormant.ts +77 -0
- package/js/sdk/env.d.ts +2 -0
- package/js/sdk/fiber.ts +591 -0
- package/js/sdk/interactions.ts +531 -0
- package/js/sdk/links.ts +201 -0
- package/js/sdk/nav.ts +383 -0
- package/js/sdk/net-install.ts +49 -0
- package/js/sdk/net.ts +684 -0
- package/js/sdk/profile-export.ts +257 -0
- package/js/sdk/profile.ts +539 -0
- package/js/sdk/push-install.ts +158 -0
- package/js/sdk/push.ts +819 -0
- package/js/sdk/retain-floor.ts +11 -0
- package/js/sdk/sampler.ts +236 -0
- package/js/sdk/screens.ts +586 -0
- package/js/sdk/stream.ts +125 -0
- package/js/zustand-vanilla.js +31 -0
- package/metro.js +238 -0
- package/package.json +26 -4
package/js/sdk/core.ts
ADDED
|
@@ -0,0 +1,2403 @@
|
|
|
1
|
+
// perf-lab core: settings, windows, the commit walk, observers, faults, logs.
|
|
2
|
+
// Loaded by boot.ts, which Metro runs before the app's entry module.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type ActivationStore,
|
|
6
|
+
type Decision,
|
|
7
|
+
MODE,
|
|
8
|
+
PERF_LAB_DEEP_LINK_PATH,
|
|
9
|
+
PERF_LAB_ENABLED,
|
|
10
|
+
parseQuery,
|
|
11
|
+
type PolyfillActivation,
|
|
12
|
+
} from './activation';
|
|
13
|
+
import {
|
|
14
|
+
componentName,
|
|
15
|
+
type Fiber,
|
|
16
|
+
type FiberRoot,
|
|
17
|
+
isComponentTag,
|
|
18
|
+
nameStats,
|
|
19
|
+
noteMounted,
|
|
20
|
+
noteUnmounted,
|
|
21
|
+
registrySizes,
|
|
22
|
+
} from './fiber';
|
|
23
|
+
import {
|
|
24
|
+
type ArtifactEntry,
|
|
25
|
+
type ArtifactReason,
|
|
26
|
+
ARTIFACT_UPLOAD_DEADLINE_MS,
|
|
27
|
+
type ArtifactUploadSummary,
|
|
28
|
+
buildManifest,
|
|
29
|
+
LONG_TASK_MS,
|
|
30
|
+
manifestName,
|
|
31
|
+
selectArtifacts,
|
|
32
|
+
summarizeArtifacts,
|
|
33
|
+
type WrittenFile,
|
|
34
|
+
} from './artifacts';
|
|
35
|
+
import {
|
|
36
|
+
backgroundSource,
|
|
37
|
+
type Carrier,
|
|
38
|
+
commitCarrier,
|
|
39
|
+
causeStats,
|
|
40
|
+
type CommitCauses,
|
|
41
|
+
latestActionAt,
|
|
42
|
+
latestActionId,
|
|
43
|
+
noteRequestSent,
|
|
44
|
+
commitCauses,
|
|
45
|
+
describeCauses,
|
|
46
|
+
describeStore,
|
|
47
|
+
describeTask,
|
|
48
|
+
forgetHostFiber,
|
|
49
|
+
noteHostFiber,
|
|
50
|
+
addRootListener,
|
|
51
|
+
addWriteListener,
|
|
52
|
+
windowPart,
|
|
53
|
+
} from './causes';
|
|
54
|
+
import { createCpuProfiler, type CpuProfileNative } from './cpuprof';
|
|
55
|
+
import { createNetCounter, type NetWindow } from './net';
|
|
56
|
+
import { createStream } from './stream';
|
|
57
|
+
import {
|
|
58
|
+
addCommitProfile,
|
|
59
|
+
brokeFrameBudget,
|
|
60
|
+
type CommitProfile,
|
|
61
|
+
createCommitProfile,
|
|
62
|
+
createWindowProfile,
|
|
63
|
+
type FrameBudget,
|
|
64
|
+
isProfilingFiber,
|
|
65
|
+
type ProfiledFiber,
|
|
66
|
+
type ProfiledRoot,
|
|
67
|
+
recordFiber,
|
|
68
|
+
selfMs,
|
|
69
|
+
summarizeWindowProfile,
|
|
70
|
+
totalMs,
|
|
71
|
+
updateCause,
|
|
72
|
+
type WindowProfile,
|
|
73
|
+
} from './profile';
|
|
74
|
+
import {
|
|
75
|
+
buildReactExport,
|
|
76
|
+
createAncestorPaths,
|
|
77
|
+
type ExportCommit,
|
|
78
|
+
exportFiber,
|
|
79
|
+
fiberId,
|
|
80
|
+
} from './profile-export';
|
|
81
|
+
import {
|
|
82
|
+
applySamplerParams,
|
|
83
|
+
estimateOffset,
|
|
84
|
+
frameBudgetFromRecord,
|
|
85
|
+
OPEN_SAMPLE_TOLERANCE_MS,
|
|
86
|
+
type SamplerRecord,
|
|
87
|
+
type SamplerSettings,
|
|
88
|
+
samplerDefaults,
|
|
89
|
+
type WindowSampler,
|
|
90
|
+
windowAggregate,
|
|
91
|
+
} from './sampler';
|
|
92
|
+
|
|
93
|
+
export { PERF_LAB_DEEP_LINK_PATH, PERF_LAB_ENABLED, parseQuery };
|
|
94
|
+
|
|
95
|
+
type LogSink = 'console' | 'native' | 'both';
|
|
96
|
+
|
|
97
|
+
interface Faults {
|
|
98
|
+
cpu: number;
|
|
99
|
+
rerender: number;
|
|
100
|
+
leak: number;
|
|
101
|
+
leakjs: number;
|
|
102
|
+
defer: number;
|
|
103
|
+
dupnet: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
interface Settings extends SamplerSettings {
|
|
107
|
+
marks: boolean;
|
|
108
|
+
hook: boolean;
|
|
109
|
+
po: boolean;
|
|
110
|
+
file: boolean;
|
|
111
|
+
clear: boolean;
|
|
112
|
+
gcwin: boolean;
|
|
113
|
+
net: boolean;
|
|
114
|
+
log: LogSink;
|
|
115
|
+
faults: Faults;
|
|
116
|
+
faultTarget: string;
|
|
117
|
+
// Per-component update causes (any renderer) and render times (profiling renderer).
|
|
118
|
+
profile: boolean;
|
|
119
|
+
// React DevTools v5 export file per window (for TraceSift).
|
|
120
|
+
rexport: boolean;
|
|
121
|
+
// Hermes sampling profiler around windows, one .cpuprofile per span.
|
|
122
|
+
cpuprof: boolean;
|
|
123
|
+
cpuhz: number;
|
|
124
|
+
// Diagnostic iteration: React exports on, and at flush every raw file of the
|
|
125
|
+
// run is kept and uploaded (otherwise only files of budget-breaking windows).
|
|
126
|
+
diag: boolean;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
interface PerfLabNative extends Partial<CpuProfileNative> {
|
|
130
|
+
beginAsyncSection(name: string, cookie: number): void;
|
|
131
|
+
endAsyncSection(name: string, cookie: number): void;
|
|
132
|
+
setCounter(name: string, value: number): void;
|
|
133
|
+
isTracing(): boolean;
|
|
134
|
+
log(message: string): void;
|
|
135
|
+
clockSnapshot(): Record<string, number>;
|
|
136
|
+
writeText(fileName: string, content: string): string | null;
|
|
137
|
+
samplerConfigure(mode: string, intervalMs: number): Record<string, unknown>;
|
|
138
|
+
samplerSample(reason: string): SamplerRecord | null;
|
|
139
|
+
// Schedules a sample on the sampler's own thread and returns at once; the record shows up in samplerRecent with this reason.
|
|
140
|
+
samplerRequest?(reason: string): void;
|
|
141
|
+
// Removes and returns the record a samplerRequest took (kept natively outside the ring).
|
|
142
|
+
samplerTake?(reason: string): SamplerRecord | null;
|
|
143
|
+
samplerRecent(sinceT: number): SamplerRecord[];
|
|
144
|
+
samplerNow(): number;
|
|
145
|
+
flushSignal?(status: string, detail: string): void;
|
|
146
|
+
// GC before the action: Android finishes the pending command broadcast with this result; iOS shows it on `perflab.ack`.
|
|
147
|
+
commandDone?(n: number, data: string): void;
|
|
148
|
+
/** Android: whether the broadcast of command n still waits for its answer (native finishes it at a deadline). */
|
|
149
|
+
commandPending?(n: number): boolean;
|
|
150
|
+
ackSignal?(text: string): void;
|
|
151
|
+
commandListen?(on: boolean): void;
|
|
152
|
+
addListener?(event: 'perflabCommand', listener: (e: { cmd: string; n: number; tNative: number }) => void): { remove(): void };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
interface RendererInternals {
|
|
156
|
+
version?: string;
|
|
157
|
+
rendererPackageName?: string;
|
|
158
|
+
bundleType?: number;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
interface PerfEntry {
|
|
162
|
+
entryType: string;
|
|
163
|
+
name: string;
|
|
164
|
+
startTime: number;
|
|
165
|
+
duration: number;
|
|
166
|
+
processingStart?: number;
|
|
167
|
+
processingEnd?: number;
|
|
168
|
+
interactionId?: number;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
interface PerfObserverList {
|
|
172
|
+
getEntries(): PerfEntry[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
interface PerfObserver {
|
|
176
|
+
observe(options: Record<string, unknown>): void;
|
|
177
|
+
disconnect(): void;
|
|
178
|
+
takeRecords?: () => PerfEntry[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
type PerfObserverCtor = new (
|
|
182
|
+
callback: (list: PerfObserverList) => void,
|
|
183
|
+
) => PerfObserver;
|
|
184
|
+
|
|
185
|
+
interface HermesInternalApi {
|
|
186
|
+
getRuntimeProperties?: () => Record<string, unknown>;
|
|
187
|
+
getInstrumentedStats?: () => Record<string, number>;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
interface PerformanceLike {
|
|
191
|
+
now(): number;
|
|
192
|
+
memory?: Record<string, number>;
|
|
193
|
+
mark?: (name: string) => void;
|
|
194
|
+
measure?: (
|
|
195
|
+
name: string,
|
|
196
|
+
start: string,
|
|
197
|
+
end: string,
|
|
198
|
+
) => { duration?: number } | undefined;
|
|
199
|
+
clearMarks?: (name?: string) => void;
|
|
200
|
+
clearMeasures?: (name?: string) => void;
|
|
201
|
+
getEntriesByType?: (type: string) => unknown[];
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface HookListener {
|
|
205
|
+
onInject?: (id: number, internals: RendererInternals) => void;
|
|
206
|
+
onCommit: (id: number, root: FiberRoot) => void;
|
|
207
|
+
onUnmount?: (id: number, fiber: Fiber) => void;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
interface PolyfillHook {
|
|
211
|
+
state: {
|
|
212
|
+
installedAt: number;
|
|
213
|
+
typeofHookAtPolyfill: string;
|
|
214
|
+
dev: boolean;
|
|
215
|
+
mode: string;
|
|
216
|
+
chainedTargets: number;
|
|
217
|
+
replacedAfterInstall: number;
|
|
218
|
+
injections: {
|
|
219
|
+
id: number;
|
|
220
|
+
pkg?: string;
|
|
221
|
+
version?: string;
|
|
222
|
+
bundleType?: number;
|
|
223
|
+
t: number;
|
|
224
|
+
}[];
|
|
225
|
+
commitsByRenderer: Record<string, number>;
|
|
226
|
+
unmountsByRenderer: Record<string, number>;
|
|
227
|
+
lastRootByRenderer: Record<string, FiberRoot>;
|
|
228
|
+
errors: number;
|
|
229
|
+
invalid: boolean;
|
|
230
|
+
};
|
|
231
|
+
ensure: () => string;
|
|
232
|
+
attach: (listener: HookListener | null) => void;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
interface PerfLabGlobal {
|
|
236
|
+
HermesInternal?: HermesInternalApi;
|
|
237
|
+
PerformanceObserver?: PerfObserverCtor & { supportedEntryTypes?: string[] };
|
|
238
|
+
performance?: PerformanceLike;
|
|
239
|
+
gc?: () => void;
|
|
240
|
+
console: { log: (...args: unknown[]) => void; timeStamp?: unknown };
|
|
241
|
+
expo?: { modules?: Record<string, unknown> };
|
|
242
|
+
__REACT_DEVTOOLS_GLOBAL_HOOK__?: Record<string, unknown>;
|
|
243
|
+
__PERFLAB_HOOK__?: PolyfillHook;
|
|
244
|
+
__PERFLAB_ACTIVATION__?: PolyfillActivation;
|
|
245
|
+
__PERF_LAB__?: unknown;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const g = globalThis as unknown as PerfLabGlobal;
|
|
249
|
+
|
|
250
|
+
const NO_FAULTS: Faults = {
|
|
251
|
+
cpu: 0,
|
|
252
|
+
rerender: 0,
|
|
253
|
+
leak: 0,
|
|
254
|
+
leakjs: 0,
|
|
255
|
+
defer: 0,
|
|
256
|
+
dupnet: 0,
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
// Measure-only builds run someone else's test (Maestro, Detox, Appium) that cannot send our
|
|
260
|
+
// deep link, so collection has to be on from the first frame. A dormant build loads this
|
|
261
|
+
// module only once activated, and then measures like an auto build.
|
|
262
|
+
const AUTO = MODE !== 'manual';
|
|
263
|
+
|
|
264
|
+
// Faults run on press windows whose name contains one of these (comma-separated).
|
|
265
|
+
const DEFAULT_FAULT_TARGET = 'community.nav.chat';
|
|
266
|
+
|
|
267
|
+
const defaultSettings = (): Settings => ({
|
|
268
|
+
marks: true,
|
|
269
|
+
hook: AUTO,
|
|
270
|
+
po: AUTO,
|
|
271
|
+
file: false,
|
|
272
|
+
clear: true,
|
|
273
|
+
gcwin: false,
|
|
274
|
+
net: AUTO,
|
|
275
|
+
log: AUTO ? 'both' : 'console',
|
|
276
|
+
faults: { ...NO_FAULTS },
|
|
277
|
+
faultTarget: DEFAULT_FAULT_TARGET,
|
|
278
|
+
profile: (process.env.EXPO_PUBLIC_KEEPSNAPPY_PROFILE ?? process.env.EXPO_PUBLIC_PERF_LAB_PROFILE) === '1',
|
|
279
|
+
rexport: (process.env.EXPO_PUBLIC_KEEPSNAPPY_REACT_EXPORT ?? process.env.EXPO_PUBLIC_PERF_LAB_REACT_EXPORT) === '1',
|
|
280
|
+
cpuprof: (process.env.EXPO_PUBLIC_KEEPSNAPPY_CPUPROF ?? process.env.EXPO_PUBLIC_PERF_LAB_CPUPROF) === '1',
|
|
281
|
+
cpuhz: Number((process.env.EXPO_PUBLIC_KEEPSNAPPY_CPUPROF_HZ ?? process.env.EXPO_PUBLIC_PERF_LAB_CPUPROF_HZ)) || 250,
|
|
282
|
+
diag: false,
|
|
283
|
+
...samplerDefaults({
|
|
284
|
+
auto: AUTO,
|
|
285
|
+
sampler: (process.env.EXPO_PUBLIC_KEEPSNAPPY_SAMPLER ?? process.env.EXPO_PUBLIC_PERF_LAB_SAMPLER),
|
|
286
|
+
samplerMs: (process.env.EXPO_PUBLIC_KEEPSNAPPY_SAMPLER_MS ?? process.env.EXPO_PUBLIC_PERF_LAB_SAMPLER_MS),
|
|
287
|
+
}),
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
export const settings: Settings = defaultSettings();
|
|
291
|
+
|
|
292
|
+
// A diagnostic iteration always records the React export (its raw files are the point).
|
|
293
|
+
const exportOn = () => settings.rexport || settings.diag;
|
|
294
|
+
|
|
295
|
+
export const now = () => (g.performance ? g.performance.now() : Date.now());
|
|
296
|
+
export const fmt = (ms: number) => ms.toFixed(3);
|
|
297
|
+
const noop = () => undefined;
|
|
298
|
+
|
|
299
|
+
let nativeModule: PerfLabNative | null | undefined;
|
|
300
|
+
let nativeMissingLogged = false;
|
|
301
|
+
|
|
302
|
+
const getNative = (): PerfLabNative | null => {
|
|
303
|
+
if (nativeModule) {
|
|
304
|
+
return nativeModule;
|
|
305
|
+
}
|
|
306
|
+
const found = g.expo?.modules?.PerfLab as PerfLabNative | undefined;
|
|
307
|
+
if (found) {
|
|
308
|
+
nativeModule = found;
|
|
309
|
+
return found;
|
|
310
|
+
}
|
|
311
|
+
if (!nativeMissingLogged) {
|
|
312
|
+
nativeMissingLogged = true;
|
|
313
|
+
g.console.log(
|
|
314
|
+
'PERF_LAB native module PerfLab missing (expo.modules.PerfLab)',
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
return null;
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Unique per launch, so files written by one process never overwrite another's.
|
|
321
|
+
export const PROCESS_ID = `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
322
|
+
|
|
323
|
+
// Every commit of the process with its cause facts, for the backend's split (stream.ts).
|
|
324
|
+
const commitStream = createStream(PROCESS_ID);
|
|
325
|
+
addRootListener(root => commitStream.noteRoot(root));
|
|
326
|
+
addWriteListener(write => commitStream.noteWrite(write));
|
|
327
|
+
|
|
328
|
+
// A process with a run token ends with `flush=1`: files are written there,
|
|
329
|
+
// never at window end, so no file I/O lands inside a measured window.
|
|
330
|
+
const inRun = () => runToken !== null;
|
|
331
|
+
|
|
332
|
+
const cpu = createCpuProfiler({
|
|
333
|
+
processId: PROCESS_ID,
|
|
334
|
+
deferDump: inRun,
|
|
335
|
+
native: () => {
|
|
336
|
+
const native = getNative();
|
|
337
|
+
return native?.cpuProfileStart &&
|
|
338
|
+
native.cpuProfileStop &&
|
|
339
|
+
native.cpuProfileDump
|
|
340
|
+
? (native as CpuProfileNative)
|
|
341
|
+
: null;
|
|
342
|
+
},
|
|
343
|
+
now: () => now(),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
export const perfLog = (message: string, sink: LogSink = settings.log) => {
|
|
347
|
+
if (!PERF_LAB_ENABLED) {
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const line = `PERF_LAB ${message}`;
|
|
351
|
+
if (sink === 'console' || sink === 'both') {
|
|
352
|
+
g.console.log(line);
|
|
353
|
+
}
|
|
354
|
+
if (sink === 'native' || sink === 'both') {
|
|
355
|
+
const native = getNative();
|
|
356
|
+
if (native) {
|
|
357
|
+
native.log(line);
|
|
358
|
+
} else if (sink === 'native') {
|
|
359
|
+
g.console.log(line);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
// Requests started per window (fetch/XHR hooks in net-install.ts feed it).
|
|
365
|
+
export const net = createNetCounter({ now, onStart: noteRequestSent });
|
|
366
|
+
|
|
367
|
+
const samplerState = {
|
|
368
|
+
mode: 'off' as string,
|
|
369
|
+
intervalMs: 0,
|
|
370
|
+
// performance.now() minus the native sampler clock, measured when the sampler is configured.
|
|
371
|
+
offsetMs: 0,
|
|
372
|
+
status: null as Record<string, unknown> | null,
|
|
373
|
+
error: null as string | null,
|
|
374
|
+
};
|
|
375
|
+
|
|
376
|
+
const syncSampler = () => {
|
|
377
|
+
const unchanged =
|
|
378
|
+
samplerState.mode === settings.sampler &&
|
|
379
|
+
(settings.sampler === 'off' ||
|
|
380
|
+
samplerState.intervalMs === settings.samplerMs);
|
|
381
|
+
if (unchanged) {
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
const native = getNative();
|
|
385
|
+
if (!native || typeof native.samplerConfigure !== 'function') {
|
|
386
|
+
samplerState.error = 'native sampler unavailable';
|
|
387
|
+
return;
|
|
388
|
+
}
|
|
389
|
+
try {
|
|
390
|
+
samplerState.status = native.samplerConfigure(
|
|
391
|
+
settings.sampler,
|
|
392
|
+
settings.samplerMs,
|
|
393
|
+
);
|
|
394
|
+
const before = now();
|
|
395
|
+
const nativeNow = native.samplerNow();
|
|
396
|
+
samplerState.offsetMs = estimateOffset(before, nativeNow, now());
|
|
397
|
+
samplerState.mode = settings.sampler;
|
|
398
|
+
samplerState.intervalMs = settings.samplerMs;
|
|
399
|
+
samplerState.error = null;
|
|
400
|
+
} catch (error) {
|
|
401
|
+
samplerState.error = String(error);
|
|
402
|
+
}
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const nativeSamplerNow = () => {
|
|
406
|
+
try {
|
|
407
|
+
return getNative()?.samplerNow() ?? -1;
|
|
408
|
+
} catch {
|
|
409
|
+
return -1;
|
|
410
|
+
}
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
// On-demand sample: logged natively like the automatic ones and returned. Null when the sampler is off.
|
|
414
|
+
export const sample = (reason = 'manual'): SamplerRecord | null => {
|
|
415
|
+
if (!PERF_LAB_ENABLED || samplerState.mode === 'off') {
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
try {
|
|
419
|
+
const record = getNative()?.samplerSample(reason) ?? null;
|
|
420
|
+
noteFrames(record);
|
|
421
|
+
return record;
|
|
422
|
+
} catch (error) {
|
|
423
|
+
samplerState.error = String(error);
|
|
424
|
+
return null;
|
|
425
|
+
}
|
|
426
|
+
};
|
|
427
|
+
|
|
428
|
+
// The device frame budget, from the sampler's frame counter once it has run
|
|
429
|
+
// (iOS: the display's top rate); 16.7 ms until then.
|
|
430
|
+
let frameBudget: FrameBudget = { ms: 16.7, hz: null, source: 'default' };
|
|
431
|
+
|
|
432
|
+
const noteFrames = (record: SamplerRecord | null | undefined) => {
|
|
433
|
+
const found = frameBudgetFromRecord(record);
|
|
434
|
+
if (found) {
|
|
435
|
+
frameBudget = { ms: found.ms, hz: found.hz, source: 'sampler' };
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
export const samplerStatus = () => ({
|
|
440
|
+
...samplerState,
|
|
441
|
+
settings: {
|
|
442
|
+
sampler: settings.sampler,
|
|
443
|
+
samplerMs: settings.samplerMs,
|
|
444
|
+
},
|
|
445
|
+
});
|
|
446
|
+
|
|
447
|
+
// The sample taken when the last window closed, and that window's end time (performance.now timebase). A window that
|
|
448
|
+
// opens right after takes it as its own open sample: one native call is the close of the old window and the open of
|
|
449
|
+
// the new one.
|
|
450
|
+
let lastClose: { record: SamplerRecord; t1: number } | null = null;
|
|
451
|
+
|
|
452
|
+
interface OpenSampleState {
|
|
453
|
+
openRecord: SamplerRecord | null;
|
|
454
|
+
// Reason of an asynchronous request still to be read back from native; null when none is pending.
|
|
455
|
+
openPending: string | null;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
// Open sample of a window starting at t0 (performance.now timebase). Runs in the touch-start handler, so it never
|
|
459
|
+
// blocks on the native sampler:
|
|
460
|
+
// - a window that starts exactly where the last one ended (finishPress passes the next touch's time to both) takes that
|
|
461
|
+
// window's close sample however late it was taken: the two brackets are contiguous, no gap, no overlap;
|
|
462
|
+
// - so does a window that starts within OPEN_SAMPLE_TOLERANCE_MS of the close sample (restarted key);
|
|
463
|
+
// - a window detected long after its t0 (a screen window opened by a press, seen after the new screen rendered) gets
|
|
464
|
+
// none: its bound is the press's open sample;
|
|
465
|
+
// - else the sampler is asked to sample on its own thread.
|
|
466
|
+
const openSampleFor = (id: number, t0: number): OpenSampleState => {
|
|
467
|
+
const none = { openRecord: null, openPending: null };
|
|
468
|
+
if (samplerState.mode === 'off') {
|
|
469
|
+
return none;
|
|
470
|
+
}
|
|
471
|
+
const t0Native = t0 - samplerState.offsetMs;
|
|
472
|
+
if (
|
|
473
|
+
lastClose &&
|
|
474
|
+
(lastClose.t1 === t0 ||
|
|
475
|
+
Math.abs(lastClose.record.t - t0Native) <= OPEN_SAMPLE_TOLERANCE_MS)
|
|
476
|
+
) {
|
|
477
|
+
return { openRecord: lastClose.record, openPending: null };
|
|
478
|
+
}
|
|
479
|
+
if (now() - samplerState.offsetMs - t0Native > OPEN_SAMPLE_TOLERANCE_MS) {
|
|
480
|
+
return none;
|
|
481
|
+
}
|
|
482
|
+
const reason = `window-start:${id}`;
|
|
483
|
+
const native = getNative();
|
|
484
|
+
try {
|
|
485
|
+
if (native && typeof native.samplerRequest === 'function') {
|
|
486
|
+
native.samplerRequest(reason);
|
|
487
|
+
return { openRecord: null, openPending: reason };
|
|
488
|
+
}
|
|
489
|
+
} catch (error) {
|
|
490
|
+
samplerState.error = String(error);
|
|
491
|
+
return none;
|
|
492
|
+
}
|
|
493
|
+
return { openRecord: sample(reason), openPending: null };
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
// Reads an asynchronous open request back, at finalize only (no timer: a blocking native read in the middle of the
|
|
497
|
+
// tap's response would be the cost this design avoids). Native pins request records outside the 64-record ring, so a
|
|
498
|
+
// long window still finds its own.
|
|
499
|
+
const resolveOpenSample = (state: OpenSampleState) => {
|
|
500
|
+
const reason = state.openPending;
|
|
501
|
+
const native = getNative();
|
|
502
|
+
if (state.openRecord || !reason || !native) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
try {
|
|
506
|
+
const found =
|
|
507
|
+
native.samplerTake?.(reason) ??
|
|
508
|
+
native.samplerRecent(0).find(r => r.reason === reason) ??
|
|
509
|
+
null;
|
|
510
|
+
if (found) {
|
|
511
|
+
state.openRecord = found;
|
|
512
|
+
state.openPending = null;
|
|
513
|
+
}
|
|
514
|
+
} catch (error) {
|
|
515
|
+
samplerState.error = String(error);
|
|
516
|
+
}
|
|
517
|
+
};
|
|
518
|
+
|
|
519
|
+
// Runs at finalize, right after the window closed, so the close sample never lands inside it. The bracket is
|
|
520
|
+
// the window's open sample and this close sample (a background sample only adds peak memory). The window keeps
|
|
521
|
+
// its open record, so a long window whose open sample left the native ring still has its bound.
|
|
522
|
+
const samplerForWindow = (
|
|
523
|
+
window: {
|
|
524
|
+
id: number;
|
|
525
|
+
t0: number;
|
|
526
|
+
t1: number | null;
|
|
527
|
+
} & OpenSampleState,
|
|
528
|
+
): WindowSampler | null => {
|
|
529
|
+
const native = getNative();
|
|
530
|
+
if (samplerState.mode === 'off' || !native || window.t1 === null) {
|
|
531
|
+
return null;
|
|
532
|
+
}
|
|
533
|
+
const t0 = window.t0 - samplerState.offsetMs;
|
|
534
|
+
const t1 = window.t1 - samplerState.offsetMs;
|
|
535
|
+
try {
|
|
536
|
+
resolveOpenSample(window);
|
|
537
|
+
const records = native.samplerRecent(t0 - samplerState.intervalMs - 1000);
|
|
538
|
+
const open = window.openRecord;
|
|
539
|
+
if (open && !records.some(r => r.seq === open.seq)) {
|
|
540
|
+
records.push(open);
|
|
541
|
+
}
|
|
542
|
+
noteFrames(records[records.length - 1]);
|
|
543
|
+
// A tap that ends a press and a screen window at once (same t1) takes one close sample for both.
|
|
544
|
+
const close =
|
|
545
|
+
lastClose && lastClose.t1 === window.t1
|
|
546
|
+
? lastClose.record
|
|
547
|
+
: sample(`window-end:${window.id}`);
|
|
548
|
+
lastClose = close ? { record: close, t1: window.t1 } : null;
|
|
549
|
+
if (close) {
|
|
550
|
+
records.push(close);
|
|
551
|
+
}
|
|
552
|
+
return windowAggregate(records, t0, t1, open ?? undefined);
|
|
553
|
+
} catch (error) {
|
|
554
|
+
samplerState.error = String(error);
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
};
|
|
558
|
+
|
|
559
|
+
export const percentile = (values: number[], p: number) => {
|
|
560
|
+
if (values.length === 0) {
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
564
|
+
const index = Math.min(
|
|
565
|
+
sorted.length - 1,
|
|
566
|
+
Math.max(0, Math.ceil((p / 100) * sorted.length) - 1),
|
|
567
|
+
);
|
|
568
|
+
return sorted[index] ?? null;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
const readHermesStats = (): Record<string, number> | null => {
|
|
572
|
+
try {
|
|
573
|
+
return g.HermesInternal?.getInstrumentedStats?.() ?? null;
|
|
574
|
+
} catch {
|
|
575
|
+
return null;
|
|
576
|
+
}
|
|
577
|
+
};
|
|
578
|
+
|
|
579
|
+
const readMemory = (): Record<string, number> | null => {
|
|
580
|
+
try {
|
|
581
|
+
const memory = g.performance?.memory;
|
|
582
|
+
return memory ? { ...memory } : null;
|
|
583
|
+
} catch {
|
|
584
|
+
return null;
|
|
585
|
+
}
|
|
586
|
+
};
|
|
587
|
+
|
|
588
|
+
const diffStats = (
|
|
589
|
+
before: Record<string, number> | null,
|
|
590
|
+
after: Record<string, number> | null,
|
|
591
|
+
) => {
|
|
592
|
+
if (!before || !after) {
|
|
593
|
+
return null;
|
|
594
|
+
}
|
|
595
|
+
const out: Record<string, number> = {};
|
|
596
|
+
for (const key of Object.keys(after)) {
|
|
597
|
+
const a = after[key];
|
|
598
|
+
const b = before[key];
|
|
599
|
+
if (typeof a === 'number' && typeof b === 'number') {
|
|
600
|
+
out[key] = a - b;
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return out;
|
|
604
|
+
};
|
|
605
|
+
|
|
606
|
+
// `background`: the work of one external source over the run (REQ-MEAS-32), opened by its first commit.
|
|
607
|
+
export type WindowKind = 'marker' | 'press' | 'screen' | 'background';
|
|
608
|
+
|
|
609
|
+
interface MarkerWindow {
|
|
610
|
+
id: number;
|
|
611
|
+
key: string;
|
|
612
|
+
name: string;
|
|
613
|
+
kind: WindowKind;
|
|
614
|
+
trace: string;
|
|
615
|
+
t0: number;
|
|
616
|
+
t1: number | null;
|
|
617
|
+
openRecord: SamplerRecord | null;
|
|
618
|
+
openPending: string | null;
|
|
619
|
+
endReason: string | null;
|
|
620
|
+
commits: number;
|
|
621
|
+
commitsByRenderer: Record<string, number>;
|
|
622
|
+
fibersVisited: number;
|
|
623
|
+
renders: Record<string, number>;
|
|
624
|
+
mounts: number;
|
|
625
|
+
hookCostMs: number[];
|
|
626
|
+
statsStart: Record<string, number> | null;
|
|
627
|
+
memoryStart: Record<string, number> | null;
|
|
628
|
+
points: { name: string; t: number }[];
|
|
629
|
+
settingsAtStart: string;
|
|
630
|
+
lastCommitT: number | null;
|
|
631
|
+
heapAfterGc: Record<string, number> | null;
|
|
632
|
+
details: (() => Record<string, unknown>) | null;
|
|
633
|
+
backfill: { commits: number; complete: boolean } | null;
|
|
634
|
+
net: boolean;
|
|
635
|
+
profile: WindowProfile | null;
|
|
636
|
+
exportCommits: ExportCommit[] | null;
|
|
637
|
+
// Work in the window that was proven foreign to it, per source (REQ-MEAS-32); out of every other count.
|
|
638
|
+
external: Record<string, ExternalPart>;
|
|
639
|
+
// The newest action when the window started (causes.ts `latestActionId`); older actions are foreign to it.
|
|
640
|
+
anchorAction: number;
|
|
641
|
+
// Commit-stream seqs this window was given (judged or not), as [first, last] runs: the window model's part of the split.
|
|
642
|
+
offered: [number, number][];
|
|
643
|
+
exportFibers: number;
|
|
644
|
+
exportTruncated: boolean;
|
|
645
|
+
cpuSpan: number | null;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
interface ExternalPart {
|
|
649
|
+
commits: number;
|
|
650
|
+
renderMs: number[];
|
|
651
|
+
// The part carried by a timer tick after a newer action (a queue drain, a poller, a ticker).
|
|
652
|
+
timerCarried: { commits: number; renderMs: number[] };
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
const MAX_HOOK_SAMPLES = 5000;
|
|
656
|
+
// Rendered-fiber entries kept per window for the React export (memory cap).
|
|
657
|
+
const MAX_EXPORT_FIBERS = 200000;
|
|
658
|
+
|
|
659
|
+
interface CommitRecord {
|
|
660
|
+
t: number;
|
|
661
|
+
// The process's commit count at this commit (the commit stream's `seq`).
|
|
662
|
+
seq: number;
|
|
663
|
+
rendererId: number;
|
|
664
|
+
renders: Record<string, number>;
|
|
665
|
+
mounts: number;
|
|
666
|
+
visited: number;
|
|
667
|
+
costMs: number;
|
|
668
|
+
profile: CommitProfile | null;
|
|
669
|
+
exported: ExportCommit | null;
|
|
670
|
+
causes: CommitCauses;
|
|
671
|
+
// Timer-carried (causes.ts `commitCarrier`), fixed when the commit is reported.
|
|
672
|
+
carrier: Carrier;
|
|
673
|
+
// A window judged it, at its commit or when a back-dated window took it later (backfill).
|
|
674
|
+
judged: boolean;
|
|
675
|
+
// Its background source when no window judged it at its commit; it goes there when every window ends (flush or app
|
|
676
|
+
// background) unless a back-dated window judged it in the meantime (REQ-MEAS-32).
|
|
677
|
+
background: string | null;
|
|
678
|
+
// Counted by its background window; no later window takes it.
|
|
679
|
+
routed: boolean;
|
|
680
|
+
// Set once a tap or scroll window has counted it: such a window is closed at the next touch's hardware time, which reaches
|
|
681
|
+
// JS later, so the commit that lands in between was already counted by the window that was open. It stays there.
|
|
682
|
+
pressTaken?: boolean;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
// Commits a window opened late with a backdated start (a screen detected after the action that caused it) can still
|
|
686
|
+
// take (REQ-MEAS-28: bounded by events, not by time). No such window starts before the open time of the current screen
|
|
687
|
+
// visit (screens.ts sets the floor) nor before an open window, so older commits are dropped. Without a screen floor, the
|
|
688
|
+
// newest action bounds it.
|
|
689
|
+
let retainFloor: () => number = latestActionAt;
|
|
690
|
+
export const setRetainFloor = (floor: (() => number) | null) => {
|
|
691
|
+
retainFloor = floor ?? latestActionAt;
|
|
692
|
+
};
|
|
693
|
+
const recentCommits: CommitRecord[] = [];
|
|
694
|
+
let newestDroppedCommitT = -Infinity;
|
|
695
|
+
// Foreign commits that left the ring before any window judged them. A back-dated window still takes them, so the ring's
|
|
696
|
+
// bound never decides between a window and the background; they go to the background when every window ends.
|
|
697
|
+
const unclaimed: CommitRecord[] = [];
|
|
698
|
+
|
|
699
|
+
const recordCommit = (record: CommitRecord) => {
|
|
700
|
+
recentCommits.push(record);
|
|
701
|
+
let floor = retainFloor();
|
|
702
|
+
for (const window of openWindows.values()) {
|
|
703
|
+
floor = Math.min(floor, window.t0);
|
|
704
|
+
}
|
|
705
|
+
let drop = 0;
|
|
706
|
+
while (
|
|
707
|
+
drop < recentCommits.length &&
|
|
708
|
+
(recentCommits[drop] as CommitRecord).t < floor
|
|
709
|
+
) {
|
|
710
|
+
drop++;
|
|
711
|
+
}
|
|
712
|
+
if (drop > 0) {
|
|
713
|
+
newestDroppedCommitT = (recentCommits[drop - 1] as CommitRecord).t;
|
|
714
|
+
for (const old of recentCommits.splice(0, drop)) {
|
|
715
|
+
if (!old.judged && old.background !== null) {
|
|
716
|
+
unclaimed.push(old);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
};
|
|
721
|
+
|
|
722
|
+
// Adds the commit to the window's judged part, or to its foreign work (REQ-MEAS-32). Returns whether it was judged.
|
|
723
|
+
const addCommit = (window: MarkerWindow, record: CommitRecord): boolean => {
|
|
724
|
+
const run = window.offered[window.offered.length - 1];
|
|
725
|
+
if (run && run[1] === record.seq - 1) {
|
|
726
|
+
run[1] = record.seq;
|
|
727
|
+
} else if (!run || run[1] !== record.seq) {
|
|
728
|
+
window.offered.push([record.seq, record.seq]);
|
|
729
|
+
}
|
|
730
|
+
if (window.kind === 'press') {
|
|
731
|
+
record.pressTaken = true;
|
|
732
|
+
}
|
|
733
|
+
window.lastCommitT = record.t;
|
|
734
|
+
if (window.kind !== 'background') {
|
|
735
|
+
const part = windowPart(record.causes, window.anchorAction);
|
|
736
|
+
if (!part.judged) {
|
|
737
|
+
const into = (window.external[part.source] ??= { commits: 0, renderMs: [], timerCarried: { commits: 0, renderMs: [] } });
|
|
738
|
+
into.commits++;
|
|
739
|
+
if (record.profile?.renderMs != null) {
|
|
740
|
+
into.renderMs.push(record.profile.renderMs);
|
|
741
|
+
}
|
|
742
|
+
if (record.carrier !== null) {
|
|
743
|
+
into.timerCarried.commits++;
|
|
744
|
+
if (record.profile?.renderMs != null) {
|
|
745
|
+
into.timerCarried.renderMs.push(record.profile.renderMs);
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
window.commits++;
|
|
752
|
+
window.commitsByRenderer[record.rendererId] =
|
|
753
|
+
(window.commitsByRenderer[record.rendererId] ?? 0) + 1;
|
|
754
|
+
for (const name in record.renders) {
|
|
755
|
+
window.renders[name] =
|
|
756
|
+
(window.renders[name] ?? 0) + (record.renders[name] as number);
|
|
757
|
+
}
|
|
758
|
+
window.mounts += record.mounts;
|
|
759
|
+
window.fibersVisited += record.visited;
|
|
760
|
+
if (window.hookCostMs.length < MAX_HOOK_SAMPLES) {
|
|
761
|
+
window.hookCostMs.push(record.costMs);
|
|
762
|
+
}
|
|
763
|
+
if (window.profile && record.profile) {
|
|
764
|
+
addCommitProfile(window.profile, record.profile);
|
|
765
|
+
}
|
|
766
|
+
if (window.exportCommits && record.exported) {
|
|
767
|
+
if (
|
|
768
|
+
window.exportFibers + record.exported.fibers.length <=
|
|
769
|
+
MAX_EXPORT_FIBERS
|
|
770
|
+
) {
|
|
771
|
+
window.exportCommits.push(record.exported);
|
|
772
|
+
window.exportFibers += record.exported.fibers.length;
|
|
773
|
+
} else {
|
|
774
|
+
window.exportTruncated = true;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
return true;
|
|
778
|
+
};
|
|
779
|
+
|
|
780
|
+
const backfill = (window: MarkerWindow) => {
|
|
781
|
+
let commits = 0;
|
|
782
|
+
for (const record of [...unclaimed, ...recentCommits]) {
|
|
783
|
+
if (
|
|
784
|
+
record.t >= window.t0 &&
|
|
785
|
+
!record.routed &&
|
|
786
|
+
!(window.kind === 'press' && record.pressTaken)
|
|
787
|
+
) {
|
|
788
|
+
if (addCommit(window, record)) {
|
|
789
|
+
record.judged = true;
|
|
790
|
+
}
|
|
791
|
+
commits++;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
window.backfill = {
|
|
795
|
+
commits,
|
|
796
|
+
complete: newestDroppedCommitT < window.t0,
|
|
797
|
+
};
|
|
798
|
+
};
|
|
799
|
+
|
|
800
|
+
let nextWindowId = 1;
|
|
801
|
+
const openWindows = new Map<string, MarkerWindow>();
|
|
802
|
+
|
|
803
|
+
// Retained-heap probe: a full GC takes 60-120 ms on a large heap, so it runs only while nothing is
|
|
804
|
+
// measured, a short idle after the last window ends. The next window takes that reading as its
|
|
805
|
+
// heap after GC; a window that starts before the probe ran has none. Tap and scroll windows never take it (see start).
|
|
806
|
+
const IDLE_GC_MS = 500;
|
|
807
|
+
let idleGcTimer: ReturnType<typeof setTimeout> | null = null;
|
|
808
|
+
let idleHeap: Record<string, number> | null = null;
|
|
809
|
+
|
|
810
|
+
const clearIdleGc = () => {
|
|
811
|
+
if (idleGcTimer !== null) {
|
|
812
|
+
clearTimeout(idleGcTimer);
|
|
813
|
+
idleGcTimer = null;
|
|
814
|
+
}
|
|
815
|
+
};
|
|
816
|
+
|
|
817
|
+
const scheduleIdleGc = () => {
|
|
818
|
+
clearIdleGc();
|
|
819
|
+
if (!PERF_LAB_ENABLED || !settings.gcwin || openWindows.size > 0) {
|
|
820
|
+
return;
|
|
821
|
+
}
|
|
822
|
+
idleGcTimer = setTimeout(() => {
|
|
823
|
+
idleGcTimer = null;
|
|
824
|
+
if (!settings.gcwin || openWindows.size > 0) {
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
g.gc?.();
|
|
828
|
+
idleHeap = readHermesStats();
|
|
829
|
+
}, IDLE_GC_MS);
|
|
830
|
+
};
|
|
831
|
+
|
|
832
|
+
export const lastCommitAt = (key: string) =>
|
|
833
|
+
openWindows.get(key)?.lastCommitT ?? null;
|
|
834
|
+
|
|
835
|
+
export const isWindowOpen = (key?: string) =>
|
|
836
|
+
key ? openWindows.has(key) : openWindows.size > 0;
|
|
837
|
+
|
|
838
|
+
const traceName = (name: string) => `perf-lab:${name}`;
|
|
839
|
+
|
|
840
|
+
interface StartOptions {
|
|
841
|
+
key?: string;
|
|
842
|
+
kind?: WindowKind;
|
|
843
|
+
// Backdates t0 (e.g. to the hardware touch time). performance.now() timebase.
|
|
844
|
+
at?: number;
|
|
845
|
+
details?: () => Record<string, unknown>;
|
|
846
|
+
// The action the window belongs to (a cause root id); default: the newest action when it starts.
|
|
847
|
+
anchor?: number;
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
const settingsSnapshot = () =>
|
|
851
|
+
JSON.stringify({
|
|
852
|
+
hook: settings.hook,
|
|
853
|
+
po: settings.po,
|
|
854
|
+
faults: settings.faults,
|
|
855
|
+
profile: settings.profile,
|
|
856
|
+
rexport: settings.rexport,
|
|
857
|
+
cpuprof: settings.cpuprof ? settings.cpuhz : 0,
|
|
858
|
+
diag: settings.diag,
|
|
859
|
+
gcwin: settings.gcwin,
|
|
860
|
+
});
|
|
861
|
+
|
|
862
|
+
// One window per foreign source (REQ-MEAS-32): it takes every commit that no open window judges and whose causes are
|
|
863
|
+
// proven foreign (a push, an earlier action, the launch), from its first such commit until the app leaves the foreground
|
|
864
|
+
// or the run flushes. It has no sampler, CPU or memory parts.
|
|
865
|
+
const backgroundWindows = new Map<string, MarkerWindow>();
|
|
866
|
+
|
|
867
|
+
const backgroundWindow = (source: string, t: number): MarkerWindow => {
|
|
868
|
+
const existing = backgroundWindows.get(source);
|
|
869
|
+
if (existing) {
|
|
870
|
+
return existing;
|
|
871
|
+
}
|
|
872
|
+
const name = `background:${source}`;
|
|
873
|
+
const window: MarkerWindow = {
|
|
874
|
+
id: nextWindowId++,
|
|
875
|
+
key: name,
|
|
876
|
+
name,
|
|
877
|
+
kind: 'background',
|
|
878
|
+
trace: traceName(name),
|
|
879
|
+
t0: t,
|
|
880
|
+
t1: null,
|
|
881
|
+
openRecord: null,
|
|
882
|
+
openPending: null,
|
|
883
|
+
endReason: null,
|
|
884
|
+
commits: 0,
|
|
885
|
+
commitsByRenderer: {},
|
|
886
|
+
fibersVisited: 0,
|
|
887
|
+
renders: {},
|
|
888
|
+
mounts: 0,
|
|
889
|
+
hookCostMs: [],
|
|
890
|
+
statsStart: null,
|
|
891
|
+
memoryStart: null,
|
|
892
|
+
points: [],
|
|
893
|
+
lastCommitT: null,
|
|
894
|
+
heapAfterGc: null,
|
|
895
|
+
details: null,
|
|
896
|
+
backfill: null,
|
|
897
|
+
net: false,
|
|
898
|
+
profile: settings.profile ? createWindowProfile() : null,
|
|
899
|
+
exportCommits: exportOn() ? [] : null,
|
|
900
|
+
external: {},
|
|
901
|
+
offered: [],
|
|
902
|
+
anchorAction: latestActionId(),
|
|
903
|
+
exportFibers: 0,
|
|
904
|
+
exportTruncated: false,
|
|
905
|
+
cpuSpan: null,
|
|
906
|
+
settingsAtStart: settingsSnapshot(),
|
|
907
|
+
};
|
|
908
|
+
backgroundWindows.set(source, window);
|
|
909
|
+
perfLog(`PERF_MARK ${name} start t=${fmt(t)} id=${window.id} kind=background`);
|
|
910
|
+
return window;
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
const routeToBackground = (record: CommitRecord) => {
|
|
914
|
+
if (record.routed || record.judged || record.background === null) {
|
|
915
|
+
return;
|
|
916
|
+
}
|
|
917
|
+
record.routed = true;
|
|
918
|
+
addCommit(backgroundWindow(record.background, record.t), record);
|
|
919
|
+
};
|
|
920
|
+
|
|
921
|
+
// Segments written and not yet listed for upload (listed at the next flush).
|
|
922
|
+
const pendingStreamFiles: WrittenFile[] = [];
|
|
923
|
+
// A segment is cut at this many commits even between flushes, so a long stretch outside a run stays bounded.
|
|
924
|
+
const MAX_SEGMENT_COMMITS = 5000;
|
|
925
|
+
|
|
926
|
+
const cutStream = () => {
|
|
927
|
+
const segment = commitStream.cut(now());
|
|
928
|
+
if (!segment) {
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
let path: string | null = null;
|
|
932
|
+
try {
|
|
933
|
+
path = getNative()?.writeText(segment.name, segment.json) ?? null;
|
|
934
|
+
} catch (error) {
|
|
935
|
+
perfLog(`stream write error ${String(error)}`);
|
|
936
|
+
}
|
|
937
|
+
if (path) {
|
|
938
|
+
pendingStreamFiles.push({ name: segment.name, windows: [], bytes: segment.json.length });
|
|
939
|
+
}
|
|
940
|
+
perfLog(`stream ${JSON.stringify({ name: segment.name, commits: segment.commits, bytes: segment.json.length, fibersDropped: segment.fibersDropped, written: path !== null })}`);
|
|
941
|
+
};
|
|
942
|
+
|
|
943
|
+
const endBackground = (reason: string) => {
|
|
944
|
+
for (const record of [...unclaimed.splice(0), ...recentCommits]) {
|
|
945
|
+
routeToBackground(record);
|
|
946
|
+
}
|
|
947
|
+
// Every window ended (the flush or the app leaving the foreground): no later window starts before this, so the ring
|
|
948
|
+
// keeps nothing older.
|
|
949
|
+
const last = recentCommits[recentCommits.length - 1];
|
|
950
|
+
if (last) {
|
|
951
|
+
newestDroppedCommitT = last.t;
|
|
952
|
+
recentCommits.length = 0;
|
|
953
|
+
}
|
|
954
|
+
for (const window of backgroundWindows.values()) {
|
|
955
|
+
window.t1 = now();
|
|
956
|
+
window.endReason = reason;
|
|
957
|
+
perfLog(`PERF_MARK ${window.name} end t=${fmt(window.t1)} id=${window.id} reason=${reason}`);
|
|
958
|
+
finalizeBackground(window);
|
|
959
|
+
}
|
|
960
|
+
backgroundWindows.clear();
|
|
961
|
+
cutStream();
|
|
962
|
+
};
|
|
963
|
+
|
|
964
|
+
const start = (name: string, options: StartOptions = {}) => {
|
|
965
|
+
if (!PERF_LAB_ENABLED || !settings.marks) {
|
|
966
|
+
return null;
|
|
967
|
+
}
|
|
968
|
+
const key = options.key ?? name;
|
|
969
|
+
if (openWindows.has(key)) {
|
|
970
|
+
end(key, 'restarted');
|
|
971
|
+
}
|
|
972
|
+
let heapAfterGc: Record<string, number> | null = null;
|
|
973
|
+
if (openWindows.size === 0) {
|
|
974
|
+
clearIdleGc();
|
|
975
|
+
// Tap and scroll windows (window model 2) get no reading: forced GC is never run in them, and a reading from the idle
|
|
976
|
+
// probe would be a different kind of reading from the rest of their cycles. Their leak slope is missing.
|
|
977
|
+
heapAfterGc = settings.gcwin && options.kind !== 'press' ? idleHeap : null;
|
|
978
|
+
idleHeap = null;
|
|
979
|
+
}
|
|
980
|
+
const statsStart = readHermesStats();
|
|
981
|
+
const id = nextWindowId++;
|
|
982
|
+
const startT = options.at ?? now();
|
|
983
|
+
const openSample = openSampleFor(id, startT);
|
|
984
|
+
const window: MarkerWindow = {
|
|
985
|
+
id,
|
|
986
|
+
key,
|
|
987
|
+
name,
|
|
988
|
+
kind: options.kind ?? 'marker',
|
|
989
|
+
trace: traceName(name),
|
|
990
|
+
t0: startT,
|
|
991
|
+
t1: null,
|
|
992
|
+
...openSample,
|
|
993
|
+
endReason: null,
|
|
994
|
+
commits: 0,
|
|
995
|
+
commitsByRenderer: {},
|
|
996
|
+
fibersVisited: 0,
|
|
997
|
+
renders: {},
|
|
998
|
+
mounts: 0,
|
|
999
|
+
hookCostMs: [],
|
|
1000
|
+
statsStart,
|
|
1001
|
+
memoryStart: readMemory(),
|
|
1002
|
+
points: [],
|
|
1003
|
+
lastCommitT: null,
|
|
1004
|
+
heapAfterGc,
|
|
1005
|
+
details: options.details ?? null,
|
|
1006
|
+
backfill: null,
|
|
1007
|
+
net: settings.net,
|
|
1008
|
+
profile: settings.profile ? createWindowProfile() : null,
|
|
1009
|
+
exportCommits: exportOn() ? [] : null,
|
|
1010
|
+
external: {},
|
|
1011
|
+
offered: [],
|
|
1012
|
+
anchorAction: options.anchor ?? latestActionId(),
|
|
1013
|
+
exportFibers: 0,
|
|
1014
|
+
exportTruncated: false,
|
|
1015
|
+
cpuSpan: null,
|
|
1016
|
+
settingsAtStart: settingsSnapshot(),
|
|
1017
|
+
};
|
|
1018
|
+
if (options.at !== undefined && settings.hook) {
|
|
1019
|
+
backfill(window);
|
|
1020
|
+
}
|
|
1021
|
+
if (window.net) {
|
|
1022
|
+
net.windowOpen(id, window.t0, options.at !== undefined);
|
|
1023
|
+
}
|
|
1024
|
+
openWindows.set(key, window);
|
|
1025
|
+
window.cpuSpan = cpu.windowOpened(window.id);
|
|
1026
|
+
for (const listener of windowStartListeners) {
|
|
1027
|
+
listener();
|
|
1028
|
+
}
|
|
1029
|
+
getNative()?.beginAsyncSection(window.trace, window.id);
|
|
1030
|
+
g.performance?.mark?.(`${window.trace}:start:${window.id}`);
|
|
1031
|
+
perfLog(
|
|
1032
|
+
`PERF_MARK ${name} start t=${fmt(window.t0)} id=${window.id} kind=${window.kind}`,
|
|
1033
|
+
);
|
|
1034
|
+
return window;
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
const end = (key: string, reason = 'end', at?: number) => {
|
|
1038
|
+
const window = openWindows.get(key);
|
|
1039
|
+
if (!window) {
|
|
1040
|
+
return;
|
|
1041
|
+
}
|
|
1042
|
+
window.t1 = at ?? now();
|
|
1043
|
+
window.endReason = reason;
|
|
1044
|
+
openWindows.delete(key);
|
|
1045
|
+
getNative()?.endAsyncSection(window.trace, window.id);
|
|
1046
|
+
let measured: number | null = null;
|
|
1047
|
+
try {
|
|
1048
|
+
g.performance?.mark?.(`${window.trace}:end:${window.id}`);
|
|
1049
|
+
measured =
|
|
1050
|
+
g.performance?.measure?.(
|
|
1051
|
+
`${window.trace}:${window.id}`,
|
|
1052
|
+
`${window.trace}:start:${window.id}`,
|
|
1053
|
+
`${window.trace}:end:${window.id}`,
|
|
1054
|
+
)?.duration ?? null;
|
|
1055
|
+
} catch {
|
|
1056
|
+
measured = null;
|
|
1057
|
+
}
|
|
1058
|
+
perfLog(
|
|
1059
|
+
`PERF_MARK ${window.name} end t=${fmt(window.t1)} id=${window.id} dur=${fmt(window.t1 - window.t0)} reason=${reason}${measured === null ? '' : ` measure=${fmt(measured)}`}`,
|
|
1060
|
+
);
|
|
1061
|
+
// Written now: a process killed right after the window ends must still have it.
|
|
1062
|
+
// Observer entries not yet delivered to the callback are taken synchronously.
|
|
1063
|
+
drainObservers();
|
|
1064
|
+
finalizeWindow(window);
|
|
1065
|
+
scheduleIdleGc();
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
// Ends every open window, e.g. when the app leaves the foreground: iOS pauses
|
|
1069
|
+
// JS timers in the background, so no timer could end them before a kill.
|
|
1070
|
+
const windowsEndListeners = new Set<(reason: string) => void>();
|
|
1071
|
+
export const onWindowsEnd = (listener: (reason: string) => void) => {
|
|
1072
|
+
windowsEndListeners.add(listener);
|
|
1073
|
+
};
|
|
1074
|
+
|
|
1075
|
+
const endAll = (reason: string) => {
|
|
1076
|
+
for (const key of [...openWindows.keys()]) {
|
|
1077
|
+
end(key, reason);
|
|
1078
|
+
}
|
|
1079
|
+
endBackground(reason);
|
|
1080
|
+
for (const listener of windowsEndListeners) {
|
|
1081
|
+
listener(reason);
|
|
1082
|
+
}
|
|
1083
|
+
};
|
|
1084
|
+
|
|
1085
|
+
const rename = (key: string, name: string) => {
|
|
1086
|
+
const window = openWindows.get(key);
|
|
1087
|
+
if (window && window.name !== name) {
|
|
1088
|
+
perfLog(`PERF_MARK ${window.name} renamed=${name} id=${window.id}`);
|
|
1089
|
+
window.name = name;
|
|
1090
|
+
}
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
const point = (name: string) => {
|
|
1094
|
+
if (!PERF_LAB_ENABLED || !settings.marks) {
|
|
1095
|
+
return;
|
|
1096
|
+
}
|
|
1097
|
+
const t = now();
|
|
1098
|
+
for (const window of openWindows.values()) {
|
|
1099
|
+
window.points.push({ name, t });
|
|
1100
|
+
}
|
|
1101
|
+
perfLog(`PERF_MARK ${name} point t=${fmt(t)}`);
|
|
1102
|
+
};
|
|
1103
|
+
|
|
1104
|
+
export const markers = { start, end, endAll, rename, point };
|
|
1105
|
+
|
|
1106
|
+
const PERFORMED_WORK = 1;
|
|
1107
|
+
|
|
1108
|
+
const hookState = {
|
|
1109
|
+
installed: false,
|
|
1110
|
+
source: 'none' as 'none' | 'polyfill' | 'boot',
|
|
1111
|
+
preexisting: false,
|
|
1112
|
+
injected: [] as {
|
|
1113
|
+
id: number;
|
|
1114
|
+
version?: string;
|
|
1115
|
+
pkg?: string;
|
|
1116
|
+
bundleType?: number;
|
|
1117
|
+
}[],
|
|
1118
|
+
totalCommits: 0,
|
|
1119
|
+
totalCostMs: 0,
|
|
1120
|
+
outsideWindowCostMs: 0,
|
|
1121
|
+
profilingRenderer: null as boolean | null,
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
// Every commit is walked (bailed-out subtrees skipped): renders are counted for
|
|
1125
|
+
// the open windows and the recent-commit ring, and newly mounted host/screen
|
|
1126
|
+
// fibers feed the registries that name touches and screens.
|
|
1127
|
+
const walkCommit = (
|
|
1128
|
+
root: FiberRoot,
|
|
1129
|
+
profile: CommitProfile | null,
|
|
1130
|
+
exported: ExportCommit | null,
|
|
1131
|
+
causes: CommitCauses,
|
|
1132
|
+
) => {
|
|
1133
|
+
const stack: Fiber[] = [root.current];
|
|
1134
|
+
const renders: Record<string, number> = {};
|
|
1135
|
+
let mounts = 0;
|
|
1136
|
+
let visited = 0;
|
|
1137
|
+
const ancestors = exported ? createAncestorPaths() : null;
|
|
1138
|
+
while (stack.length > 0) {
|
|
1139
|
+
const fiber = stack.pop() as Fiber;
|
|
1140
|
+
visited++;
|
|
1141
|
+
const mounted = fiber.alternate === null;
|
|
1142
|
+
if (mounted) {
|
|
1143
|
+
noteMounted(fiber);
|
|
1144
|
+
}
|
|
1145
|
+
noteHostFiber(fiber, causes);
|
|
1146
|
+
if (isComponentTag(fiber.tag) && (fiber.flags & PERFORMED_WORK) !== 0) {
|
|
1147
|
+
const name = componentName(fiber);
|
|
1148
|
+
renders[name] = (renders[name] ?? 0) + 1;
|
|
1149
|
+
if (mounted) {
|
|
1150
|
+
mounts++;
|
|
1151
|
+
}
|
|
1152
|
+
if (profile || exported) {
|
|
1153
|
+
const cause = updateCause(fiber as ProfiledFiber);
|
|
1154
|
+
if (profile) {
|
|
1155
|
+
recordFiber(profile, name, fiber as ProfiledFiber, cause, fiberId(fiber));
|
|
1156
|
+
}
|
|
1157
|
+
if (exported) {
|
|
1158
|
+
exported.fibers.push(
|
|
1159
|
+
exportFiber(
|
|
1160
|
+
fiber,
|
|
1161
|
+
name,
|
|
1162
|
+
cause,
|
|
1163
|
+
totalMs(fiber as ProfiledFiber),
|
|
1164
|
+
selfMs(fiber as ProfiledFiber),
|
|
1165
|
+
(ancestors as (f: Fiber) => number[])(fiber),
|
|
1166
|
+
),
|
|
1167
|
+
);
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
// Children not cloned in this render are the previous commit's fibers and
|
|
1172
|
+
// carry stale flags, so a bailed-out subtree is skipped entirely.
|
|
1173
|
+
const alternate = fiber.alternate;
|
|
1174
|
+
if (alternate !== null && fiber.child === alternate.child) {
|
|
1175
|
+
continue;
|
|
1176
|
+
}
|
|
1177
|
+
for (let child = fiber.child; child !== null; child = child.sibling) {
|
|
1178
|
+
stack.push(child);
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
return { renders, mounts, visited };
|
|
1182
|
+
};
|
|
1183
|
+
|
|
1184
|
+
const afterCommitListeners = new Set<() => void>();
|
|
1185
|
+
const windowStartListeners = new Set<() => void>();
|
|
1186
|
+
|
|
1187
|
+
export const onWindowStart = (listener: () => void) => {
|
|
1188
|
+
windowStartListeners.add(listener);
|
|
1189
|
+
};
|
|
1190
|
+
|
|
1191
|
+
export const onAfterCommit = (listener: () => void) => {
|
|
1192
|
+
afterCommitListeners.add(listener);
|
|
1193
|
+
};
|
|
1194
|
+
|
|
1195
|
+
const onCommitFiberRoot = (rendererId: number, root: FiberRoot) => {
|
|
1196
|
+
if (!settings.hook) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
hookState.totalCommits++;
|
|
1200
|
+
const seq = hookState.totalCommits;
|
|
1201
|
+
const t0 = now();
|
|
1202
|
+
const causes = commitCauses(root);
|
|
1203
|
+
const windows = [...openWindows.values()];
|
|
1204
|
+
let walked: ReturnType<typeof walkCommit> | null = null;
|
|
1205
|
+
let profile: CommitProfile | null = null;
|
|
1206
|
+
let exported: ExportCommit | null = null;
|
|
1207
|
+
let renderMs: number | null = null;
|
|
1208
|
+
try {
|
|
1209
|
+
const profiled = root as unknown as ProfiledRoot;
|
|
1210
|
+
if (hookState.profilingRenderer === null) {
|
|
1211
|
+
hookState.profilingRenderer = isProfilingFiber(profiled.current);
|
|
1212
|
+
}
|
|
1213
|
+
// The figure `profile.renderMs` reads; null when the renderer does not time commits.
|
|
1214
|
+
renderMs = isProfilingFiber(profiled.current) ? totalMs(profiled.current) : null;
|
|
1215
|
+
if (settings.profile) {
|
|
1216
|
+
profile = createCommitProfile(profiled, seq);
|
|
1217
|
+
}
|
|
1218
|
+
// Only commits a window can take are exported (open now, or backfilled
|
|
1219
|
+
// into a window opened later with a backdated start).
|
|
1220
|
+
if (exportOn()) {
|
|
1221
|
+
exported = {
|
|
1222
|
+
t: t0,
|
|
1223
|
+
durationMs: totalMs(profiled.current) ?? 0,
|
|
1224
|
+
updaters: profile?.updaters ?? null,
|
|
1225
|
+
causes: describeCauses(causes),
|
|
1226
|
+
carrier: commitCarrier(causes),
|
|
1227
|
+
store: describeStore(causes.set),
|
|
1228
|
+
task: describeTask(causes),
|
|
1229
|
+
fibers: [],
|
|
1230
|
+
};
|
|
1231
|
+
}
|
|
1232
|
+
walked = walkCommit(root, profile, exported, causes);
|
|
1233
|
+
} catch (error) {
|
|
1234
|
+
perfLog(`hook walk error ${String(error)}`);
|
|
1235
|
+
}
|
|
1236
|
+
const cost = now() - t0;
|
|
1237
|
+
hookState.totalCostMs += cost;
|
|
1238
|
+
if (windows.length === 0) {
|
|
1239
|
+
hookState.outsideWindowCostMs += cost;
|
|
1240
|
+
}
|
|
1241
|
+
const record: CommitRecord = {
|
|
1242
|
+
t: t0,
|
|
1243
|
+
seq,
|
|
1244
|
+
rendererId,
|
|
1245
|
+
renders: walked?.renders ?? {},
|
|
1246
|
+
mounts: walked?.mounts ?? 0,
|
|
1247
|
+
visited: walked?.visited ?? 0,
|
|
1248
|
+
costMs: cost,
|
|
1249
|
+
profile,
|
|
1250
|
+
exported,
|
|
1251
|
+
causes,
|
|
1252
|
+
carrier: commitCarrier(causes),
|
|
1253
|
+
judged: false,
|
|
1254
|
+
background: null,
|
|
1255
|
+
routed: false,
|
|
1256
|
+
};
|
|
1257
|
+
for (const window of windows) {
|
|
1258
|
+
record.judged = addCommit(window, record) || record.judged;
|
|
1259
|
+
}
|
|
1260
|
+
// Work no open window judges is the background of its source: pushes, and loops of earlier actions or of the launch.
|
|
1261
|
+
record.background = record.judged ? null : backgroundSource(causes);
|
|
1262
|
+
commitStream.noteCommit({ seq, t: t0, renderMs, causes, fibers: exported?.fibers ?? null, updaters: profile?.updaters ?? null });
|
|
1263
|
+
if (commitStream.size() >= MAX_SEGMENT_COMMITS) {
|
|
1264
|
+
cutStream();
|
|
1265
|
+
}
|
|
1266
|
+
recordCommit(record);
|
|
1267
|
+
for (const listener of afterCommitListeners) {
|
|
1268
|
+
listener();
|
|
1269
|
+
}
|
|
1270
|
+
};
|
|
1271
|
+
|
|
1272
|
+
const onCommitFiberUnmount = (_rendererId: number, fiber: Fiber) => {
|
|
1273
|
+
if (settings.hook) {
|
|
1274
|
+
noteUnmounted(fiber);
|
|
1275
|
+
forgetHostFiber(fiber);
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
export const lastRoot = (rendererId = 1): FiberRoot | null =>
|
|
1280
|
+
g.__PERFLAB_HOOK__?.state.lastRootByRenderer[rendererId] ?? null;
|
|
1281
|
+
|
|
1282
|
+
// The polyfill (withPerfLab) installed the hook before React loaded; attach to
|
|
1283
|
+
// it. Without the polyfill, fall back to installing it now, as the prototype did.
|
|
1284
|
+
export const attachHook = () => {
|
|
1285
|
+
if (!PERF_LAB_ENABLED || hookState.installed) {
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
const polyfill = g.__PERFLAB_HOOK__;
|
|
1289
|
+
if (polyfill) {
|
|
1290
|
+
polyfill.ensure();
|
|
1291
|
+
polyfill.attach({
|
|
1292
|
+
onInject: (id, internals) => {
|
|
1293
|
+
hookState.injected.push({
|
|
1294
|
+
id,
|
|
1295
|
+
version: internals.version,
|
|
1296
|
+
pkg: internals.rendererPackageName,
|
|
1297
|
+
bundleType: internals.bundleType,
|
|
1298
|
+
});
|
|
1299
|
+
},
|
|
1300
|
+
onCommit: onCommitFiberRoot,
|
|
1301
|
+
onUnmount: onCommitFiberUnmount,
|
|
1302
|
+
});
|
|
1303
|
+
for (const injection of polyfill.state.injections) {
|
|
1304
|
+
hookState.injected.push(injection);
|
|
1305
|
+
}
|
|
1306
|
+
hookState.preexisting = polyfill.state.mode === 'chained';
|
|
1307
|
+
hookState.source = 'polyfill';
|
|
1308
|
+
hookState.installed = true;
|
|
1309
|
+
return;
|
|
1310
|
+
}
|
|
1311
|
+
if (g.__REACT_DEVTOOLS_GLOBAL_HOOK__) {
|
|
1312
|
+
hookState.preexisting = true;
|
|
1313
|
+
g.console.log('PERF_LAB hook already present, not replacing it');
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
let nextRendererId = 1;
|
|
1317
|
+
g.__REACT_DEVTOOLS_GLOBAL_HOOK__ = {
|
|
1318
|
+
supportsFiber: true,
|
|
1319
|
+
isDisabled: false,
|
|
1320
|
+
renderers: new Map<number, RendererInternals>(),
|
|
1321
|
+
inject(internals: RendererInternals) {
|
|
1322
|
+
const id = nextRendererId++;
|
|
1323
|
+
hookState.injected.push({
|
|
1324
|
+
id,
|
|
1325
|
+
version: internals.version,
|
|
1326
|
+
pkg: internals.rendererPackageName,
|
|
1327
|
+
bundleType: internals.bundleType,
|
|
1328
|
+
});
|
|
1329
|
+
g.console.log(
|
|
1330
|
+
`PERF_LAB hook inject id=${id} renderer=${internals.rendererPackageName} version=${internals.version} bundleType=${internals.bundleType}`,
|
|
1331
|
+
);
|
|
1332
|
+
return id;
|
|
1333
|
+
},
|
|
1334
|
+
onCommitFiberRoot,
|
|
1335
|
+
onPostCommitFiberRoot: noop,
|
|
1336
|
+
onCommitFiberUnmount,
|
|
1337
|
+
setStrictMode: noop,
|
|
1338
|
+
checkDCE: noop,
|
|
1339
|
+
};
|
|
1340
|
+
hookState.source = 'boot';
|
|
1341
|
+
hookState.installed = true;
|
|
1342
|
+
};
|
|
1343
|
+
|
|
1344
|
+
const MAX_ENTRIES = 1000;
|
|
1345
|
+
export const entries: PerfEntry[] = [];
|
|
1346
|
+
let observers: PerfObserver[] = [];
|
|
1347
|
+
|
|
1348
|
+
const pushEntries = (list: PerfObserverList) => {
|
|
1349
|
+
for (const entry of list.getEntries()) {
|
|
1350
|
+
entries.push({
|
|
1351
|
+
entryType: entry.entryType,
|
|
1352
|
+
name: entry.name,
|
|
1353
|
+
startTime: entry.startTime,
|
|
1354
|
+
duration: entry.duration,
|
|
1355
|
+
processingStart: entry.processingStart,
|
|
1356
|
+
processingEnd: entry.processingEnd,
|
|
1357
|
+
interactionId: entry.interactionId,
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1360
|
+
if (entries.length > MAX_ENTRIES) {
|
|
1361
|
+
entries.splice(0, entries.length - MAX_ENTRIES);
|
|
1362
|
+
}
|
|
1363
|
+
};
|
|
1364
|
+
|
|
1365
|
+
const startObservers = () => {
|
|
1366
|
+
if (observers.length > 0 || !g.PerformanceObserver) {
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const Ctor = g.PerformanceObserver;
|
|
1370
|
+
for (const type of ['longtask', 'event']) {
|
|
1371
|
+
try {
|
|
1372
|
+
const observer = new Ctor(pushEntries);
|
|
1373
|
+
observer.observe(
|
|
1374
|
+
type === 'event'
|
|
1375
|
+
? { type, buffered: false, durationThreshold: 0 }
|
|
1376
|
+
: { type, buffered: false },
|
|
1377
|
+
);
|
|
1378
|
+
observers.push(observer);
|
|
1379
|
+
} catch (error) {
|
|
1380
|
+
perfLog(`observer ${type} failed ${String(error)}`);
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
const drainObservers = () => {
|
|
1386
|
+
for (const observer of observers) {
|
|
1387
|
+
try {
|
|
1388
|
+
const pending = observer.takeRecords?.();
|
|
1389
|
+
if (pending && pending.length > 0) {
|
|
1390
|
+
pushEntries({ getEntries: () => pending });
|
|
1391
|
+
}
|
|
1392
|
+
} catch (error) {
|
|
1393
|
+
perfLog(`observer takeRecords failed ${String(error)}`);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
const stopObservers = () => {
|
|
1399
|
+
for (const observer of observers) {
|
|
1400
|
+
observer.disconnect();
|
|
1401
|
+
}
|
|
1402
|
+
observers = [];
|
|
1403
|
+
entries.length = 0;
|
|
1404
|
+
};
|
|
1405
|
+
|
|
1406
|
+
const summarizeEntries = (window: MarkerWindow) => {
|
|
1407
|
+
const t1 = window.t1 ?? now();
|
|
1408
|
+
const longtasks = entries.filter(
|
|
1409
|
+
e =>
|
|
1410
|
+
e.entryType === 'longtask' &&
|
|
1411
|
+
e.startTime < t1 &&
|
|
1412
|
+
e.startTime + e.duration > window.t0,
|
|
1413
|
+
);
|
|
1414
|
+
const events = entries.filter(
|
|
1415
|
+
e =>
|
|
1416
|
+
e.entryType === 'event' &&
|
|
1417
|
+
e.startTime >= window.t0 - 1000 &&
|
|
1418
|
+
e.startTime < t1,
|
|
1419
|
+
);
|
|
1420
|
+
return {
|
|
1421
|
+
longtaskCount: longtasks.length,
|
|
1422
|
+
longtaskTotalMs: longtasks.reduce((sum, e) => sum + e.duration, 0),
|
|
1423
|
+
longtaskMaxMs: longtasks.reduce((max, e) => Math.max(max, e.duration), 0),
|
|
1424
|
+
events: events.map(e => ({
|
|
1425
|
+
name: e.name,
|
|
1426
|
+
startTime: e.startTime,
|
|
1427
|
+
duration: e.duration,
|
|
1428
|
+
inputDelay:
|
|
1429
|
+
e.processingStart === undefined
|
|
1430
|
+
? null
|
|
1431
|
+
: e.processingStart - e.startTime,
|
|
1432
|
+
processing:
|
|
1433
|
+
e.processingStart === undefined || e.processingEnd === undefined
|
|
1434
|
+
? null
|
|
1435
|
+
: e.processingEnd - e.processingStart,
|
|
1436
|
+
interactionId: e.interactionId,
|
|
1437
|
+
})),
|
|
1438
|
+
};
|
|
1439
|
+
};
|
|
1440
|
+
|
|
1441
|
+
const clearPerformanceBuffers = () => {
|
|
1442
|
+
const perf = g.performance;
|
|
1443
|
+
if (!perf) {
|
|
1444
|
+
return null;
|
|
1445
|
+
}
|
|
1446
|
+
const counts = {
|
|
1447
|
+
marks: perf.getEntriesByType?.('mark')?.length ?? null,
|
|
1448
|
+
measures: perf.getEntriesByType?.('measure')?.length ?? null,
|
|
1449
|
+
};
|
|
1450
|
+
// RN's mark/measure buffers have no size limit; nothing in the app reads them.
|
|
1451
|
+
perf.clearMarks?.();
|
|
1452
|
+
perf.clearMeasures?.();
|
|
1453
|
+
return counts;
|
|
1454
|
+
};
|
|
1455
|
+
|
|
1456
|
+
const topRenders = (renders: Record<string, number>, limit: number) =>
|
|
1457
|
+
Object.entries(renders)
|
|
1458
|
+
.sort((a, b) => b[1] - a[1])
|
|
1459
|
+
.slice(0, limit);
|
|
1460
|
+
|
|
1461
|
+
const safeDetails = (window: MarkerWindow) => {
|
|
1462
|
+
if (!window.details) {
|
|
1463
|
+
return null;
|
|
1464
|
+
}
|
|
1465
|
+
try {
|
|
1466
|
+
return window.details();
|
|
1467
|
+
} catch (error) {
|
|
1468
|
+
return { error: String(error) };
|
|
1469
|
+
}
|
|
1470
|
+
};
|
|
1471
|
+
|
|
1472
|
+
const netForWindow = (window: MarkerWindow): NetWindow | null =>
|
|
1473
|
+
window.net ? net.windowClose(window.id) : null;
|
|
1474
|
+
|
|
1475
|
+
const reactExportName = (window: MarkerWindow) =>
|
|
1476
|
+
`react-${PROCESS_ID}-${window.id}-${window.name.replace(/[^\w.:+-]/g, '_')}.json`;
|
|
1477
|
+
|
|
1478
|
+
interface PendingExport {
|
|
1479
|
+
name: string;
|
|
1480
|
+
window: number;
|
|
1481
|
+
t0: number;
|
|
1482
|
+
commits: ExportCommit[];
|
|
1483
|
+
fibers: number;
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
// React exports held until flush (inside a run), capped like a window's.
|
|
1487
|
+
const MAX_PENDING_EXPORT_FIBERS = 400000;
|
|
1488
|
+
const pendingExports: PendingExport[] = [];
|
|
1489
|
+
let pendingExportFibers = 0;
|
|
1490
|
+
|
|
1491
|
+
const writeExportFile = (name: string, t0: number, commits: ExportCommit[]) => {
|
|
1492
|
+
const json = JSON.stringify(buildReactExport(t0, commits));
|
|
1493
|
+
return { path: getNative()?.writeText(name, json) ?? null, bytes: json.length };
|
|
1494
|
+
};
|
|
1495
|
+
|
|
1496
|
+
const writeReactExport = (window: MarkerWindow) => {
|
|
1497
|
+
if (!window.exportCommits) {
|
|
1498
|
+
return null;
|
|
1499
|
+
}
|
|
1500
|
+
const name = reactExportName(window);
|
|
1501
|
+
const base = {
|
|
1502
|
+
name,
|
|
1503
|
+
commits: window.exportCommits.length,
|
|
1504
|
+
fibers: window.exportFibers,
|
|
1505
|
+
truncated: window.exportTruncated,
|
|
1506
|
+
};
|
|
1507
|
+
if (inRun()) {
|
|
1508
|
+
if (pendingExportFibers + window.exportFibers > MAX_PENDING_EXPORT_FIBERS) {
|
|
1509
|
+
return { ...base, deferred: true, dropped: true };
|
|
1510
|
+
}
|
|
1511
|
+
pendingExports.push({
|
|
1512
|
+
name,
|
|
1513
|
+
window: window.id,
|
|
1514
|
+
t0: window.t0,
|
|
1515
|
+
commits: window.exportCommits,
|
|
1516
|
+
fibers: window.exportFibers,
|
|
1517
|
+
});
|
|
1518
|
+
pendingExportFibers += window.exportFibers;
|
|
1519
|
+
return { ...base, deferred: true, dropped: false };
|
|
1520
|
+
}
|
|
1521
|
+
try {
|
|
1522
|
+
return { ...base, deferred: false, ...writeExportFile(name, window.t0, window.exportCommits) };
|
|
1523
|
+
} catch (error) {
|
|
1524
|
+
return { ...base, deferred: false, error: String(error) };
|
|
1525
|
+
}
|
|
1526
|
+
};
|
|
1527
|
+
|
|
1528
|
+
// The span's file goes on the window that closed it; the others name the span.
|
|
1529
|
+
const cpuForWindow = (window: MarkerWindow) => {
|
|
1530
|
+
if (window.cpuSpan === null) {
|
|
1531
|
+
return null;
|
|
1532
|
+
}
|
|
1533
|
+
const result = cpu.windowClosed(openWindows.size);
|
|
1534
|
+
if (result && !result.deferred) {
|
|
1535
|
+
perfLog(`cpuprofile ${JSON.stringify(result)}`);
|
|
1536
|
+
}
|
|
1537
|
+
return result ?? { span: window.cpuSpan };
|
|
1538
|
+
};
|
|
1539
|
+
|
|
1540
|
+
// Windows of this run that broke a budget on the device, by id: their raw
|
|
1541
|
+
// files are kept even outside a diagnostic iteration (plan 40 §3.3).
|
|
1542
|
+
const budgetBreaks = new Map<number, ArtifactReason[]>();
|
|
1543
|
+
|
|
1544
|
+
const noteBudgetBreak = (window: MarkerWindow, frame: boolean, longTaskMaxMs: number | null) => {
|
|
1545
|
+
if (!inRun()) {
|
|
1546
|
+
return;
|
|
1547
|
+
}
|
|
1548
|
+
const reasons: ArtifactReason[] = [];
|
|
1549
|
+
if (frame) {
|
|
1550
|
+
reasons.push('frame-budget');
|
|
1551
|
+
}
|
|
1552
|
+
if (longTaskMaxMs !== null && longTaskMaxMs > LONG_TASK_MS) {
|
|
1553
|
+
reasons.push('long-task');
|
|
1554
|
+
}
|
|
1555
|
+
if (reasons.length > 0) {
|
|
1556
|
+
budgetBreaks.set(window.id, reasons);
|
|
1557
|
+
}
|
|
1558
|
+
};
|
|
1559
|
+
|
|
1560
|
+
const bytesOf = (file: Record<string, unknown> | null) =>
|
|
1561
|
+
typeof file?.bytes === 'number' ? file.bytes : null;
|
|
1562
|
+
|
|
1563
|
+
// Runs at flush, after the windows ended and before lines are sent: the CPU
|
|
1564
|
+
// span is stopped and dumped, the held React exports are written. Returns the
|
|
1565
|
+
// files the run keeps (diagnostic iteration, or budget-breaking windows).
|
|
1566
|
+
const writeDeferredFiles = (): ArtifactEntry[] => {
|
|
1567
|
+
const t0 = now();
|
|
1568
|
+
const cpuResult = cpu.dump();
|
|
1569
|
+
if (cpuResult) {
|
|
1570
|
+
perfLog(`cpuprofile ${JSON.stringify(cpuResult)}`);
|
|
1571
|
+
}
|
|
1572
|
+
const exports = pendingExports.splice(0);
|
|
1573
|
+
pendingExportFibers = 0;
|
|
1574
|
+
const written: WrittenFile[] = [];
|
|
1575
|
+
for (const pending of exports) {
|
|
1576
|
+
let result: Record<string, unknown>;
|
|
1577
|
+
try {
|
|
1578
|
+
result = writeExportFile(pending.name, pending.t0, pending.commits);
|
|
1579
|
+
if (result.path) {
|
|
1580
|
+
written.push({ name: pending.name, windows: [pending.window], bytes: bytesOf(result) });
|
|
1581
|
+
}
|
|
1582
|
+
} catch (error) {
|
|
1583
|
+
result = { error: String(error) };
|
|
1584
|
+
}
|
|
1585
|
+
perfLog(
|
|
1586
|
+
`rexport ${JSON.stringify({ name: pending.name, commits: pending.commits.length, fibers: pending.fibers, ...result })}`,
|
|
1587
|
+
);
|
|
1588
|
+
}
|
|
1589
|
+
const cpuFile: WrittenFile | null =
|
|
1590
|
+
cpuResult?.fileName && cpuResult.file
|
|
1591
|
+
? { name: cpuResult.fileName, windows: cpuResult.windows, bytes: bytesOf(cpuResult.file) }
|
|
1592
|
+
: null;
|
|
1593
|
+
const artifacts = inRun()
|
|
1594
|
+
? selectArtifacts({ diag: settings.diag, runToken, breaks: budgetBreaks, cpu: cpuFile, exports: written, stream: pendingStreamFiles.splice(0) })
|
|
1595
|
+
: [];
|
|
1596
|
+
budgetBreaks.clear();
|
|
1597
|
+
if (cpuResult || exports.length > 0) {
|
|
1598
|
+
perfLog(
|
|
1599
|
+
`flush files ${JSON.stringify({ ms: now() - t0, cpuprofile: cpuResult?.fileName ?? null, rexports: exports.length, artifacts: artifacts.length })}`,
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
return artifacts;
|
|
1603
|
+
};
|
|
1604
|
+
|
|
1605
|
+
type ArtifactHandler = (
|
|
1606
|
+
entries: ArtifactEntry[],
|
|
1607
|
+
runToken: string | null,
|
|
1608
|
+
processId: string,
|
|
1609
|
+
deadline: number,
|
|
1610
|
+
) => Promise<ArtifactUploadSummary>;
|
|
1611
|
+
let artifactHandler: ArtifactHandler | null = null;
|
|
1612
|
+
|
|
1613
|
+
export const setArtifactHandler = (handler: ArtifactHandler | null) => {
|
|
1614
|
+
artifactHandler = handler;
|
|
1615
|
+
};
|
|
1616
|
+
|
|
1617
|
+
// Entries a window interrupted wait for the next flush of this process.
|
|
1618
|
+
let artifactBacklog: ArtifactEntry[] = [];
|
|
1619
|
+
|
|
1620
|
+
const writeManifest = (entries: ArtifactEntry[], upload: ArtifactUploadSummary | null) => {
|
|
1621
|
+
try {
|
|
1622
|
+
const manifest = buildManifest({
|
|
1623
|
+
processId: PROCESS_ID,
|
|
1624
|
+
diag: settings.diag,
|
|
1625
|
+
frameBudgetMs: frameBudget.ms,
|
|
1626
|
+
artifacts: entries,
|
|
1627
|
+
upload,
|
|
1628
|
+
});
|
|
1629
|
+
getNative()?.writeText(manifestName(PROCESS_ID), JSON.stringify(manifest));
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
perfLog(`artifacts manifest failed ${String(error)}`);
|
|
1632
|
+
}
|
|
1633
|
+
};
|
|
1634
|
+
|
|
1635
|
+
// After the lines: the kept files go up, outside any measured window. A
|
|
1636
|
+
// failed upload leaves the files on the device and never fails the flush.
|
|
1637
|
+
const sendArtifacts = async (fresh: ArtifactEntry[], deadline: number) => {
|
|
1638
|
+
const entries = [...artifactBacklog, ...fresh];
|
|
1639
|
+
artifactBacklog = [];
|
|
1640
|
+
if (entries.length === 0) {
|
|
1641
|
+
return null;
|
|
1642
|
+
}
|
|
1643
|
+
writeManifest(entries, null);
|
|
1644
|
+
let summary: ArtifactUploadSummary;
|
|
1645
|
+
if (artifactHandler) {
|
|
1646
|
+
try {
|
|
1647
|
+
summary = await artifactHandler(entries, runToken, PROCESS_ID, deadline);
|
|
1648
|
+
} catch (error) {
|
|
1649
|
+
for (const entry of entries) {
|
|
1650
|
+
if (entry.state === 'pending') {
|
|
1651
|
+
entry.state = 'local';
|
|
1652
|
+
entry.error = String(error);
|
|
1653
|
+
}
|
|
1654
|
+
}
|
|
1655
|
+
summary = summarizeArtifacts(entries, 0, String(error));
|
|
1656
|
+
}
|
|
1657
|
+
} else {
|
|
1658
|
+
for (const entry of entries) {
|
|
1659
|
+
entry.state = 'local';
|
|
1660
|
+
entry.error = 'push not configured';
|
|
1661
|
+
}
|
|
1662
|
+
summary = summarizeArtifacts(entries, 0, 'push not configured');
|
|
1663
|
+
}
|
|
1664
|
+
artifactBacklog = entries.filter(entry => entry.state === 'pending' || entry.state === 'failed');
|
|
1665
|
+
writeManifest(entries, summary);
|
|
1666
|
+
perfLog(`artifacts ${JSON.stringify(summary)}`);
|
|
1667
|
+
return summary;
|
|
1668
|
+
};
|
|
1669
|
+
|
|
1670
|
+
// Declared on every window line so the backend can tell windows of this model from older ones (REQ-VER-34).
|
|
1671
|
+
// 2: a tap or scroll window runs from its touch to the next touch, the background or the flush; no quiet rule, no `+3s` twin.
|
|
1672
|
+
// 3: model 2, and a screen window also ends at the next action, the next screen, the background or the flush; no 3 s cap.
|
|
1673
|
+
export const WINDOW_MODEL = 3;
|
|
1674
|
+
|
|
1675
|
+
const finalizeWindow = (window: MarkerWindow) => {
|
|
1676
|
+
const statsEnd = readHermesStats();
|
|
1677
|
+
// Sampler first: its records carry the display rate the frame budget needs.
|
|
1678
|
+
const sampler = samplerForWindow(window);
|
|
1679
|
+
const profile = window.profile
|
|
1680
|
+
? summarizeWindowProfile(window.profile, { budget: frameBudget })
|
|
1681
|
+
: null;
|
|
1682
|
+
const perf = settings.po ? summarizeEntries(window) : null;
|
|
1683
|
+
noteBudgetBreak(
|
|
1684
|
+
window,
|
|
1685
|
+
profile !== null && brokeFrameBudget(profile),
|
|
1686
|
+
perf ? perf.longtaskMaxMs : null,
|
|
1687
|
+
);
|
|
1688
|
+
const totalRenders = Object.values(window.renders).reduce((s, n) => s + n, 0);
|
|
1689
|
+
const summary = {
|
|
1690
|
+
v: 1,
|
|
1691
|
+
windowModel: WINDOW_MODEL,
|
|
1692
|
+
id: window.id,
|
|
1693
|
+
name: window.name,
|
|
1694
|
+
kind: window.kind,
|
|
1695
|
+
t0: window.t0,
|
|
1696
|
+
t1: window.t1,
|
|
1697
|
+
durMs: window.t1 === null ? null : window.t1 - window.t0,
|
|
1698
|
+
endReason: window.endReason,
|
|
1699
|
+
settings: JSON.parse(window.settingsAtStart) as unknown,
|
|
1700
|
+
points: window.points,
|
|
1701
|
+
react: settings.hook
|
|
1702
|
+
? {
|
|
1703
|
+
commits: window.commits,
|
|
1704
|
+
commitsByRenderer: window.commitsByRenderer,
|
|
1705
|
+
renders: totalRenders,
|
|
1706
|
+
mounts: window.mounts,
|
|
1707
|
+
fibersVisited: window.fibersVisited,
|
|
1708
|
+
backfill: window.backfill,
|
|
1709
|
+
// Commit records the SDK holds when the window ends (recent ring and foreign ones waiting for the flush).
|
|
1710
|
+
ring: { records: recentCommits.length, unclaimed: unclaimed.length },
|
|
1711
|
+
componentsDistinct: Object.keys(window.renders).length,
|
|
1712
|
+
top: topRenders(window.renders, 40),
|
|
1713
|
+
hookCostMs: {
|
|
1714
|
+
n: window.hookCostMs.length,
|
|
1715
|
+
sum: window.hookCostMs.reduce((s, n) => s + n, 0),
|
|
1716
|
+
p50: percentile(window.hookCostMs, 50),
|
|
1717
|
+
p99: percentile(window.hookCostMs, 99),
|
|
1718
|
+
max: percentile(window.hookCostMs, 100),
|
|
1719
|
+
},
|
|
1720
|
+
profile,
|
|
1721
|
+
external: externalSummary(window, profile !== null && profile.renderer === 'profiling'),
|
|
1722
|
+
causes: {
|
|
1723
|
+
awaitThroughThen: causeStats.awaitThroughThen,
|
|
1724
|
+
storesWrapped: causeStats.storesWrapped,
|
|
1725
|
+
anchor: window.anchorAction,
|
|
1726
|
+
// Requests mark carried ticks `timer-net` only while the request counter runs.
|
|
1727
|
+
requestsMarked: window.net,
|
|
1728
|
+
// The commit-stream segment open when the window ended: the backend needs every segment up to it.
|
|
1729
|
+
process: PROCESS_ID,
|
|
1730
|
+
streamSeq: commitStream.seq(),
|
|
1731
|
+
offered: window.offered,
|
|
1732
|
+
},
|
|
1733
|
+
export: writeReactExport(window),
|
|
1734
|
+
}
|
|
1735
|
+
: null,
|
|
1736
|
+
cpuprofile: cpuForWindow(window),
|
|
1737
|
+
perf,
|
|
1738
|
+
[window.kind === 'screen' ? 'screen' : 'interaction']: safeDetails(window),
|
|
1739
|
+
hermesDelta: diffStats(window.statsStart, statsEnd),
|
|
1740
|
+
hermesEnd: statsEnd,
|
|
1741
|
+
heapAfterGc: window.heapAfterGc,
|
|
1742
|
+
memoryStart: window.memoryStart,
|
|
1743
|
+
memoryEnd: readMemory(),
|
|
1744
|
+
retainedLeakMB: leakState.retainedMB,
|
|
1745
|
+
sampler,
|
|
1746
|
+
net: netForWindow(window),
|
|
1747
|
+
buffersBeforeClear: settings.clear ? clearPerformanceBuffers() : null,
|
|
1748
|
+
};
|
|
1749
|
+
writeWindowRecord(window, summary);
|
|
1750
|
+
forwardSamples();
|
|
1751
|
+
};
|
|
1752
|
+
|
|
1753
|
+
const writeWindowRecord = (window: MarkerWindow, summary: Record<string, unknown>) => {
|
|
1754
|
+
const json = JSON.stringify(summary);
|
|
1755
|
+
perfLog(`window ${json}`);
|
|
1756
|
+
windowListener?.(`${Date.now()} PERF_LAB window ${json}`);
|
|
1757
|
+
if (settings.file) {
|
|
1758
|
+
const path = getNative()?.writeText(
|
|
1759
|
+
`window-${window.id}-${window.name.replace(/[^\w.:+-]/g, '_')}.json`,
|
|
1760
|
+
json,
|
|
1761
|
+
);
|
|
1762
|
+
perfLog(`window file id=${window.id} path=${path ?? 'unavailable'}`);
|
|
1763
|
+
}
|
|
1764
|
+
};
|
|
1765
|
+
|
|
1766
|
+
const roundMs = (ms: number) => Math.round(ms * 1000) / 1000;
|
|
1767
|
+
|
|
1768
|
+
const renderSummary = (ms: number[], timed: boolean) =>
|
|
1769
|
+
timed
|
|
1770
|
+
? {
|
|
1771
|
+
sum: roundMs(ms.reduce((s, n) => s + n, 0)),
|
|
1772
|
+
max: roundMs(ms.reduce((m, n) => Math.max(m, n), 0)),
|
|
1773
|
+
n: ms.length,
|
|
1774
|
+
}
|
|
1775
|
+
: null;
|
|
1776
|
+
|
|
1777
|
+
const externalSummary = (window: MarkerWindow, timed: boolean) => {
|
|
1778
|
+
const out: Record<
|
|
1779
|
+
string,
|
|
1780
|
+
{
|
|
1781
|
+
commits: number;
|
|
1782
|
+
renderMs: { sum: number; max: number; n: number } | null;
|
|
1783
|
+
timerCarried: { commits: number; renderMs: { sum: number; max: number; n: number } | null };
|
|
1784
|
+
}
|
|
1785
|
+
> = {};
|
|
1786
|
+
for (const source of Object.keys(window.external).sort()) {
|
|
1787
|
+
const part = window.external[source] as ExternalPart;
|
|
1788
|
+
out[source] = {
|
|
1789
|
+
commits: part.commits,
|
|
1790
|
+
renderMs: renderSummary(part.renderMs, timed),
|
|
1791
|
+
timerCarried: { commits: part.timerCarried.commits, renderMs: renderSummary(part.timerCarried.renderMs, timed) },
|
|
1792
|
+
};
|
|
1793
|
+
}
|
|
1794
|
+
return out;
|
|
1795
|
+
};
|
|
1796
|
+
|
|
1797
|
+
const finalizeBackground = (window: MarkerWindow) => {
|
|
1798
|
+
const profile = window.profile
|
|
1799
|
+
? summarizeWindowProfile(window.profile, { budget: frameBudget })
|
|
1800
|
+
: null;
|
|
1801
|
+
writeWindowRecord(window, {
|
|
1802
|
+
v: 1,
|
|
1803
|
+
windowModel: WINDOW_MODEL,
|
|
1804
|
+
id: window.id,
|
|
1805
|
+
name: window.name,
|
|
1806
|
+
kind: window.kind,
|
|
1807
|
+
t0: window.t0,
|
|
1808
|
+
t1: window.t1,
|
|
1809
|
+
durMs: window.t1 === null ? null : window.t1 - window.t0,
|
|
1810
|
+
endReason: window.endReason,
|
|
1811
|
+
settings: JSON.parse(window.settingsAtStart) as unknown,
|
|
1812
|
+
react: {
|
|
1813
|
+
commits: window.commits,
|
|
1814
|
+
commitsByRenderer: window.commitsByRenderer,
|
|
1815
|
+
renders: Object.values(window.renders).reduce((s, n) => s + n, 0),
|
|
1816
|
+
mounts: window.mounts,
|
|
1817
|
+
fibersVisited: window.fibersVisited,
|
|
1818
|
+
componentsDistinct: Object.keys(window.renders).length,
|
|
1819
|
+
top: topRenders(window.renders, 40),
|
|
1820
|
+
profile,
|
|
1821
|
+
causes: { process: PROCESS_ID, streamSeq: commitStream.seq(), offered: window.offered },
|
|
1822
|
+
export: writeReactExport(window),
|
|
1823
|
+
},
|
|
1824
|
+
});
|
|
1825
|
+
};
|
|
1826
|
+
|
|
1827
|
+
let sink = 0;
|
|
1828
|
+
|
|
1829
|
+
export const burnCpu = (ms: number) => {
|
|
1830
|
+
const t0 = now();
|
|
1831
|
+
let x = sink;
|
|
1832
|
+
while (now() - t0 < ms) {
|
|
1833
|
+
for (let i = 0; i < 1000; i++) {
|
|
1834
|
+
x = (x * 1103515245 + 12345) % 2147483648;
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
sink = x;
|
|
1838
|
+
return now() - t0;
|
|
1839
|
+
};
|
|
1840
|
+
|
|
1841
|
+
const leakState = {
|
|
1842
|
+
retained: [] as unknown[],
|
|
1843
|
+
retainedMB: 0,
|
|
1844
|
+
};
|
|
1845
|
+
|
|
1846
|
+
const MB = 1024 * 1024;
|
|
1847
|
+
|
|
1848
|
+
const leakNative = (mb: number) => {
|
|
1849
|
+
// fill() touches every page, so the allocation shows in RSS/PSS and not only
|
|
1850
|
+
// as reserved address space. ArrayBuffer memory is external to the Hermes heap.
|
|
1851
|
+
leakState.retained.push(new Uint8Array(mb * MB).fill(1));
|
|
1852
|
+
leakState.retainedMB += mb;
|
|
1853
|
+
};
|
|
1854
|
+
|
|
1855
|
+
const leakJsHeap = (mb: number) => {
|
|
1856
|
+
// ~8 bytes per element in the Hermes GC heap.
|
|
1857
|
+
leakState.retained.push(
|
|
1858
|
+
new Array(Math.round((mb * MB) / 8)).fill(Math.random()),
|
|
1859
|
+
);
|
|
1860
|
+
leakState.retainedMB += mb;
|
|
1861
|
+
};
|
|
1862
|
+
|
|
1863
|
+
export const isFaultTarget = (windowName: string) =>
|
|
1864
|
+
settings.faultTarget
|
|
1865
|
+
.split(',')
|
|
1866
|
+
.map(part => part.trim())
|
|
1867
|
+
.some(part => part !== '' && windowName.includes(part));
|
|
1868
|
+
|
|
1869
|
+
export const runOpenFaults = (where: string) => {
|
|
1870
|
+
const faults = settings.faults;
|
|
1871
|
+
if (faults.cpu > 0) {
|
|
1872
|
+
const actual = burnCpu(faults.cpu);
|
|
1873
|
+
perfLog(
|
|
1874
|
+
`fault cpu where=${where} ms=${faults.cpu} actual=${fmt(actual)} t=${fmt(now())}`,
|
|
1875
|
+
);
|
|
1876
|
+
}
|
|
1877
|
+
if (faults.leak > 0) {
|
|
1878
|
+
leakNative(faults.leak);
|
|
1879
|
+
perfLog(
|
|
1880
|
+
`fault leak where=${where} mb=${faults.leak} retainedMB=${leakState.retainedMB}`,
|
|
1881
|
+
);
|
|
1882
|
+
}
|
|
1883
|
+
if (faults.leakjs > 0) {
|
|
1884
|
+
leakJsHeap(faults.leakjs);
|
|
1885
|
+
perfLog(
|
|
1886
|
+
`fault leakjs where=${where} mb=${faults.leakjs} retainedMB=${leakState.retainedMB}`,
|
|
1887
|
+
);
|
|
1888
|
+
}
|
|
1889
|
+
if (faults.rerender > 0) {
|
|
1890
|
+
// The prototype re-rendered a probe component placed in the chat sheet;
|
|
1891
|
+
// with no app code there is no component to re-render.
|
|
1892
|
+
perfLog(`fault rerender unsupported where=${where}`);
|
|
1893
|
+
}
|
|
1894
|
+
};
|
|
1895
|
+
|
|
1896
|
+
export const runDeferredFault = (where: string) => {
|
|
1897
|
+
const ms = settings.faults.defer;
|
|
1898
|
+
if (ms <= 0) {
|
|
1899
|
+
return;
|
|
1900
|
+
}
|
|
1901
|
+
setTimeout(() => {
|
|
1902
|
+
point('fault.defer.start');
|
|
1903
|
+
const actual = burnCpu(ms);
|
|
1904
|
+
point('fault.defer.end');
|
|
1905
|
+
perfLog(`fault defer where=${where} ms=${ms} actual=${fmt(actual)}`);
|
|
1906
|
+
}, 0);
|
|
1907
|
+
};
|
|
1908
|
+
|
|
1909
|
+
const parseFaults = (spec: string): Faults => {
|
|
1910
|
+
const faults: Faults = { ...NO_FAULTS };
|
|
1911
|
+
if (spec === '' || spec === 'none' || spec === '0') {
|
|
1912
|
+
return faults;
|
|
1913
|
+
}
|
|
1914
|
+
for (const part of spec.split(',')) {
|
|
1915
|
+
const [key = '', value = '1'] = part.split(':');
|
|
1916
|
+
if (key in faults) {
|
|
1917
|
+
const parsed = Number(value);
|
|
1918
|
+
faults[key as keyof Faults] =
|
|
1919
|
+
Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
|
|
1920
|
+
} else {
|
|
1921
|
+
perfLog(`unknown fault ${key}`);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return faults;
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
const flag = (value: string | undefined, current: boolean) =>
|
|
1928
|
+
value === undefined ? current : value === '1' || value === 'true';
|
|
1929
|
+
|
|
1930
|
+
type WindowListener = (line: string) => void;
|
|
1931
|
+
let windowListener: WindowListener | null = null;
|
|
1932
|
+
|
|
1933
|
+
// Native clock of the last sampler record handed to the listener; records at
|
|
1934
|
+
// or before it are never sent again.
|
|
1935
|
+
let samplesSentT = -1;
|
|
1936
|
+
|
|
1937
|
+
// Inside a run the sampler's records go out with the window lines (after a
|
|
1938
|
+
// window or at flush, never inside one), so the server derives the run-level
|
|
1939
|
+
// sampler.* metrics the CLI reads from the log. The native ring keeps the
|
|
1940
|
+
// last 64 records, so a gap without windows longer than that loses the oldest.
|
|
1941
|
+
const forwardSamples = () => {
|
|
1942
|
+
const native = getNative();
|
|
1943
|
+
if (!windowListener || !inRun() || samplerState.mode === 'off' || !native) {
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
try {
|
|
1947
|
+
for (const record of native.samplerRecent(Math.max(0, samplesSentT))) {
|
|
1948
|
+
if (record.t <= samplesSentT) {
|
|
1949
|
+
continue;
|
|
1950
|
+
}
|
|
1951
|
+
windowListener(`${record.wallMs} PERF_LAB sample ${JSON.stringify(record)}`);
|
|
1952
|
+
samplesSentT = record.t;
|
|
1953
|
+
}
|
|
1954
|
+
} catch (error) {
|
|
1955
|
+
samplerState.error = String(error);
|
|
1956
|
+
}
|
|
1957
|
+
};
|
|
1958
|
+
|
|
1959
|
+
export const setWindowListener = (listener: WindowListener | null) => {
|
|
1960
|
+
windowListener = listener;
|
|
1961
|
+
};
|
|
1962
|
+
|
|
1963
|
+
// Keyed by the run token and n: the driver numbers its commands from 1 in every session, so a number alone repeats across
|
|
1964
|
+
// runs of one process. Emptied at every flush, so a run never takes an answer of the run before it.
|
|
1965
|
+
const commandAnswers = new Map<string, string>();
|
|
1966
|
+
const COMMAND_ANSWERS_KEPT = 64;
|
|
1967
|
+
let gcRanSinceFlush = false;
|
|
1968
|
+
|
|
1969
|
+
// A run event the backend derives from (the GC command, an AppState change): logged, and inside a run sent with the window lines.
|
|
1970
|
+
// AppState changes go out only once a GC command ran since the last flush, so a run without GC uploads exactly what it did
|
|
1971
|
+
// before, and the next run's activation link (a pause on Android) never lands in the run before it.
|
|
1972
|
+
export const writeRunEvent = (kind: 'gc' | 'appstate', record: Record<string, unknown>) => {
|
|
1973
|
+
const json = JSON.stringify({ ...record, t: now(), wallMs: Date.now() });
|
|
1974
|
+
perfLog(`${kind} ${json}`);
|
|
1975
|
+
if (inRun() && (kind === 'gc' || gcRanSinceFlush)) {
|
|
1976
|
+
windowListener?.(`${Date.now()} PERF_LAB ${kind} ${json}`);
|
|
1977
|
+
}
|
|
1978
|
+
};
|
|
1979
|
+
|
|
1980
|
+
// Kept out of `settings` because settings are logged. One per process in a
|
|
1981
|
+
// dormant build: set from the activation that started it, links only change
|
|
1982
|
+
// the next launch's.
|
|
1983
|
+
let runToken: string | null = null;
|
|
1984
|
+
|
|
1985
|
+
export const getRunToken = () => runToken;
|
|
1986
|
+
|
|
1987
|
+
let activation: { decision: Decision; store: ActivationStore } | null = null;
|
|
1988
|
+
|
|
1989
|
+
type FlushResult = { ok: boolean; batches: number; lines: number; error: string | null };
|
|
1990
|
+
type FlushHandler = (deadline: number) => Promise<FlushResult>;
|
|
1991
|
+
let flushHandler: FlushHandler | null = null;
|
|
1992
|
+
|
|
1993
|
+
export const setFlushHandler = (handler: FlushHandler | null) => {
|
|
1994
|
+
flushHandler = handler;
|
|
1995
|
+
};
|
|
1996
|
+
|
|
1997
|
+
// `flush=1`: end the open windows, send every unsent line, then show the result
|
|
1998
|
+
// to the test driver as an accessibility element (iOS: id `perflab.flushed`,
|
|
1999
|
+
// label `ok` / `error` / `off`), so a flow can wait for it without network access.
|
|
2000
|
+
const flushOnce = async () => {
|
|
2001
|
+
// One deadline for the whole flush (lines, clock, artifacts); the end report of the upload comes after it.
|
|
2002
|
+
const deadline = Date.now() + ARTIFACT_UPLOAD_DEADLINE_MS;
|
|
2003
|
+
endAll('flush');
|
|
2004
|
+
forwardSamples();
|
|
2005
|
+
let artifacts: ArtifactEntry[] = [];
|
|
2006
|
+
try {
|
|
2007
|
+
artifacts = writeDeferredFiles();
|
|
2008
|
+
} catch (error) {
|
|
2009
|
+
perfLog(`flush files failed ${String(error)}`);
|
|
2010
|
+
}
|
|
2011
|
+
let status = 'off';
|
|
2012
|
+
let detail = 'push not configured';
|
|
2013
|
+
if (flushHandler) {
|
|
2014
|
+
try {
|
|
2015
|
+
const result = await flushHandler(deadline);
|
|
2016
|
+
status = result.ok ? 'ok' : 'error';
|
|
2017
|
+
detail = result.ok
|
|
2018
|
+
? `batches=${result.batches} lines=${result.lines}`
|
|
2019
|
+
: String(result.error);
|
|
2020
|
+
} catch (error) {
|
|
2021
|
+
status = 'error';
|
|
2022
|
+
detail = String(error);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
try {
|
|
2026
|
+
const sent = await sendArtifacts(artifacts, deadline);
|
|
2027
|
+
if (sent) {
|
|
2028
|
+
detail += ` artifacts=${sent.uploaded}/${sent.total}`;
|
|
2029
|
+
}
|
|
2030
|
+
} catch (error) {
|
|
2031
|
+
perfLog(`artifacts failed ${String(error)}`);
|
|
2032
|
+
}
|
|
2033
|
+
perfLog(`flush ${status} ${detail}`);
|
|
2034
|
+
try {
|
|
2035
|
+
getNative()?.flushSignal?.(status, detail);
|
|
2036
|
+
} catch (error) {
|
|
2037
|
+
perfLog(`flush signal failed ${String(error)}`);
|
|
2038
|
+
}
|
|
2039
|
+
};
|
|
2040
|
+
|
|
2041
|
+
// A second flush link while one runs (the driver's final flush) waits for it: the flush signal means the
|
|
2042
|
+
// uploads of the first are done, so it must not be raised by a flush that has nothing left to send.
|
|
2043
|
+
let flushing: Promise<void> | null = null;
|
|
2044
|
+
export const flushNow = (): Promise<void> => {
|
|
2045
|
+
commandAnswers.clear();
|
|
2046
|
+
gcRanSinceFlush = false;
|
|
2047
|
+
// The first flush starts at once (it ends the open windows in the same tick as the link).
|
|
2048
|
+
const run = flushing === null ? flushOnce() : flushing.then(flushOnce);
|
|
2049
|
+
const tail: Promise<void> = run
|
|
2050
|
+
.catch(() => undefined)
|
|
2051
|
+
.then(() => {
|
|
2052
|
+
if (flushing === tail) {
|
|
2053
|
+
flushing = null;
|
|
2054
|
+
}
|
|
2055
|
+
});
|
|
2056
|
+
flushing = tail;
|
|
2057
|
+
return run;
|
|
2058
|
+
};
|
|
2059
|
+
|
|
2060
|
+
// The activation's profile/cpuprof turn those on for the whole process; off
|
|
2061
|
+
// leaves the build's env defaults.
|
|
2062
|
+
// A PerfLab run (one with a run token) forces an idle GC between windows so heap-after-GC
|
|
2063
|
+
// readings exist for the leak slope (plan 41 Q7); `gcwin=0` in a link still turns it off.
|
|
2064
|
+
const applyDecision = (decision: Decision) => {
|
|
2065
|
+
settings.diag = decision.diag;
|
|
2066
|
+
if (decision.runToken !== null) {
|
|
2067
|
+
settings.gcwin = true;
|
|
2068
|
+
}
|
|
2069
|
+
if (decision.profile) {
|
|
2070
|
+
settings.profile = true;
|
|
2071
|
+
}
|
|
2072
|
+
if (decision.cpuprof > 0) {
|
|
2073
|
+
settings.cpuprof = true;
|
|
2074
|
+
settings.cpuhz = decision.cpuprof;
|
|
2075
|
+
}
|
|
2076
|
+
};
|
|
2077
|
+
|
|
2078
|
+
export const setActivation = (decision: Decision, store: ActivationStore) => {
|
|
2079
|
+
activation = { decision, store };
|
|
2080
|
+
runToken = decision.runToken;
|
|
2081
|
+
applyDecision(decision);
|
|
2082
|
+
scheduleIdleGc();
|
|
2083
|
+
syncCommandListener();
|
|
2084
|
+
};
|
|
2085
|
+
|
|
2086
|
+
const activationReport = () => {
|
|
2087
|
+
if (!activation) {
|
|
2088
|
+
return { mode: 'active', buildMode: MODE, read: 'none' };
|
|
2089
|
+
}
|
|
2090
|
+
const { decision, store } = activation;
|
|
2091
|
+
const next = store.current();
|
|
2092
|
+
return {
|
|
2093
|
+
mode: 'active',
|
|
2094
|
+
buildMode: decision.mode,
|
|
2095
|
+
read: decision.read,
|
|
2096
|
+
installMs: g.__PERFLAB_ACTIVATION__?.installMs,
|
|
2097
|
+
runToken: runToken !== null,
|
|
2098
|
+
expiresAt: decision.expiresAt,
|
|
2099
|
+
diag: decision.diag,
|
|
2100
|
+
profile: decision.profile,
|
|
2101
|
+
cpuprof: decision.cpuprof,
|
|
2102
|
+
renderer: g.__PERFLAB_ACTIVATION__?.renderer ?? null,
|
|
2103
|
+
next: next
|
|
2104
|
+
? {
|
|
2105
|
+
active: next.active,
|
|
2106
|
+
runToken: next.runToken !== null,
|
|
2107
|
+
expiresAt: next.expiresAt,
|
|
2108
|
+
diag: next.diag,
|
|
2109
|
+
profile: next.profile,
|
|
2110
|
+
cpuprof: next.cpuprof,
|
|
2111
|
+
}
|
|
2112
|
+
: null,
|
|
2113
|
+
...store.stats(),
|
|
2114
|
+
};
|
|
2115
|
+
};
|
|
2116
|
+
|
|
2117
|
+
export const applySettings = (params: Record<string, string>) => {
|
|
2118
|
+
if (params.reset === '1') {
|
|
2119
|
+
Object.assign(settings, defaultSettings());
|
|
2120
|
+
if (activation) {
|
|
2121
|
+
applyDecision(activation.decision);
|
|
2122
|
+
}
|
|
2123
|
+
leakState.retained = [];
|
|
2124
|
+
leakState.retainedMB = 0;
|
|
2125
|
+
}
|
|
2126
|
+
// A dormant build takes both from the activation it started with; a link
|
|
2127
|
+
// there only changes the next launch's.
|
|
2128
|
+
if (params.runToken !== undefined && MODE !== 'dormant') {
|
|
2129
|
+
runToken = params.runToken === '' ? null : params.runToken;
|
|
2130
|
+
samplesSentT = nativeSamplerNow();
|
|
2131
|
+
syncCommandListener();
|
|
2132
|
+
if (runToken !== null) {
|
|
2133
|
+
settings.gcwin = true;
|
|
2134
|
+
}
|
|
2135
|
+
}
|
|
2136
|
+
if (params.diag !== undefined && MODE !== 'dormant') {
|
|
2137
|
+
settings.diag = flag(params.diag, settings.diag);
|
|
2138
|
+
}
|
|
2139
|
+
settings.marks = flag(params.marks, settings.marks);
|
|
2140
|
+
settings.hook = flag(params.hook, settings.hook);
|
|
2141
|
+
settings.po = flag(params.po, settings.po);
|
|
2142
|
+
settings.file = flag(params.file, settings.file);
|
|
2143
|
+
settings.clear = flag(params.clear, settings.clear);
|
|
2144
|
+
settings.gcwin = flag(params.gcwin, settings.gcwin);
|
|
2145
|
+
scheduleIdleGc();
|
|
2146
|
+
settings.net = flag(params.net, settings.net);
|
|
2147
|
+
settings.rexport = flag(params.rexport, settings.rexport);
|
|
2148
|
+
// Dormant: from the activation only, so the settings match the renderer the
|
|
2149
|
+
// process loaded; these link params go to the next launch's record.
|
|
2150
|
+
if (MODE !== 'dormant') {
|
|
2151
|
+
settings.profile = flag(params.profile, settings.profile);
|
|
2152
|
+
settings.cpuprof = flag(params.cpuprof, settings.cpuprof);
|
|
2153
|
+
if (params.cpuhz !== undefined && Number(params.cpuhz) > 0) {
|
|
2154
|
+
settings.cpuhz = Number(params.cpuhz);
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
cpu.configure(settings.cpuprof, settings.cpuhz);
|
|
2158
|
+
if (
|
|
2159
|
+
params.log === 'console' ||
|
|
2160
|
+
params.log === 'native' ||
|
|
2161
|
+
params.log === 'both'
|
|
2162
|
+
) {
|
|
2163
|
+
settings.log = params.log;
|
|
2164
|
+
}
|
|
2165
|
+
if (params.fault !== undefined) {
|
|
2166
|
+
settings.faults = parseFaults(params.fault);
|
|
2167
|
+
}
|
|
2168
|
+
if (params.target !== undefined) {
|
|
2169
|
+
settings.faultTarget = params.target;
|
|
2170
|
+
}
|
|
2171
|
+
Object.assign(settings, applySamplerParams(settings, params));
|
|
2172
|
+
syncSampler();
|
|
2173
|
+
if (params.sample === '1') {
|
|
2174
|
+
const record = sample('link');
|
|
2175
|
+
if (!record) {
|
|
2176
|
+
perfLog(`sample skipped sampler=${settings.sampler}`);
|
|
2177
|
+
}
|
|
2178
|
+
}
|
|
2179
|
+
if (settings.po) {
|
|
2180
|
+
startObservers();
|
|
2181
|
+
} else {
|
|
2182
|
+
stopObservers();
|
|
2183
|
+
}
|
|
2184
|
+
if (params.gc === '1') {
|
|
2185
|
+
const before = readHermesStats();
|
|
2186
|
+
g.gc?.();
|
|
2187
|
+
perfLog(
|
|
2188
|
+
`gc forced before=${JSON.stringify(before)} after=${JSON.stringify(readHermesStats())}`,
|
|
2189
|
+
);
|
|
2190
|
+
}
|
|
2191
|
+
perfLog(
|
|
2192
|
+
`settings ${JSON.stringify(settings)} hookCommits=${hookState.totalCommits} hookCostMs=${fmt(hookState.totalCostMs)} shortNames=${nameStats.short}/${nameStats.total}`,
|
|
2193
|
+
);
|
|
2194
|
+
if (params.dump === '1') {
|
|
2195
|
+
logStartup('dump');
|
|
2196
|
+
}
|
|
2197
|
+
};
|
|
2198
|
+
|
|
2199
|
+
// GC before the action (docs/plans/gc-before-action.md): the driver's command n, sent before a declared step's start marker when
|
|
2200
|
+
// the run asks for it. It ends the open windows (the command is the test's next action), samples, runs a full GC, reads the heap,
|
|
2201
|
+
// samples again and answers. The answer is the signal the driver waits for; no time decides anything.
|
|
2202
|
+
|
|
2203
|
+
const answerCommand = (n: number, text: string) => {
|
|
2204
|
+
const native = getNative();
|
|
2205
|
+
try {
|
|
2206
|
+
native?.commandDone?.(n, text);
|
|
2207
|
+
native?.ackSignal?.(text);
|
|
2208
|
+
} catch (error) {
|
|
2209
|
+
perfLog(`command answer failed ${String(error)}`);
|
|
2210
|
+
}
|
|
2211
|
+
};
|
|
2212
|
+
|
|
2213
|
+
export const actionGc = (n: number, tNative?: number): string => {
|
|
2214
|
+
const key = `${runToken ?? '-'}#${n}`;
|
|
2215
|
+
const known = commandAnswers.get(key);
|
|
2216
|
+
if (known !== undefined) {
|
|
2217
|
+
answerCommand(n, known);
|
|
2218
|
+
return known;
|
|
2219
|
+
}
|
|
2220
|
+
let answer: string;
|
|
2221
|
+
// Without a GC the command changes nothing: no window ends, no sample is taken, and the driver turns the command off.
|
|
2222
|
+
if (typeof g.gc !== 'function') {
|
|
2223
|
+
answer = `gc:${n} unavailable`;
|
|
2224
|
+
writeRunEvent('gc', { n, status: 'unavailable' });
|
|
2225
|
+
} else {
|
|
2226
|
+
const queueMs = tNative === undefined ? null : (getNative()?.samplerNow() ?? tNative) - tNative;
|
|
2227
|
+
const closed = [...openWindows.values()].map(w => w.id);
|
|
2228
|
+
endAll('gc-command');
|
|
2229
|
+
const pre = sample(`gc-pre:${n}`);
|
|
2230
|
+
const heapBefore = readHermesStats();
|
|
2231
|
+
const t0 = now();
|
|
2232
|
+
g.gc();
|
|
2233
|
+
const gcMs = now() - t0;
|
|
2234
|
+
const heapAfter = readHermesStats();
|
|
2235
|
+
const post = sample(`gc-post:${n}`);
|
|
2236
|
+
// The idle probe end() just scheduled would GC again: this reading is the heap after GC the next window takes.
|
|
2237
|
+
clearIdleGc();
|
|
2238
|
+
idleHeap = heapAfter;
|
|
2239
|
+
answer = `gc:${n} ok`;
|
|
2240
|
+
gcRanSinceFlush = true;
|
|
2241
|
+
writeRunEvent('gc', { n, status: 'ok', queueMs, gcMs, pre: pre?.seq ?? null, post: post?.seq ?? null, heapBefore, heapAfter, closed });
|
|
2242
|
+
}
|
|
2243
|
+
// The gc-pre / gc-post records go out now: the backend pairs them with this command by their seq.
|
|
2244
|
+
forwardSamples();
|
|
2245
|
+
commandAnswers.set(key, answer);
|
|
2246
|
+
if (commandAnswers.size > COMMAND_ANSWERS_KEPT) {
|
|
2247
|
+
commandAnswers.delete(commandAnswers.keys().next().value as string);
|
|
2248
|
+
}
|
|
2249
|
+
answerCommand(n, answer);
|
|
2250
|
+
return answer;
|
|
2251
|
+
};
|
|
2252
|
+
|
|
2253
|
+
let commandSubscription: { remove(): void } | null = null;
|
|
2254
|
+
|
|
2255
|
+
// Android's receiver exists only while a run with a token measures; a dormant or tokenless process registers nothing.
|
|
2256
|
+
const syncCommandListener = () => {
|
|
2257
|
+
const native = getNative();
|
|
2258
|
+
if (!native?.commandListen) {
|
|
2259
|
+
return;
|
|
2260
|
+
}
|
|
2261
|
+
const on = PERF_LAB_ENABLED && runToken !== null;
|
|
2262
|
+
if (on && commandSubscription === null) {
|
|
2263
|
+
commandSubscription =
|
|
2264
|
+
native.addListener?.('perflabCommand', e => {
|
|
2265
|
+
// A command native already timed out on is not run: its GC would land in the next step's action.
|
|
2266
|
+
if (native.commandPending?.(e.n) === false) {
|
|
2267
|
+
perfLog(`command gc:${e.n} late, skipped`);
|
|
2268
|
+
return;
|
|
2269
|
+
}
|
|
2270
|
+
handlePerfLabCommand(e.cmd, e.n, e.tNative);
|
|
2271
|
+
}) ?? null;
|
|
2272
|
+
}
|
|
2273
|
+
native.commandListen(on);
|
|
2274
|
+
};
|
|
2275
|
+
|
|
2276
|
+
/** A command (`cmd=<name>&n=<n>`), from a link (iOS) or the native receiver (Android); unknown commands are ignored. */
|
|
2277
|
+
export const handlePerfLabCommand = (cmd: string | undefined, n: number, tNative?: number): boolean => {
|
|
2278
|
+
if (!PERF_LAB_ENABLED || cmd !== 'gc' || !Number.isInteger(n) || n < 1) {
|
|
2279
|
+
return false;
|
|
2280
|
+
}
|
|
2281
|
+
// Every received command is answered, so the Android receiver is never left pending.
|
|
2282
|
+
try {
|
|
2283
|
+
actionGc(n, tNative);
|
|
2284
|
+
} catch (error) {
|
|
2285
|
+
perfLog(`command gc:${n} failed ${String(error)}`);
|
|
2286
|
+
answerCommand(n, `gc:${n} error`);
|
|
2287
|
+
}
|
|
2288
|
+
return true;
|
|
2289
|
+
};
|
|
2290
|
+
|
|
2291
|
+
export const handlePerfLabDeepLink = (url: string) => {
|
|
2292
|
+
if (!PERF_LAB_ENABLED) {
|
|
2293
|
+
return;
|
|
2294
|
+
}
|
|
2295
|
+
const params = parseQuery(url.split('?')[1]);
|
|
2296
|
+
// A command changes no settings and writes no activation record.
|
|
2297
|
+
if (params.cmd !== undefined) {
|
|
2298
|
+
handlePerfLabCommand(params.cmd, Number(params.n));
|
|
2299
|
+
return;
|
|
2300
|
+
}
|
|
2301
|
+
const written = activation?.store.apply(params) ?? null;
|
|
2302
|
+
if (written) {
|
|
2303
|
+
perfLog(
|
|
2304
|
+
`activation next-launch active=${written.active} runToken=${written.runToken !== null} expiresAt=${written.expiresAt}`,
|
|
2305
|
+
);
|
|
2306
|
+
}
|
|
2307
|
+
applySettings(params);
|
|
2308
|
+
if (params.flush === '1') {
|
|
2309
|
+
void flushNow();
|
|
2310
|
+
}
|
|
2311
|
+
};
|
|
2312
|
+
|
|
2313
|
+
const safe = <T>(read: () => T): T | string => {
|
|
2314
|
+
try {
|
|
2315
|
+
return read();
|
|
2316
|
+
} catch (error) {
|
|
2317
|
+
return `error: ${String(error)}`;
|
|
2318
|
+
}
|
|
2319
|
+
};
|
|
2320
|
+
|
|
2321
|
+
const extraStartupSections = new Map<string, () => unknown>();
|
|
2322
|
+
|
|
2323
|
+
export const addStartupSection = (name: string, read: () => unknown) => {
|
|
2324
|
+
extraStartupSections.set(name, read);
|
|
2325
|
+
};
|
|
2326
|
+
|
|
2327
|
+
export const logStartup = (phase: string) => {
|
|
2328
|
+
const native = getNative();
|
|
2329
|
+
const polyfill = g.__PERFLAB_HOOK__?.state;
|
|
2330
|
+
const extra: Record<string, unknown> = {};
|
|
2331
|
+
for (const [name, read] of extraStartupSections) {
|
|
2332
|
+
extra[name] = safe(read);
|
|
2333
|
+
}
|
|
2334
|
+
const report = {
|
|
2335
|
+
phase,
|
|
2336
|
+
t: now(),
|
|
2337
|
+
processId: PROCESS_ID,
|
|
2338
|
+
runtimeProperties: safe(
|
|
2339
|
+
() => g.HermesInternal?.getRuntimeProperties?.() ?? null,
|
|
2340
|
+
),
|
|
2341
|
+
instrumentedStats: safe(
|
|
2342
|
+
() => g.HermesInternal?.getInstrumentedStats?.() ?? null,
|
|
2343
|
+
),
|
|
2344
|
+
supportedEntryTypes: safe(
|
|
2345
|
+
() => g.PerformanceObserver?.supportedEntryTypes ?? null,
|
|
2346
|
+
),
|
|
2347
|
+
typeofGc: typeof g.gc,
|
|
2348
|
+
typeofConsoleTimeStamp: typeof g.console.timeStamp,
|
|
2349
|
+
performanceMemory: safe(readMemory),
|
|
2350
|
+
hook: {
|
|
2351
|
+
installed: hookState.installed,
|
|
2352
|
+
source: hookState.source,
|
|
2353
|
+
preexisting: hookState.preexisting,
|
|
2354
|
+
injected: hookState.injected,
|
|
2355
|
+
totalCommits: hookState.totalCommits,
|
|
2356
|
+
totalCostMs: hookState.totalCostMs,
|
|
2357
|
+
outsideWindowCostMs: hookState.outsideWindowCostMs,
|
|
2358
|
+
profilingRenderer: hookState.profilingRenderer,
|
|
2359
|
+
polyfill: polyfill
|
|
2360
|
+
? {
|
|
2361
|
+
installedAt: polyfill.installedAt,
|
|
2362
|
+
typeofHookAtPolyfill: polyfill.typeofHookAtPolyfill,
|
|
2363
|
+
dev: polyfill.dev,
|
|
2364
|
+
mode: polyfill.mode,
|
|
2365
|
+
chainedTargets: polyfill.chainedTargets,
|
|
2366
|
+
replacedAfterInstall: polyfill.replacedAfterInstall,
|
|
2367
|
+
injections: polyfill.injections,
|
|
2368
|
+
commitsByRenderer: polyfill.commitsByRenderer,
|
|
2369
|
+
errors: polyfill.errors,
|
|
2370
|
+
invalid: polyfill.invalid,
|
|
2371
|
+
}
|
|
2372
|
+
: null,
|
|
2373
|
+
},
|
|
2374
|
+
activation: activationReport(),
|
|
2375
|
+
registries: registrySizes(),
|
|
2376
|
+
sampler: samplerStatus(),
|
|
2377
|
+
...extra,
|
|
2378
|
+
native: native !== null,
|
|
2379
|
+
tracing: native ? safe(() => native.isTracing()) : null,
|
|
2380
|
+
clock: native
|
|
2381
|
+
? safe(() => {
|
|
2382
|
+
const before = now();
|
|
2383
|
+
const snapshot = native.clockSnapshot();
|
|
2384
|
+
return { perfNowBefore: before, ...snapshot, perfNowAfter: now() };
|
|
2385
|
+
})
|
|
2386
|
+
: null,
|
|
2387
|
+
};
|
|
2388
|
+
// Startup goes to both sinks so day 1 learns whether console.log reaches
|
|
2389
|
+
// logcat in a release build at all.
|
|
2390
|
+
perfLog(`startup ${JSON.stringify(report)}`, native ? 'both' : 'console');
|
|
2391
|
+
};
|
|
2392
|
+
|
|
2393
|
+
export const exposeGlobal = () => {
|
|
2394
|
+
g.__PERF_LAB__ = {
|
|
2395
|
+
settings,
|
|
2396
|
+
set: applySettings,
|
|
2397
|
+
markers,
|
|
2398
|
+
sample,
|
|
2399
|
+
// App lines in the same native log as the SDK's (log.txt / logcat), whatever the sink.
|
|
2400
|
+
log: (message: string) => perfLog(`app ${message}`, 'both'),
|
|
2401
|
+
dump: () => logStartup('global'),
|
|
2402
|
+
};
|
|
2403
|
+
};
|