@g9tm/app-pilot 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +16 -0
- package/package.json +58 -4
- package/src/args.ts +26 -0
- package/src/blackbox.ts +554 -0
- package/src/config.ts +182 -0
- package/src/design.ts +120 -0
- package/src/dispatch.ts +111 -0
- package/src/errors.ts +59 -0
- package/src/events.ts +86 -0
- package/src/har.ts +148 -0
- package/src/idle.ts +125 -0
- package/src/index.ts +105 -0
- package/src/lease.ts +145 -0
- package/src/oracles.ts +384 -0
- package/src/recorder.ts +382 -0
- package/src/registry.ts +98 -0
- package/src/scopes.ts +30 -0
- package/src/stage.ts +314 -0
- package/src/vitals.ts +196 -0
- package/README.md +0 -3
package/src/stage.ts
ADDED
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
// Setting the scene from the controller (`stage` scope): the network the app's own fetch sees, the clock, randomness and
|
|
2
|
+
// flags — so a flow can say «the network drops in the middle of a send», «the token is a day old», «the same random
|
|
3
|
+
// order every time» instead of hoping for it.
|
|
4
|
+
//
|
|
5
|
+
// installStage(); // the commands below
|
|
6
|
+
// const shape = shapeRequest('POST', url); // in the app's fetch wrapper: { delayMs, offline, status } or null
|
|
7
|
+
// pilotFlag('reduceMotion') // what a test set with flags.set
|
|
8
|
+
//
|
|
9
|
+
// net.shape {rules: [{match: '/api/Placement', mode: 'offline', times: 1}]} the next matching request fails offline
|
|
10
|
+
// net.shape {rules: [{mode: 'delay', delayMs: 3000}]} every request 3 s slower
|
|
11
|
+
// net.shape {rules: [{match: '/api/', method: 'POST', mode: 'fail', status: 503}]}
|
|
12
|
+
// net.shape {rules: [{kind: 'socket', match: '/hub', mode: 'drop'}]} the open hub connection drops NOW
|
|
13
|
+
// net.shape {rules: [{kind: 'socket', match: '/hub', mode: 'offline', times: 3}]} open ones drop, the next 3 connects fail
|
|
14
|
+
// (socket rules reach a hub connection through the app's transport: shapeSocket(url) / onSocketDrop(listener))
|
|
15
|
+
// clock.set {offsetMs: 86400000} | {at: '2026-12-01T08:00:00Z'} · clock.reset · clock.status
|
|
16
|
+
// state.seed {seed: 42} | {off: true} Math.random from a fixed seed (mulberry32)
|
|
17
|
+
// flags.set {name, value} · flags.get
|
|
18
|
+
//
|
|
19
|
+
// Everything is the PAGE's: requests the host makes natively (a sync hub's socket, map tiles) are not shaped — the
|
|
20
|
+
// controller's `g9pilot os net` drops the device's whole network for those. Debug builds only, like every pilot module.
|
|
21
|
+
import { optionalInt, optionalString, requireString } from './args.ts';
|
|
22
|
+
import { emit } from './events.ts';
|
|
23
|
+
import { PilotError } from './errors.ts';
|
|
24
|
+
import { harAnswer, installHar, resetHarForTests } from './har.ts';
|
|
25
|
+
import { registerCommand } from './registry.ts';
|
|
26
|
+
|
|
27
|
+
// ---- the network ---------------------------------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
export type ShapeMode = 'offline' | 'fail' | 'delay' | 'drop';
|
|
30
|
+
|
|
31
|
+
export interface ShapeRule {
|
|
32
|
+
/** `request` (default): the page's fetch. `socket`: a hub connection (its socket and its own HTTP) — research R3. */
|
|
33
|
+
readonly kind?: 'request' | 'socket';
|
|
34
|
+
/** A part of the address (case-insensitive); none = every request. */
|
|
35
|
+
readonly match?: string;
|
|
36
|
+
readonly method?: string;
|
|
37
|
+
readonly mode: ShapeMode;
|
|
38
|
+
/** `fail`: the status the app gets (default 503). */
|
|
39
|
+
readonly status?: number;
|
|
40
|
+
/** `delay` (and before an offline/fail answer): how long the request waits first. */
|
|
41
|
+
readonly delayMs?: number;
|
|
42
|
+
/** How many requests it applies to (none = until cleared). */
|
|
43
|
+
times?: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface Shape {
|
|
47
|
+
readonly delayMs: number;
|
|
48
|
+
readonly offline: boolean;
|
|
49
|
+
readonly status: number | null;
|
|
50
|
+
/** A recorded answer to give instead of going to the network (net.har replay). */
|
|
51
|
+
readonly replay?: { readonly status: number; readonly mimeType: string; readonly text: string };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
let rules: ShapeRule[] = [];
|
|
55
|
+
let shaped = 0;
|
|
56
|
+
const socketDroppers = new Set<(match: string | undefined) => number>();
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The app's transport registers how to drop its open connections (e.g. a hub client's `dropSockets(match)`); a
|
|
60
|
+
* `{kind: 'socket', mode: 'drop'}` rule calls it at once. Returns the unregister function.
|
|
61
|
+
*/
|
|
62
|
+
export function onSocketDrop(dropper: (match: string | undefined) => number): () => void {
|
|
63
|
+
socketDroppers.add(dropper);
|
|
64
|
+
return () => {
|
|
65
|
+
socketDroppers.delete(dropper);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What the app's transport asks before a hub connection (its socket, or its own HTTP: negotiate, long polling) goes out:
|
|
71
|
+
* `{offline: true}` = fail it as with no network. A rule with `times` counts connection attempts. null = untouched.
|
|
72
|
+
*/
|
|
73
|
+
export function shapeSocket(url: string): { readonly offline: boolean } | null {
|
|
74
|
+
const u = url.toLowerCase();
|
|
75
|
+
const i = rules.findIndex((r) => r.kind === 'socket' && r.mode === 'offline' && (!r.match || u.includes(r.match)));
|
|
76
|
+
if (i < 0) return null;
|
|
77
|
+
const rule = rules[i] as ShapeRule;
|
|
78
|
+
if (rule.times !== undefined) {
|
|
79
|
+
rule.times -= 1;
|
|
80
|
+
if (rule.times <= 0) rules.splice(i, 1);
|
|
81
|
+
}
|
|
82
|
+
shaped++;
|
|
83
|
+
emit('net.shaped', { kind: 'socket', url, mode: 'offline' });
|
|
84
|
+
return { offline: true };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function parseRules(value: unknown): ShapeRule[] {
|
|
88
|
+
if (!Array.isArray(value)) throw new PilotError('net.shape needs `rules`: a list, or `clear: true`.', { code: 'BAD_ARGS', fixExample: { command: 'net.shape', args: { rules: [{ match: '/api/', mode: 'offline', times: 1 }] } } });
|
|
89
|
+
return value.map((r, i) => {
|
|
90
|
+
const o = (r ?? {}) as Record<string, unknown>;
|
|
91
|
+
const mode = o.mode;
|
|
92
|
+
const kind = o.kind === 'socket' ? 'socket' : 'request';
|
|
93
|
+
if (o.kind !== undefined && o.kind !== 'socket' && o.kind !== 'request') throw new PilotError(`rules[${i}].kind must be request or socket.`, { code: 'BAD_ARGS' });
|
|
94
|
+
if (kind === 'socket' && mode !== 'offline' && mode !== 'drop') throw new PilotError(`rules[${i}]: a socket rule's mode is offline (connects fail) or drop (open connections drop now).`, { code: 'BAD_ARGS' });
|
|
95
|
+
if (kind === 'request' && mode !== 'offline' && mode !== 'fail' && mode !== 'delay') throw new PilotError(`rules[${i}].mode must be offline, fail or delay.`, { code: 'BAD_ARGS' });
|
|
96
|
+
const num = (v: unknown, lo: number, hi: number) => (typeof v === 'number' && Number.isFinite(v) ? Math.min(hi, Math.max(lo, Math.floor(v))) : undefined);
|
|
97
|
+
const delayMs = num(o.delayMs, 0, 120_000);
|
|
98
|
+
const times = num(o.times, 1, 10_000);
|
|
99
|
+
const rule: ShapeRule = {
|
|
100
|
+
...(kind === 'socket' ? { kind } : {}),
|
|
101
|
+
mode: mode as ShapeMode,
|
|
102
|
+
...(typeof o.match === 'string' && o.match ? { match: o.match.toLowerCase() } : {}),
|
|
103
|
+
...(typeof o.method === 'string' && o.method ? { method: o.method.toUpperCase() } : {}),
|
|
104
|
+
...(mode === 'fail' ? { status: num(o.status, 400, 599) ?? 503 } : {}),
|
|
105
|
+
...(delayMs !== undefined ? { delayMs } : {}),
|
|
106
|
+
...(times !== undefined ? { times } : {}),
|
|
107
|
+
};
|
|
108
|
+
if (mode === 'delay' && rule.delayMs === undefined) throw new PilotError(`rules[${i}]: a delay needs delayMs.`, { code: 'BAD_ARGS' });
|
|
109
|
+
return rule;
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* What the app's fetch wrapper does with one request: wait `delayMs`, then fail offline, answer `status`, or go on.
|
|
115
|
+
* The first matching rule wins; a rule with `times` is used up. null = untouched.
|
|
116
|
+
*/
|
|
117
|
+
export function shapeRequest(method: string, url: string): Shape | null {
|
|
118
|
+
const recordedAnswer = harAnswer(method, url);
|
|
119
|
+
if (recordedAnswer === 'abort') return { delayMs: 0, offline: true, status: null };
|
|
120
|
+
if (recordedAnswer) return { delayMs: 0, offline: false, status: null, replay: recordedAnswer };
|
|
121
|
+
if (rules.length === 0) return null;
|
|
122
|
+
const m = method.toUpperCase();
|
|
123
|
+
const u = url.toLowerCase();
|
|
124
|
+
const i = rules.findIndex((r) => r.kind !== 'socket' && (!r.match || u.includes(r.match)) && (!r.method || r.method === m));
|
|
125
|
+
if (i < 0) return null;
|
|
126
|
+
const rule = rules[i] as ShapeRule;
|
|
127
|
+
if (rule.times !== undefined) {
|
|
128
|
+
rule.times -= 1;
|
|
129
|
+
if (rule.times <= 0) rules.splice(i, 1);
|
|
130
|
+
}
|
|
131
|
+
shaped++;
|
|
132
|
+
emit('net.shaped', { method: m, url, mode: rule.mode });
|
|
133
|
+
return { delayMs: rule.delayMs ?? 0, offline: rule.mode === 'offline', status: rule.mode === 'fail' ? (rule.status ?? 503) : null };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ---- the clock -----------------------------------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const RealDate = Date;
|
|
139
|
+
const realNow = Date.now.bind(Date);
|
|
140
|
+
let offsetMs = 0;
|
|
141
|
+
let clockInstalled = false;
|
|
142
|
+
|
|
143
|
+
function installClock(): void {
|
|
144
|
+
if (clockInstalled) return;
|
|
145
|
+
clockInstalled = true;
|
|
146
|
+
// `new Date()` and `Date.now()` see the shifted time; `new Date(x)` keeps meaning x. Timers are not affected.
|
|
147
|
+
const Shifted = function (this: unknown, ...args: unknown[]) {
|
|
148
|
+
if (!(this instanceof Shifted)) return new RealDate(realNow() + offsetMs).toString();
|
|
149
|
+
return args.length === 0 ? new RealDate(realNow() + offsetMs) : new (RealDate as unknown as new (...a: unknown[]) => Date)(...args);
|
|
150
|
+
} as unknown as DateConstructor;
|
|
151
|
+
Object.setPrototypeOf(Shifted, RealDate);
|
|
152
|
+
(Shifted as { prototype: Date }).prototype = RealDate.prototype;
|
|
153
|
+
(Shifted as { now: () => number }).now = () => realNow() + offsetMs;
|
|
154
|
+
(globalThis as { Date: DateConstructor }).Date = Shifted;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function resetClock(): void {
|
|
158
|
+
offsetMs = 0;
|
|
159
|
+
if (clockInstalled) (globalThis as { Date: DateConstructor }).Date = RealDate;
|
|
160
|
+
clockInstalled = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
const clockStatus = () => ({ offsetMs, now: new RealDate(realNow() + offsetMs).toISOString(), real: new RealDate(realNow()).toISOString() });
|
|
164
|
+
|
|
165
|
+
// ---- randomness ----------------------------------------------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
const realRandom = Math.random;
|
|
168
|
+
let seeded: number | null = null;
|
|
169
|
+
|
|
170
|
+
function mulberry32(seed: number): () => number {
|
|
171
|
+
let a = seed >>> 0;
|
|
172
|
+
return () => {
|
|
173
|
+
a = (a + 0x6d2b79f5) >>> 0;
|
|
174
|
+
let t = a;
|
|
175
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
176
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
177
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// ---- flags ---------------------------------------------------------------------------------------------------------
|
|
182
|
+
|
|
183
|
+
const flags = new Map<string, unknown>();
|
|
184
|
+
const flagListeners = new Map<string, Set<(value: unknown) => void>>();
|
|
185
|
+
|
|
186
|
+
/** A flag a test set (`flags.set {name, value}`), else `fallback`. */
|
|
187
|
+
export function pilotFlag<T = unknown>(name: string, fallback: T | null = null): T | null {
|
|
188
|
+
return flags.has(name) ? (flags.get(name) as T) : fallback;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Called with the flag's value whenever a test sets or clears it. */
|
|
192
|
+
export function onPilotFlag(name: string, listener: (value: unknown) => void): () => void {
|
|
193
|
+
const set = flagListeners.get(name) ?? new Set();
|
|
194
|
+
set.add(listener);
|
|
195
|
+
flagListeners.set(name, set);
|
|
196
|
+
return () => set.delete(listener);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function setFlag(name: string, value: unknown): void {
|
|
200
|
+
if (value === null || value === undefined) flags.delete(name);
|
|
201
|
+
else flags.set(name, value);
|
|
202
|
+
for (const l of Array.from(flagListeners.get(name) ?? [])) {
|
|
203
|
+
try {
|
|
204
|
+
l(value ?? null);
|
|
205
|
+
} catch {
|
|
206
|
+
// a listener's failure is its own
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// ---- install -------------------------------------------------------------------------------------------------------
|
|
212
|
+
|
|
213
|
+
/** The commands net.shape / net.status, clock.set / reset / status, state.seed, flags.set / get. */
|
|
214
|
+
export function installStage(): () => void {
|
|
215
|
+
const offs = [
|
|
216
|
+
installHar(),
|
|
217
|
+
registerCommand(
|
|
218
|
+
'net.shape',
|
|
219
|
+
(args) => {
|
|
220
|
+
if (args.clear === true) {
|
|
221
|
+
rules = [];
|
|
222
|
+
return { rules, shaped };
|
|
223
|
+
}
|
|
224
|
+
const parsed = parseRules(args.rules);
|
|
225
|
+
// A drop happens now and is not kept: every open connection that matches is cut, as a lost network cuts it. Going
|
|
226
|
+
// offline cuts the open ones too (a hub client keeps its connection open between runs, so «the next connect
|
|
227
|
+
// fails» alone would never be reached) — and then its next connects fail.
|
|
228
|
+
let dropped = 0;
|
|
229
|
+
for (const r of parsed.filter((x) => x.kind === 'socket')) {
|
|
230
|
+
for (const d of Array.from(socketDroppers)) {
|
|
231
|
+
try {
|
|
232
|
+
dropped += d(r.match);
|
|
233
|
+
} catch {
|
|
234
|
+
// a transport's failure to drop is its own; the count says what happened
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
emit('net.shaped', { kind: 'socket', mode: r.mode, match: r.match ?? null, dropped: true });
|
|
238
|
+
}
|
|
239
|
+
rules = parsed.filter((x) => !(x.kind === 'socket' && x.mode === 'drop'));
|
|
240
|
+
const droppers = socketDroppers.size;
|
|
241
|
+
return { rules, shaped, ...(parsed.some((x) => x.kind === 'socket') ? { dropped, droppers } : {}) };
|
|
242
|
+
},
|
|
243
|
+
{ scope: 'stage', description: 'Shapes the requests the page makes: offline, fail with a status, or delay — by address part and method, for `times` requests or until cleared. `kind: socket`: a hub connection — `drop` cuts the open ones now, `offline` fails the next connects.', example: { rules: [{ match: '/api/', mode: 'offline', times: 1 }] } },
|
|
244
|
+
),
|
|
245
|
+
registerCommand('net.status', () => ({ rules, shaped }), { scope: 'observe', description: 'The shaping rules in force and how many requests they shaped.' }),
|
|
246
|
+
registerCommand(
|
|
247
|
+
'clock.set',
|
|
248
|
+
(args) => {
|
|
249
|
+
const at = optionalString(args, 'at');
|
|
250
|
+
if (at !== null) {
|
|
251
|
+
const t = RealDate.parse(at);
|
|
252
|
+
if (!Number.isFinite(t)) throw new PilotError(`"${at}" is not a date (an ISO time, e.g. 2026-12-01T08:00:00Z).`, { code: 'BAD_ARGS' });
|
|
253
|
+
offsetMs = t - realNow();
|
|
254
|
+
} else if (typeof args.offsetMs === 'number' && Number.isFinite(args.offsetMs)) {
|
|
255
|
+
offsetMs = Math.trunc(args.offsetMs);
|
|
256
|
+
} else {
|
|
257
|
+
throw new PilotError('clock.set needs `offsetMs` or `at`.', { code: 'BAD_ARGS', fixExample: { command: 'clock.set', args: { offsetMs: 86_400_000 } } });
|
|
258
|
+
}
|
|
259
|
+
installClock();
|
|
260
|
+
emit('clock', clockStatus());
|
|
261
|
+
return clockStatus();
|
|
262
|
+
},
|
|
263
|
+
{ scope: 'stage', description: 'Moves the page’s clock (Date and Date.now; timers keep real time): by `offsetMs`, or to `at`. The server keeps its own time.', example: { offsetMs: 86_400_000 } },
|
|
264
|
+
),
|
|
265
|
+
registerCommand(
|
|
266
|
+
'clock.reset',
|
|
267
|
+
() => {
|
|
268
|
+
resetClock();
|
|
269
|
+
return clockStatus();
|
|
270
|
+
},
|
|
271
|
+
{ scope: 'stage', description: 'The real clock again.' },
|
|
272
|
+
),
|
|
273
|
+
registerCommand('clock.status', () => clockStatus(), { scope: 'observe' }),
|
|
274
|
+
registerCommand(
|
|
275
|
+
'state.seed',
|
|
276
|
+
(args) => {
|
|
277
|
+
if (args.off === true) {
|
|
278
|
+
Math.random = realRandom;
|
|
279
|
+
seeded = null;
|
|
280
|
+
} else {
|
|
281
|
+
seeded = optionalInt(args, 'seed', 1);
|
|
282
|
+
Math.random = mulberry32(seeded);
|
|
283
|
+
}
|
|
284
|
+
return { seeded };
|
|
285
|
+
},
|
|
286
|
+
{ scope: 'stage', description: 'Math.random from a fixed seed (the same sequence every run), or `off: true`.', example: { seed: 42 } },
|
|
287
|
+
),
|
|
288
|
+
registerCommand(
|
|
289
|
+
'flags.set',
|
|
290
|
+
(args) => {
|
|
291
|
+
const name = requireString(args, 'name');
|
|
292
|
+
setFlag(name, args.value ?? null);
|
|
293
|
+
return { flags: Object.fromEntries(flags) };
|
|
294
|
+
},
|
|
295
|
+
{ scope: 'stage', description: 'A named flag the app reads (pilotFlag); value null clears it.', example: { name: 'reduceMotion', value: true } },
|
|
296
|
+
),
|
|
297
|
+
registerCommand('flags.get', () => ({ flags: Object.fromEntries(flags) }), { scope: 'observe', description: 'The flags a test set.' }),
|
|
298
|
+
];
|
|
299
|
+
return () => {
|
|
300
|
+
for (const off of offs) off();
|
|
301
|
+
resetStageForTests();
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** For tests (and uninstall): no rules, the real clock and Math.random, no flags. */
|
|
306
|
+
export function resetStageForTests(): void {
|
|
307
|
+
resetHarForTests();
|
|
308
|
+
rules = [];
|
|
309
|
+
shaped = 0;
|
|
310
|
+
resetClock();
|
|
311
|
+
Math.random = realRandom;
|
|
312
|
+
seeded = null;
|
|
313
|
+
flags.clear();
|
|
314
|
+
}
|
package/src/vitals.ts
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// The page’s vital signs as oracles (plan v2 P1-07, research R7): a thread that stopped answering and memory that keeps
|
|
2
|
+
// growing are wrong without anyone asking, like a JS error — a flow needs no step for them.
|
|
3
|
+
//
|
|
4
|
+
// perf.longFrame a thread did not answer for ≥ longFrameMs (700): `js` = the page’s own thread (a heartbeat here,
|
|
5
|
+
// platform-free), `main` = the host’s UI thread (the adapter’s `read` reports its stalls: a native
|
|
6
|
+
// heartbeat on the main looper / run loop, only while the app is in front, never under a debugger)
|
|
7
|
+
// perf.memoryGrowth the process’s memory stayed ≥ growthMb AND ≥ growthRatio above the session’s baseline for `sustain`
|
|
8
|
+
// readings in a row (a map load’s spike is not a leak; a screen that leaks on every visit is)
|
|
9
|
+
//
|
|
10
|
+
// perf.vitals (observe) the recent stalls and the memory series, for the controller’s own judgement
|
|
11
|
+
//
|
|
12
|
+
// Measured by heartbeats, not by every frame: a frame callback on every vsync would keep an idle device awake and cost
|
|
13
|
+
// the measurements it is meant to protect. What a heartbeat sees is what a person sees — the screen did not move.
|
|
14
|
+
// Memory is the cheap reading (Android: resident set from /proc/self/statm; iOS: phys_footprint, what Xcode’s gauge shows):
|
|
15
|
+
// PSS costs 100+ ms a reading (research R7), too much to take every few seconds.
|
|
16
|
+
import { emit } from './events.ts';
|
|
17
|
+
import { reportViolation } from './oracles.ts';
|
|
18
|
+
import { registerCommand } from './registry.ts';
|
|
19
|
+
|
|
20
|
+
/** The device's clock as it was at load: `clock.set` moves the page's Date.now for a test, never the vitals' readings. */
|
|
21
|
+
const realNow = Date.now.bind(Date);
|
|
22
|
+
|
|
23
|
+
export interface VitalsStall {
|
|
24
|
+
/** Wall-clock ms when the thread answered again. */
|
|
25
|
+
readonly at: number;
|
|
26
|
+
/** How long it did not answer. */
|
|
27
|
+
readonly ms: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** What the host reports on each reading. */
|
|
31
|
+
export interface VitalsReading {
|
|
32
|
+
/** The main thread’s stalls the host kept (the newest last); only those after the last reading are new. */
|
|
33
|
+
readonly stalls?: readonly VitalsStall[];
|
|
34
|
+
readonly memoryMb?: number | null;
|
|
35
|
+
/** What memoryMb is ('rss', 'phys_footprint'). */
|
|
36
|
+
readonly memoryKind?: string;
|
|
37
|
+
/**
|
|
38
|
+
* What the page's own runtime says it holds, in MB (Lynx `lynx.performance.getMemoryUsage`: the JS heap, the elements,
|
|
39
|
+
* the UI) — the split of `memoryMb` a leak hunt needs first. Kept as the newest reading only.
|
|
40
|
+
*/
|
|
41
|
+
readonly page?: Readonly<Record<string, number>>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface VitalsOptions {
|
|
45
|
+
/** How often the host is read (default 5 s). */
|
|
46
|
+
readonly intervalMs?: number;
|
|
47
|
+
/** A thread silent this long is a long frame (default 700 ms). */
|
|
48
|
+
readonly longFrameMs?: number;
|
|
49
|
+
/** The page’s heartbeat (default 250 ms). */
|
|
50
|
+
readonly heartbeatMs?: number;
|
|
51
|
+
/** Memory rules: growth over the baseline in MB and as a share of it, both needed (defaults 150 MB, 0.5). */
|
|
52
|
+
readonly growthMb?: number;
|
|
53
|
+
readonly growthRatio?: number;
|
|
54
|
+
/** Readings taken before this (from install) set no baseline: start-up fills caches (default 30 s). */
|
|
55
|
+
readonly warmupMs?: number;
|
|
56
|
+
/** Readings in a row over the line before it counts (default 3). */
|
|
57
|
+
readonly sustain?: number;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface Sample {
|
|
61
|
+
readonly t: number;
|
|
62
|
+
readonly mb: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const KEEP_SAMPLES = 120;
|
|
66
|
+
const KEEP_STALLS = 50;
|
|
67
|
+
/** A gap this long is the app being suspended (a background, a frozen cached process), not a hang. */
|
|
68
|
+
const SUSPENDED_MS = 30_000;
|
|
69
|
+
|
|
70
|
+
/** The pure part (tests drive it with their own clock). */
|
|
71
|
+
export function createVitalsWatch(options: VitalsOptions = {}, startedAt = realNow()) {
|
|
72
|
+
const longFrameMs = options.longFrameMs ?? 700;
|
|
73
|
+
const growthMb = options.growthMb ?? 150;
|
|
74
|
+
const growthRatio = options.growthRatio ?? 0.5;
|
|
75
|
+
const warmupMs = options.warmupMs ?? 30_000;
|
|
76
|
+
const sustain = Math.max(1, options.sustain ?? 3);
|
|
77
|
+
const samples: Sample[] = [];
|
|
78
|
+
const stalls: Array<VitalsStall & { thread: 'js' | 'main' }> = [];
|
|
79
|
+
let lastMainAt = 0;
|
|
80
|
+
let lastBeat = 0;
|
|
81
|
+
let baseline: number | null = null;
|
|
82
|
+
let peak: number | null = null;
|
|
83
|
+
let over = 0;
|
|
84
|
+
let kind: string | null = null;
|
|
85
|
+
let page: Readonly<Record<string, number>> | null = null;
|
|
86
|
+
|
|
87
|
+
const stall = (thread: 'js' | 'main', s: VitalsStall) => {
|
|
88
|
+
stalls.push({ thread, ...s });
|
|
89
|
+
if (stalls.length > KEEP_STALLS) stalls.shift();
|
|
90
|
+
const what = thread === 'js' ? 'the page thread' : 'the UI thread';
|
|
91
|
+
reportViolation('perf.longFrame', thread, `${what} did not answer for ${Math.round(s.ms)} ms (a person sees the screen stop).`, { thread, ms: Math.round(s.ms), at: s.at });
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
/** The page’s heartbeat: call it every periodMs; a late beat is a stall of the page’s thread. */
|
|
96
|
+
beat(now: number, periodMs: number, wallNow: number = now): void {
|
|
97
|
+
if (lastBeat > 0) {
|
|
98
|
+
const silent = now - lastBeat - periodMs;
|
|
99
|
+
if (silent >= longFrameMs && silent < SUSPENDED_MS) stall('js', { at: wallNow, ms: silent });
|
|
100
|
+
}
|
|
101
|
+
lastBeat = now;
|
|
102
|
+
},
|
|
103
|
+
/** One reading of the host. */
|
|
104
|
+
read(r: VitalsReading | null, now: number): void {
|
|
105
|
+
if (!r) return;
|
|
106
|
+
if (r.page && typeof r.page === 'object') page = r.page;
|
|
107
|
+
for (const s of r.stalls ?? []) {
|
|
108
|
+
if (s.at <= lastMainAt) continue;
|
|
109
|
+
lastMainAt = s.at;
|
|
110
|
+
if (s.ms >= longFrameMs && s.ms < SUSPENDED_MS) stall('main', s);
|
|
111
|
+
}
|
|
112
|
+
if (typeof r.memoryMb !== 'number' || !Number.isFinite(r.memoryMb)) return;
|
|
113
|
+
kind = r.memoryKind ?? kind;
|
|
114
|
+
const mb = r.memoryMb;
|
|
115
|
+
samples.push({ t: now, mb });
|
|
116
|
+
if (samples.length > KEEP_SAMPLES) samples.shift();
|
|
117
|
+
peak = peak === null ? mb : Math.max(peak, mb);
|
|
118
|
+
if (now - startedAt < warmupMs) return;
|
|
119
|
+
// The baseline is the lowest reading after warm-up: the app at rest. It can only fall (a GC after warm-up).
|
|
120
|
+
if (baseline === null || mb < baseline) baseline = mb;
|
|
121
|
+
const grown = mb - baseline;
|
|
122
|
+
over = grown >= growthMb && grown >= baseline * growthRatio ? over + 1 : 0;
|
|
123
|
+
if (over === sustain) {
|
|
124
|
+
reportViolation(
|
|
125
|
+
'perf.memoryGrowth',
|
|
126
|
+
'session',
|
|
127
|
+
`Memory grew from ${Math.round(baseline)} MB to ${Math.round(mb)} MB during the session (+${Math.round(grown)} MB) and stayed there.`,
|
|
128
|
+
{ baselineMb: Math.round(baseline), currentMb: Math.round(mb), peakMb: Math.round(peak), kind, samples: samples.slice(-20).map((s) => Math.round(s.mb)) },
|
|
129
|
+
);
|
|
130
|
+
emit('perf.memoryGrowth', { baselineMb: Math.round(baseline), currentMb: Math.round(mb) });
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
state() {
|
|
134
|
+
const current = samples.length ? samples[samples.length - 1]!.mb : null;
|
|
135
|
+
return {
|
|
136
|
+
stalls: stalls.slice(-20),
|
|
137
|
+
memory: { kind, baselineMb: baseline, currentMb: current, peakMb: peak, samples: samples.slice(-60).map((s) => ({ t: s.t, mb: Math.round(s.mb * 10) / 10 })), ...(page ? { page } : {}) },
|
|
138
|
+
rules: { longFrameMs, growthMb, growthRatio, warmupMs, sustain },
|
|
139
|
+
};
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The heartbeat's clock: monotonic when the runtime has one (`performance.now`), else the device's wall clock as it was
|
|
146
|
+
* at load — never the page's Date.now, which a test's `clock.set` moves (a +5 s offset would read as a 5 s stall). The wall
|
|
147
|
+
* clock still jumps when the device's time is corrected (an emulator snapshot restored, NTP), and a jump forward looks
|
|
148
|
+
* exactly like a stall (2026-11-05: three «7 s» page-thread stalls after a snapshot load, none on a clean start; Lynx's
|
|
149
|
+
* background thread has no performance.now).
|
|
150
|
+
*/
|
|
151
|
+
export function heartbeatClock(): { readonly now: () => number; readonly kind: 'monotonic' | 'wall' } {
|
|
152
|
+
const perf = (globalThis as { performance?: { now?: () => number } }).performance;
|
|
153
|
+
if (perf && typeof perf.now === 'function') {
|
|
154
|
+
try {
|
|
155
|
+
const t = perf.now();
|
|
156
|
+
if (Number.isFinite(t)) return { now: () => perf.now!(), kind: 'monotonic' };
|
|
157
|
+
} catch {
|
|
158
|
+
// fall through
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { now: realNow, kind: 'wall' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Starts the heartbeat and the host readings (`read` = the adapter’s native call; null = no host readings, the page’s
|
|
166
|
+
* heartbeat only) and registers `perf.vitals`. Returns the uninstaller.
|
|
167
|
+
*/
|
|
168
|
+
export function installVitals(read: (() => Promise<VitalsReading | null>) | null, options: VitalsOptions = {}): () => void {
|
|
169
|
+
const watch = createVitalsWatch(options);
|
|
170
|
+
const heartbeatMs = options.heartbeatMs ?? 250;
|
|
171
|
+
const clock = heartbeatClock();
|
|
172
|
+
// A stall is measured on the heartbeat's clock and reported at wall time (`at`): beat() takes both.
|
|
173
|
+
const beat = setInterval(() => watch.beat(clock.now(), heartbeatMs, realNow()), heartbeatMs);
|
|
174
|
+
let busy = false;
|
|
175
|
+
const poll = read
|
|
176
|
+
? setInterval(() => {
|
|
177
|
+
if (busy) return;
|
|
178
|
+
busy = true;
|
|
179
|
+
read()
|
|
180
|
+
.then((r) => watch.read(r, realNow()))
|
|
181
|
+
.catch(() => undefined)
|
|
182
|
+
.finally(() => {
|
|
183
|
+
busy = false;
|
|
184
|
+
});
|
|
185
|
+
}, options.intervalMs ?? 5_000)
|
|
186
|
+
: null;
|
|
187
|
+
const off = registerCommand('perf.vitals', () => ({ ...watch.state(), heartbeatClock: clock.kind }), {
|
|
188
|
+
scope: 'observe',
|
|
189
|
+
description: 'The recent long frames (the page thread and the UI thread) and the memory series with its baseline: what the perf.longFrame and perf.memoryGrowth oracles judge.',
|
|
190
|
+
});
|
|
191
|
+
return () => {
|
|
192
|
+
clearInterval(beat);
|
|
193
|
+
if (poll) clearInterval(poll);
|
|
194
|
+
off();
|
|
195
|
+
};
|
|
196
|
+
}
|
package/README.md
DELETED