@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/push.ts
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
// Push client: sends window lines to the farm ingest (POST /v1/windows) so a
|
|
2
|
+
// device farm needs no tunnel. Pure logic with injected deps so it runs under
|
|
3
|
+
// bun without React Native (see push-install.ts for the wiring).
|
|
4
|
+
//
|
|
5
|
+
// The run token is taken when a line is queued, not when it is sent, and a
|
|
6
|
+
// batch never mixes tokens: a window belongs to the run that was current when
|
|
7
|
+
// it was recorded. With a spool, every queued line is also journalled in the
|
|
8
|
+
// app sandbox; lines a killed process never sent are queued again, with their
|
|
9
|
+
// own token, by the next process that pushes (resume()). The journal is read
|
|
10
|
+
// lazily (first send or explicit flush, never inside a measured window) and is
|
|
11
|
+
// rewritten to the live queue whenever it outgrows twice the queue caps, so it
|
|
12
|
+
// stays bounded while the ingest is unreachable.
|
|
13
|
+
|
|
14
|
+
export interface DeviceInfo {
|
|
15
|
+
model: string | null;
|
|
16
|
+
os: string | null;
|
|
17
|
+
install_id: string;
|
|
18
|
+
app_build: string | null;
|
|
19
|
+
platform?: string | null;
|
|
20
|
+
// @keepsnappy/react-native version of the build (layer 0 fingerprint).
|
|
21
|
+
sdk_version?: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Append-only journal of the queue (native file). `append` must not block.
|
|
25
|
+
export interface PushSpool {
|
|
26
|
+
read(): string | null;
|
|
27
|
+
append(record: string): void;
|
|
28
|
+
replace(content: string): boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface PushDeps {
|
|
32
|
+
url: string;
|
|
33
|
+
key: string;
|
|
34
|
+
fetch: (
|
|
35
|
+
url: string,
|
|
36
|
+
init: Record<string, unknown>,
|
|
37
|
+
) => Promise<{ status: number; json?: () => Promise<unknown> }>;
|
|
38
|
+
now: () => number;
|
|
39
|
+
setTimer: (run: () => void, ms: number) => unknown;
|
|
40
|
+
clearTimer: (handle: unknown) => void;
|
|
41
|
+
isWindowOpen: () => boolean;
|
|
42
|
+
getRunToken: () => string | null;
|
|
43
|
+
getDevice: () => Promise<DeviceInfo>;
|
|
44
|
+
spool?: PushSpool | null;
|
|
45
|
+
// false: nothing is sent on its own (no idle timer, no retry timer); lines go
|
|
46
|
+
// out only on flushAll(), e.g. at the end of a measured farm iteration.
|
|
47
|
+
autoFlush?: boolean;
|
|
48
|
+
idleFlushMs?: number;
|
|
49
|
+
maxBatchBytes?: number;
|
|
50
|
+
maxBatchItems?: number;
|
|
51
|
+
maxQueueLines?: number;
|
|
52
|
+
maxQueueBytes?: number;
|
|
53
|
+
// The journal is rewritten to the live queue past these (default 2x the queue caps).
|
|
54
|
+
maxJournalBytes?: number;
|
|
55
|
+
maxJournalRecords?: number;
|
|
56
|
+
backoffBaseMs?: number;
|
|
57
|
+
backoffMaxMs?: number;
|
|
58
|
+
requestTimeoutMs?: number;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export interface PushStats {
|
|
62
|
+
enabled: boolean;
|
|
63
|
+
autoFlush: boolean;
|
|
64
|
+
queued: number;
|
|
65
|
+
queuedBytes: number;
|
|
66
|
+
enqueued: number;
|
|
67
|
+
dropped: number;
|
|
68
|
+
batchesSent: number;
|
|
69
|
+
batchesFailed: number;
|
|
70
|
+
batchesRejected: number;
|
|
71
|
+
linesSent: number;
|
|
72
|
+
bytesSent: number;
|
|
73
|
+
lastStatus: number | null;
|
|
74
|
+
lastError: string | null;
|
|
75
|
+
lastSentAt: number | null;
|
|
76
|
+
retryInMs: number | null;
|
|
77
|
+
resumed: number;
|
|
78
|
+
resumeDropped: number;
|
|
79
|
+
spoolErrors: number;
|
|
80
|
+
journalBytes: number;
|
|
81
|
+
journalRewrites: number;
|
|
82
|
+
// Round trips to the ingest not reported yet (plan 51 G1.4).
|
|
83
|
+
clockPending: number;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// One push round trip: t0/t3 this device's send/receive, t1/t2 the server's
|
|
87
|
+
// receive/send. The server compares them with the Maestro host's own, which
|
|
88
|
+
// says how far the window times and the step markers are apart.
|
|
89
|
+
export interface ClockSample {
|
|
90
|
+
t0: number;
|
|
91
|
+
t1: number;
|
|
92
|
+
t2: number;
|
|
93
|
+
t3: number;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const MAX_CLOCK_SAMPLES = 16;
|
|
97
|
+
|
|
98
|
+
const serverClock = async (res: { json?: () => Promise<unknown> }) => {
|
|
99
|
+
try {
|
|
100
|
+
const body = (await res.json?.()) as { clock?: { recv_ms?: unknown; send_ms?: unknown } } | undefined;
|
|
101
|
+
const t1 = body?.clock?.recv_ms;
|
|
102
|
+
const t2 = body?.clock?.send_ms;
|
|
103
|
+
return typeof t1 === 'number' && typeof t2 === 'number' ? { t1, t2 } : null;
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
interface Item {
|
|
110
|
+
line: string;
|
|
111
|
+
bytes: number;
|
|
112
|
+
token: string | null;
|
|
113
|
+
// The process that recorded the line (its bootId) and its sequence there.
|
|
114
|
+
origin: string;
|
|
115
|
+
seq: number;
|
|
116
|
+
// Id of the batch this line was first sent in: a resend keeps exactly that batch.
|
|
117
|
+
batch?: string;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
interface Batch {
|
|
121
|
+
id: string;
|
|
122
|
+
items: Item[];
|
|
123
|
+
token: string | null;
|
|
124
|
+
bytes: number;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
interface SpoolAdd {
|
|
128
|
+
o: string;
|
|
129
|
+
s: number;
|
|
130
|
+
t: string | null;
|
|
131
|
+
l: string;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
interface SpoolDone {
|
|
135
|
+
d: string;
|
|
136
|
+
s: number[];
|
|
137
|
+
// set when the lines were dropped (queue cap), not delivered
|
|
138
|
+
n?: number;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// The lines of one batch, journalled before it is first sent.
|
|
142
|
+
interface SpoolBatch {
|
|
143
|
+
b: string;
|
|
144
|
+
o: string;
|
|
145
|
+
s: number[];
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Lines dropped (queue cap) before they were delivered, carried across rewrites.
|
|
149
|
+
interface SpoolNote {
|
|
150
|
+
n: number;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type PendingLine = SpoolAdd & { b?: string };
|
|
154
|
+
|
|
155
|
+
// Lines still owed by earlier processes, in recording order, each with the
|
|
156
|
+
// batch it was already sent in (if any), and the dropped-lines note.
|
|
157
|
+
export const readSpool = (content: string | null): { pending: PendingLine[]; dropped: number } => {
|
|
158
|
+
if (!content) {
|
|
159
|
+
return { pending: [], dropped: 0 };
|
|
160
|
+
}
|
|
161
|
+
const adds = new Map<string, SpoolAdd>();
|
|
162
|
+
const batchOf = new Map<string, string>();
|
|
163
|
+
let dropped = 0;
|
|
164
|
+
for (const text of content.split('\n')) {
|
|
165
|
+
if (text === '') {
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
let record: Partial<SpoolAdd & SpoolDone & SpoolBatch & SpoolNote>;
|
|
169
|
+
try {
|
|
170
|
+
record = JSON.parse(text) as Partial<SpoolAdd & SpoolDone & SpoolBatch & SpoolNote>;
|
|
171
|
+
} catch {
|
|
172
|
+
// a line cut by a kill mid-write
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
if (typeof record.o === 'string' && typeof record.s === 'number' && typeof record.l === 'string') {
|
|
176
|
+
adds.set(`${record.o}:${record.s}`, {
|
|
177
|
+
o: record.o,
|
|
178
|
+
s: record.s,
|
|
179
|
+
t: typeof record.t === 'string' ? record.t : null,
|
|
180
|
+
l: record.l,
|
|
181
|
+
});
|
|
182
|
+
} else if (typeof record.d === 'string' && Array.isArray(record.s)) {
|
|
183
|
+
for (const seq of record.s) {
|
|
184
|
+
adds.delete(`${record.d}:${seq}`);
|
|
185
|
+
}
|
|
186
|
+
if (typeof record.n === 'number' && record.n > 0) {
|
|
187
|
+
dropped += record.n;
|
|
188
|
+
}
|
|
189
|
+
} else if (typeof record.b === 'string' && typeof record.o === 'string' && Array.isArray(record.s)) {
|
|
190
|
+
for (const seq of record.s) {
|
|
191
|
+
batchOf.set(`${record.o}:${seq}`, record.b);
|
|
192
|
+
}
|
|
193
|
+
} else if (typeof record.n === 'number' && record.n > 0) {
|
|
194
|
+
dropped += record.n;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
const pending = [...adds.values()].map(add => {
|
|
198
|
+
const b = batchOf.get(`${add.o}:${add.s}`);
|
|
199
|
+
return b === undefined ? add : { ...add, b };
|
|
200
|
+
});
|
|
201
|
+
return { pending, dropped };
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export const pendingFromSpool = (content: string | null): PendingLine[] =>
|
|
205
|
+
readSpool(content).pending;
|
|
206
|
+
|
|
207
|
+
const utf8Length = (text: string) => {
|
|
208
|
+
let n = 0;
|
|
209
|
+
for (let i = 0; i < text.length; i++) {
|
|
210
|
+
const c = text.charCodeAt(i);
|
|
211
|
+
if (c < 0x80) {
|
|
212
|
+
n += 1;
|
|
213
|
+
} else if (c < 0x800) {
|
|
214
|
+
n += 2;
|
|
215
|
+
} else if (c >= 0xd800 && c <= 0xdbff) {
|
|
216
|
+
n += 4;
|
|
217
|
+
i++;
|
|
218
|
+
} else {
|
|
219
|
+
n += 3;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return n;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Retrying cannot fix these; the batch is dropped instead of blocking the queue.
|
|
226
|
+
const isPermanent = (status: number) =>
|
|
227
|
+
status >= 400 && status < 500 && status !== 408 && status !== 429;
|
|
228
|
+
|
|
229
|
+
export const createPushClient = (deps: PushDeps) => {
|
|
230
|
+
const idleFlushMs = deps.idleFlushMs ?? 10_000;
|
|
231
|
+
const maxBatchBytes = deps.maxBatchBytes ?? 256 * 1024;
|
|
232
|
+
const maxBatchItems = deps.maxBatchItems ?? 400;
|
|
233
|
+
const maxQueueLines = deps.maxQueueLines ?? 2000;
|
|
234
|
+
const maxQueueBytes = deps.maxQueueBytes ?? 8 * 1024 * 1024;
|
|
235
|
+
const maxJournalBytes = deps.maxJournalBytes ?? 2 * maxQueueBytes;
|
|
236
|
+
const maxJournalRecords = deps.maxJournalRecords ?? 2 * maxQueueLines;
|
|
237
|
+
const backoffBase = deps.backoffBaseMs ?? 2000;
|
|
238
|
+
const backoffMax = deps.backoffMaxMs ?? 60_000;
|
|
239
|
+
const timeoutMs = deps.requestTimeoutMs ?? 15_000;
|
|
240
|
+
const endpoint = `${deps.url.replace(/\/+$/, '')}/v1/windows`;
|
|
241
|
+
// Set by flushAll: the time (deps.now) every request of that flush must be over by.
|
|
242
|
+
let flushLimit: number | null = null;
|
|
243
|
+
// Rejects after `ms`; a hard bound for a fetch that ignores its abort signal.
|
|
244
|
+
const timeoutAfter = (ms: number) => {
|
|
245
|
+
let handle: unknown;
|
|
246
|
+
const promise = new Promise<never>((_, reject) => {
|
|
247
|
+
handle = deps.setTimer(() => reject(new Error('timeout')), ms);
|
|
248
|
+
});
|
|
249
|
+
promise.catch(() => undefined);
|
|
250
|
+
return { promise, clear: () => deps.clearTimer(handle) };
|
|
251
|
+
};
|
|
252
|
+
const clockEndpoint = `${deps.url.replace(/\/+$/, '')}/v1/clock`;
|
|
253
|
+
// Unique per process: two processes started in the same millisecond must not
|
|
254
|
+
// share line keys in the journal.
|
|
255
|
+
const bootId = `${deps.now().toString(36)}${Math.random().toString(36).slice(2, 6)}`;
|
|
256
|
+
|
|
257
|
+
const queue: Item[] = [];
|
|
258
|
+
const spool = deps.spool ?? null;
|
|
259
|
+
const autoFlush = deps.autoFlush ?? true;
|
|
260
|
+
let nextSeq = 0;
|
|
261
|
+
let queuedBytes = 0;
|
|
262
|
+
let retryBatch: Batch | null = null;
|
|
263
|
+
let attempts = 0;
|
|
264
|
+
let timer: unknown = null;
|
|
265
|
+
let retryAt: number | null = null;
|
|
266
|
+
let device: DeviceInfo | null = null;
|
|
267
|
+
let sending = false;
|
|
268
|
+
let resumed = false;
|
|
269
|
+
let journalBytes = 0;
|
|
270
|
+
let journalRecords = 0;
|
|
271
|
+
// Lines dropped before delivery, by this process and (from the note) earlier ones.
|
|
272
|
+
let undelivered = 0;
|
|
273
|
+
// Round trips not reported yet, each with the run token it was measured under.
|
|
274
|
+
let clockPending: { token: string; sample: ClockSample }[] = [];
|
|
275
|
+
const clockOf = (token: string | null) =>
|
|
276
|
+
token ? clockPending.filter(c => c.token === token).map(c => c.sample) : [];
|
|
277
|
+
const keepClock = (token: string, sample: ClockSample) => {
|
|
278
|
+
clockPending.push({ token, sample });
|
|
279
|
+
if (clockPending.length > MAX_CLOCK_SAMPLES) {
|
|
280
|
+
clockPending = clockPending.slice(-MAX_CLOCK_SAMPLES);
|
|
281
|
+
}
|
|
282
|
+
};
|
|
283
|
+
const dropClock = (sent: ClockSample[]) => {
|
|
284
|
+
clockPending = clockPending.filter(c => !sent.includes(c.sample));
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
const stats: PushStats = {
|
|
288
|
+
enabled: true,
|
|
289
|
+
autoFlush,
|
|
290
|
+
queued: 0,
|
|
291
|
+
queuedBytes: 0,
|
|
292
|
+
enqueued: 0,
|
|
293
|
+
dropped: 0,
|
|
294
|
+
batchesSent: 0,
|
|
295
|
+
batchesFailed: 0,
|
|
296
|
+
batchesRejected: 0,
|
|
297
|
+
linesSent: 0,
|
|
298
|
+
bytesSent: 0,
|
|
299
|
+
lastStatus: null,
|
|
300
|
+
lastError: null,
|
|
301
|
+
lastSentAt: null,
|
|
302
|
+
retryInMs: null,
|
|
303
|
+
resumed: 0,
|
|
304
|
+
resumeDropped: 0,
|
|
305
|
+
spoolErrors: 0,
|
|
306
|
+
journalBytes: 0,
|
|
307
|
+
journalRewrites: 0,
|
|
308
|
+
clockPending: 0,
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
const key = (origin: string, seq: number) => `${origin}:${seq}`;
|
|
312
|
+
|
|
313
|
+
// Owed lines in sending order: the batch being sent or retried, then the queue.
|
|
314
|
+
const owed = (): Item[] => [...(retryBatch?.items ?? []), ...queue];
|
|
315
|
+
|
|
316
|
+
// The journal becomes exactly the owed lines (and their batches), so its size
|
|
317
|
+
// follows the queue caps however long the ingest stays unreachable.
|
|
318
|
+
const rewrite = () => {
|
|
319
|
+
if (!spool) {
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
const records: string[] = [];
|
|
323
|
+
if (undelivered > 0) {
|
|
324
|
+
records.push(JSON.stringify({ n: undelivered } satisfies SpoolNote));
|
|
325
|
+
}
|
|
326
|
+
const batches = new Map<string, SpoolBatch>();
|
|
327
|
+
for (const item of owed()) {
|
|
328
|
+
records.push(
|
|
329
|
+
JSON.stringify({ o: item.origin, s: item.seq, t: item.token, l: item.line } satisfies SpoolAdd),
|
|
330
|
+
);
|
|
331
|
+
if (item.batch !== undefined) {
|
|
332
|
+
const b = batches.get(item.batch) ?? { b: item.batch, o: item.origin, s: [] };
|
|
333
|
+
b.s.push(item.seq);
|
|
334
|
+
batches.set(item.batch, b);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
for (const b of batches.values()) {
|
|
338
|
+
records.push(JSON.stringify(b));
|
|
339
|
+
}
|
|
340
|
+
const content = records.map(r => `${r}\n`).join('');
|
|
341
|
+
try {
|
|
342
|
+
spool.replace(content);
|
|
343
|
+
journalBytes = utf8Length(content);
|
|
344
|
+
journalRecords = records.length;
|
|
345
|
+
stats.journalRewrites++;
|
|
346
|
+
} catch {
|
|
347
|
+
stats.spoolErrors++;
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
const journal = (record: SpoolAdd | SpoolDone | SpoolBatch) => {
|
|
352
|
+
if (!spool) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const text = JSON.stringify(record);
|
|
356
|
+
try {
|
|
357
|
+
spool.append(text);
|
|
358
|
+
} catch {
|
|
359
|
+
stats.spoolErrors++;
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
journalBytes += utf8Length(text) + 1;
|
|
363
|
+
journalRecords++;
|
|
364
|
+
if (journalBytes > maxJournalBytes || journalRecords > maxJournalRecords) {
|
|
365
|
+
if (!resumed) {
|
|
366
|
+
// resume() rewrites the journal itself, keeping earlier processes' lines.
|
|
367
|
+
resume();
|
|
368
|
+
} else {
|
|
369
|
+
rewrite();
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
const markDone = (items: Item[], dropped = false) => {
|
|
375
|
+
const byOrigin = new Map<string, number[]>();
|
|
376
|
+
for (const item of items) {
|
|
377
|
+
const list = byOrigin.get(item.origin) ?? [];
|
|
378
|
+
list.push(item.seq);
|
|
379
|
+
byOrigin.set(item.origin, list);
|
|
380
|
+
}
|
|
381
|
+
for (const [origin, seqs] of byOrigin) {
|
|
382
|
+
journal(dropped ? { d: origin, s: seqs, n: seqs.length } : { d: origin, s: seqs });
|
|
383
|
+
}
|
|
384
|
+
};
|
|
385
|
+
|
|
386
|
+
// Everything delivered: start the journal over so it never grows across a long session.
|
|
387
|
+
const compactIfIdle = () => {
|
|
388
|
+
if (spool && resumed && queue.length === 0 && !retryBatch) {
|
|
389
|
+
try {
|
|
390
|
+
spool.replace('');
|
|
391
|
+
journalBytes = 0;
|
|
392
|
+
journalRecords = 0;
|
|
393
|
+
undelivered = 0;
|
|
394
|
+
} catch {
|
|
395
|
+
stats.spoolErrors++;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
const readStats = (): PushStats => ({
|
|
401
|
+
...stats,
|
|
402
|
+
queued: queue.length + (retryBatch?.items.length ?? 0),
|
|
403
|
+
queuedBytes,
|
|
404
|
+
journalBytes,
|
|
405
|
+
clockPending: clockPending.length,
|
|
406
|
+
retryInMs: retryAt === null ? null : Math.max(0, retryAt - deps.now()),
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
const schedule = (ms: number) => {
|
|
410
|
+
if (!autoFlush) {
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
413
|
+
if (timer !== null) {
|
|
414
|
+
deps.clearTimer(timer);
|
|
415
|
+
}
|
|
416
|
+
timer = deps.setTimer(() => {
|
|
417
|
+
timer = null;
|
|
418
|
+
void flush('timer');
|
|
419
|
+
}, ms);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// Drops the oldest queued lines above the caps.
|
|
423
|
+
const trim = () => {
|
|
424
|
+
const dropped: Item[] = [];
|
|
425
|
+
while (
|
|
426
|
+
queue.length > 1 &&
|
|
427
|
+
(queue.length > maxQueueLines || queuedBytes > maxQueueBytes)
|
|
428
|
+
) {
|
|
429
|
+
const oldest = queue.shift();
|
|
430
|
+
if (oldest) {
|
|
431
|
+
queuedBytes -= oldest.bytes;
|
|
432
|
+
dropped.push(oldest);
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
undelivered += dropped.length;
|
|
436
|
+
return dropped;
|
|
437
|
+
};
|
|
438
|
+
|
|
439
|
+
const enqueue = (line: string) => {
|
|
440
|
+
const bytes = utf8Length(line);
|
|
441
|
+
if (bytes > maxBatchBytes) {
|
|
442
|
+
stats.dropped++;
|
|
443
|
+
undelivered++;
|
|
444
|
+
stats.lastError = `line of ${bytes} bytes exceeds batch limit`;
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const item: Item = {
|
|
448
|
+
line,
|
|
449
|
+
bytes,
|
|
450
|
+
token: deps.getRunToken(),
|
|
451
|
+
origin: bootId,
|
|
452
|
+
seq: nextSeq++,
|
|
453
|
+
};
|
|
454
|
+
queue.push(item);
|
|
455
|
+
queuedBytes += item.bytes;
|
|
456
|
+
stats.enqueued++;
|
|
457
|
+
const dropped = trim();
|
|
458
|
+
stats.dropped += dropped.length;
|
|
459
|
+
if (dropped.length > 0) {
|
|
460
|
+
markDone(dropped, true);
|
|
461
|
+
}
|
|
462
|
+
journal({ o: item.origin, s: item.seq, t: item.token, l: item.line });
|
|
463
|
+
if (timer === null) {
|
|
464
|
+
schedule(idleFlushMs);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
// Queues what earlier processes recorded and never sent, ahead of this
|
|
469
|
+
// process's lines, and rewrites the journal to the owed lines. Runs once, at
|
|
470
|
+
// the first send or explicit flush (or when the journal outgrows its cap).
|
|
471
|
+
function resume(): number {
|
|
472
|
+
if (resumed) {
|
|
473
|
+
return 0;
|
|
474
|
+
}
|
|
475
|
+
resumed = true;
|
|
476
|
+
if (!spool) {
|
|
477
|
+
return 0;
|
|
478
|
+
}
|
|
479
|
+
let state: ReturnType<typeof readSpool>;
|
|
480
|
+
try {
|
|
481
|
+
state = readSpool(spool.read());
|
|
482
|
+
} catch {
|
|
483
|
+
stats.spoolErrors++;
|
|
484
|
+
return 0;
|
|
485
|
+
}
|
|
486
|
+
undelivered += state.dropped;
|
|
487
|
+
stats.resumeDropped += state.dropped;
|
|
488
|
+
const earlier: Item[] = [];
|
|
489
|
+
for (const record of state.pending) {
|
|
490
|
+
if (record.o === bootId) {
|
|
491
|
+
// journalled by this process before the resume: already queued
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const bytes = utf8Length(record.l);
|
|
495
|
+
if (bytes > maxBatchBytes) {
|
|
496
|
+
stats.resumeDropped++;
|
|
497
|
+
undelivered++;
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
earlier.push({
|
|
501
|
+
line: record.l,
|
|
502
|
+
bytes,
|
|
503
|
+
token: record.t,
|
|
504
|
+
origin: record.o,
|
|
505
|
+
seq: record.s,
|
|
506
|
+
...(record.b === undefined ? {} : { batch: record.b }),
|
|
507
|
+
});
|
|
508
|
+
}
|
|
509
|
+
queue.unshift(...earlier);
|
|
510
|
+
for (const item of earlier) {
|
|
511
|
+
queuedBytes += item.bytes;
|
|
512
|
+
}
|
|
513
|
+
const dropped = trim();
|
|
514
|
+
for (const item of dropped) {
|
|
515
|
+
if (item.origin === bootId) {
|
|
516
|
+
stats.dropped++;
|
|
517
|
+
} else {
|
|
518
|
+
stats.resumeDropped++;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
const left = new Set(queue.map(item => key(item.origin, item.seq)));
|
|
522
|
+
const kept = earlier.filter(item => left.has(key(item.origin, item.seq))).length;
|
|
523
|
+
rewrite();
|
|
524
|
+
stats.resumed += kept;
|
|
525
|
+
if (queue.length > 0 && timer === null) {
|
|
526
|
+
schedule(idleFlushMs);
|
|
527
|
+
}
|
|
528
|
+
return kept;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
const takeBatch = (): Batch | null => {
|
|
532
|
+
if (retryBatch) {
|
|
533
|
+
return retryBatch;
|
|
534
|
+
}
|
|
535
|
+
if (queue.length === 0) {
|
|
536
|
+
return null;
|
|
537
|
+
}
|
|
538
|
+
const first = queue[0] as Item;
|
|
539
|
+
const items: Item[] = [];
|
|
540
|
+
let bytes = 0;
|
|
541
|
+
if (first.batch !== undefined) {
|
|
542
|
+
// Already sent once (by a killed process): resend exactly that batch.
|
|
543
|
+
while (queue.length > 0 && queue[0]?.batch === first.batch) {
|
|
544
|
+
const next = queue.shift() as Item;
|
|
545
|
+
queuedBytes -= next.bytes;
|
|
546
|
+
items.push(next);
|
|
547
|
+
bytes += next.bytes;
|
|
548
|
+
}
|
|
549
|
+
return { id: first.batch, items, token: first.token, bytes };
|
|
550
|
+
}
|
|
551
|
+
while (queue.length > 0 && items.length < maxBatchItems) {
|
|
552
|
+
const next = queue[0];
|
|
553
|
+
if (
|
|
554
|
+
!next ||
|
|
555
|
+
next.batch !== undefined ||
|
|
556
|
+
next.token !== first.token ||
|
|
557
|
+
next.origin !== first.origin ||
|
|
558
|
+
(items.length > 0 && bytes + next.bytes > maxBatchBytes)
|
|
559
|
+
) {
|
|
560
|
+
break;
|
|
561
|
+
}
|
|
562
|
+
queue.shift();
|
|
563
|
+
queuedBytes -= next.bytes;
|
|
564
|
+
items.push(next);
|
|
565
|
+
bytes += next.bytes;
|
|
566
|
+
}
|
|
567
|
+
return { id: '', items, token: first.token, bytes };
|
|
568
|
+
};
|
|
569
|
+
|
|
570
|
+
let inflight: Promise<void> | null = null;
|
|
571
|
+
|
|
572
|
+
const flush = (reason: string, force = false): Promise<void> => {
|
|
573
|
+
if (sending) {
|
|
574
|
+
return inflight ?? Promise.resolve();
|
|
575
|
+
}
|
|
576
|
+
inflight = sendNext(reason, force);
|
|
577
|
+
return inflight;
|
|
578
|
+
};
|
|
579
|
+
|
|
580
|
+
const sendNext = async (reason: string, force: boolean): Promise<void> => {
|
|
581
|
+
if (!force && deps.isWindowOpen()) {
|
|
582
|
+
// A measured window is open: never send inside it. Try again when idle.
|
|
583
|
+
if (queue.length > 0 || retryBatch) {
|
|
584
|
+
schedule(Math.min(idleFlushMs, 2000));
|
|
585
|
+
}
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
if (!force && retryAt !== null && deps.now() < retryAt) {
|
|
589
|
+
schedule(retryAt - deps.now());
|
|
590
|
+
return;
|
|
591
|
+
}
|
|
592
|
+
resume();
|
|
593
|
+
if (flushLimit !== null && flushLimit - deps.now() <= 0) {
|
|
594
|
+
// The flush deadline is over: the lines stay queued (and in the journal) for the next flush.
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
const batch = takeBatch();
|
|
598
|
+
if (!batch) {
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
// Owed until the server answers: a rewrite meanwhile keeps its lines.
|
|
602
|
+
retryBatch = batch;
|
|
603
|
+
sending = true;
|
|
604
|
+
try {
|
|
605
|
+
if (!device) {
|
|
606
|
+
device = await deps.getDevice();
|
|
607
|
+
}
|
|
608
|
+
if (!batch.id) {
|
|
609
|
+
// Names exactly these lines (process, first and last sequence, count),
|
|
610
|
+
// so a different set of lines is always a different batch. It is
|
|
611
|
+
// journalled before the first send: a later process that resends after
|
|
612
|
+
// a kill sends the same lines under the same id, and the server's
|
|
613
|
+
// replay check can only ever drop lines it already has.
|
|
614
|
+
const first = batch.items[0] as Item;
|
|
615
|
+
const last = batch.items[batch.items.length - 1] as Item;
|
|
616
|
+
batch.id = `${device.install_id}-${first.origin}-${first.seq}-${last.seq}-${batch.items.length}`;
|
|
617
|
+
for (const item of batch.items) {
|
|
618
|
+
item.batch = batch.id;
|
|
619
|
+
}
|
|
620
|
+
journal({ b: batch.id, o: first.origin, s: batch.items.map(item => item.seq) });
|
|
621
|
+
}
|
|
622
|
+
const runToken = batch.token;
|
|
623
|
+
const reported = clockOf(runToken);
|
|
624
|
+
const body = JSON.stringify({
|
|
625
|
+
batch_id: batch.id,
|
|
626
|
+
device,
|
|
627
|
+
items: batch.items.map(item => item.line),
|
|
628
|
+
...(runToken ? { run_token: runToken } : {}),
|
|
629
|
+
...(reported.length ? { clock: reported } : {}),
|
|
630
|
+
});
|
|
631
|
+
const headers: Record<string, string> = {
|
|
632
|
+
'content-type': 'application/json',
|
|
633
|
+
'x-perflab-key': deps.key,
|
|
634
|
+
};
|
|
635
|
+
if (runToken) {
|
|
636
|
+
headers['x-perflab-run-token'] = runToken;
|
|
637
|
+
}
|
|
638
|
+
const budget = flushLimit !== null ? Math.max(1, Math.min(timeoutMs, flushLimit - deps.now())) : timeoutMs;
|
|
639
|
+
const controller =
|
|
640
|
+
typeof AbortController === 'function' ? new AbortController() : null;
|
|
641
|
+
const abort = controller
|
|
642
|
+
? deps.setTimer(() => controller.abort(), budget)
|
|
643
|
+
: null;
|
|
644
|
+
const hard = flushLimit !== null ? timeoutAfter(budget) : null;
|
|
645
|
+
let status = 0;
|
|
646
|
+
try {
|
|
647
|
+
const t0 = deps.now();
|
|
648
|
+
const pending = deps.fetch(endpoint, {
|
|
649
|
+
method: 'POST',
|
|
650
|
+
headers,
|
|
651
|
+
body,
|
|
652
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
653
|
+
});
|
|
654
|
+
const res = hard ? await Promise.race([pending, hard.promise]) : await pending;
|
|
655
|
+
const t3 = deps.now();
|
|
656
|
+
status = res.status;
|
|
657
|
+
if (runToken && (status === 200 || status === 202)) {
|
|
658
|
+
const server = await serverClock(res);
|
|
659
|
+
if (server) {
|
|
660
|
+
// Stored only by a fresh batch; a duplicate's samples go again.
|
|
661
|
+
if (status === 202) {
|
|
662
|
+
dropClock(reported);
|
|
663
|
+
}
|
|
664
|
+
keepClock(runToken, { t0, ...server, t3 });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} finally {
|
|
668
|
+
if (abort !== null) {
|
|
669
|
+
deps.clearTimer(abort);
|
|
670
|
+
}
|
|
671
|
+
hard?.clear();
|
|
672
|
+
}
|
|
673
|
+
stats.lastStatus = status;
|
|
674
|
+
if (status === 200 || status === 202) {
|
|
675
|
+
stats.batchesSent++;
|
|
676
|
+
stats.linesSent += batch.items.length;
|
|
677
|
+
stats.bytesSent += utf8Length(body);
|
|
678
|
+
stats.lastSentAt = deps.now();
|
|
679
|
+
stats.lastError = null;
|
|
680
|
+
retryBatch = null;
|
|
681
|
+
retryAt = null;
|
|
682
|
+
attempts = 0;
|
|
683
|
+
markDone(batch.items);
|
|
684
|
+
compactIfIdle();
|
|
685
|
+
} else if (isPermanent(status)) {
|
|
686
|
+
stats.batchesRejected++;
|
|
687
|
+
stats.lastError = `rejected ${status}, batch ${batch.id} dropped`;
|
|
688
|
+
retryBatch = null;
|
|
689
|
+
retryAt = null;
|
|
690
|
+
attempts = 0;
|
|
691
|
+
markDone(batch.items);
|
|
692
|
+
compactIfIdle();
|
|
693
|
+
} else {
|
|
694
|
+
throw new Error(`http ${status}`);
|
|
695
|
+
}
|
|
696
|
+
} catch (error) {
|
|
697
|
+
stats.batchesFailed++;
|
|
698
|
+
stats.lastError = `${reason}: ${String(error)}`;
|
|
699
|
+
retryBatch = batch;
|
|
700
|
+
attempts++;
|
|
701
|
+
const wait = Math.min(backoffMax, backoffBase * 2 ** (attempts - 1));
|
|
702
|
+
retryAt = deps.now() + wait;
|
|
703
|
+
} finally {
|
|
704
|
+
sending = false;
|
|
705
|
+
}
|
|
706
|
+
if (queue.length > 0 || retryBatch) {
|
|
707
|
+
schedule(retryAt !== null ? Math.max(0, retryAt - deps.now()) : 0);
|
|
708
|
+
}
|
|
709
|
+
};
|
|
710
|
+
|
|
711
|
+
const sleep = (ms: number) =>
|
|
712
|
+
new Promise<void>(resolve => {
|
|
713
|
+
deps.setTimer(resolve, ms);
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
// The round trips no later batch will carry, one request per run token.
|
|
717
|
+
// Called only by flushAll, i.e. outside measured windows. Never throws.
|
|
718
|
+
const sendClock = async (limit: number | null) => {
|
|
719
|
+
for (const token of [...new Set(clockPending.map(c => c.token))]) {
|
|
720
|
+
const samples = clockOf(token);
|
|
721
|
+
const budget = Math.min(timeoutMs, limit === null ? timeoutMs : limit - deps.now());
|
|
722
|
+
if (budget <= 0) {
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
725
|
+
const controller = typeof AbortController === 'function' ? new AbortController() : null;
|
|
726
|
+
const abort = controller ? deps.setTimer(() => controller.abort(), budget) : null;
|
|
727
|
+
const hard = timeoutAfter(budget);
|
|
728
|
+
try {
|
|
729
|
+
const res = await Promise.race([
|
|
730
|
+
deps.fetch(clockEndpoint, {
|
|
731
|
+
method: 'POST',
|
|
732
|
+
headers: { 'content-type': 'application/json', authorization: `Bearer ${token}` },
|
|
733
|
+
body: JSON.stringify({ source: 'device', samples }),
|
|
734
|
+
...(controller ? { signal: controller.signal } : {}),
|
|
735
|
+
}),
|
|
736
|
+
hard.promise,
|
|
737
|
+
]);
|
|
738
|
+
if (res.status === 202 || (res.status >= 400 && res.status < 500)) {
|
|
739
|
+
dropClock(samples);
|
|
740
|
+
}
|
|
741
|
+
} catch {
|
|
742
|
+
// kept for the next flush
|
|
743
|
+
} finally {
|
|
744
|
+
if (abort !== null) {
|
|
745
|
+
deps.clearTimer(abort);
|
|
746
|
+
}
|
|
747
|
+
hard.clear();
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
};
|
|
751
|
+
|
|
752
|
+
// Sends everything queued now, whatever the windows and the backoff say, and
|
|
753
|
+
// reports how it went. A failed batch is retried `attempts` times in all.
|
|
754
|
+
const flushAll = async (
|
|
755
|
+
maxAttempts = 3,
|
|
756
|
+
deadline: number | null = null,
|
|
757
|
+
): Promise<{ ok: boolean; batches: number; lines: number; error: string | null }> => {
|
|
758
|
+
if (timer !== null) {
|
|
759
|
+
deps.clearTimer(timer);
|
|
760
|
+
timer = null;
|
|
761
|
+
}
|
|
762
|
+
resume();
|
|
763
|
+
const sentBefore = stats.batchesSent;
|
|
764
|
+
const linesBefore = stats.linesSent;
|
|
765
|
+
let failures = 0;
|
|
766
|
+
flushLimit = deadline;
|
|
767
|
+
try {
|
|
768
|
+
if (inflight) {
|
|
769
|
+
await inflight;
|
|
770
|
+
}
|
|
771
|
+
while (
|
|
772
|
+
(queue.length > 0 || retryBatch) &&
|
|
773
|
+
failures < maxAttempts &&
|
|
774
|
+
(deadline === null || deps.now() < deadline)
|
|
775
|
+
) {
|
|
776
|
+
const failedBefore = stats.batchesFailed;
|
|
777
|
+
await flush('explicit', true);
|
|
778
|
+
if (stats.batchesFailed > failedBefore) {
|
|
779
|
+
failures++;
|
|
780
|
+
if (failures < maxAttempts) {
|
|
781
|
+
const wait = Math.min(backoffMax, backoffBase * failures);
|
|
782
|
+
const pause = deadline === null ? wait : Math.min(wait, deadline - deps.now());
|
|
783
|
+
if (pause > 0) {
|
|
784
|
+
await sleep(pause);
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
const ok = queue.length === 0 && !retryBatch;
|
|
790
|
+
if (ok) {
|
|
791
|
+
await sendClock(deadline);
|
|
792
|
+
}
|
|
793
|
+
return {
|
|
794
|
+
ok,
|
|
795
|
+
batches: stats.batchesSent - sentBefore,
|
|
796
|
+
lines: stats.linesSent - linesBefore,
|
|
797
|
+
error: ok ? null : (stats.lastError ?? 'flush deadline'),
|
|
798
|
+
};
|
|
799
|
+
} finally {
|
|
800
|
+
flushLimit = null;
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// Lines a killed process left behind go out at the first idle flush; the
|
|
805
|
+
// journal is not read before then.
|
|
806
|
+
if (spool && autoFlush) {
|
|
807
|
+
schedule(idleFlushMs);
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
return {
|
|
811
|
+
enqueue,
|
|
812
|
+
flush,
|
|
813
|
+
flushAll,
|
|
814
|
+
resume,
|
|
815
|
+
stats: readStats,
|
|
816
|
+
};
|
|
817
|
+
};
|
|
818
|
+
|
|
819
|
+
export type PushClient = ReturnType<typeof createPushClient>;
|