@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.
@@ -0,0 +1,632 @@
1
+ // Raw profile files of a run (Hermes .cpuprofile, React exports) that go to
2
+ // the API at `flush=1`: which ones are kept, the manifest that lists them, and
3
+ // the upload (signed URL from the API, gzip + PUT by the native module, then
4
+ // `complete`). Pure logic with injected deps so it runs under bun; core.ts and
5
+ // push-install.ts wire it. Contract: docs/plans/40-profiler-foundation.md §3.6.
6
+
7
+ export type ArtifactKind = 'cpuprofile' | 'react-export' | 'commit-stream';
8
+
9
+ // Why a file is kept: the iteration is diagnostic, or one of its windows broke
10
+ // a budget on the device (a commit longer than a frame, a long task > 50 ms).
11
+ export type ArtifactReason = 'diag' | 'frame-budget' | 'long-task' | 'stream';
12
+
13
+ export type ArtifactState =
14
+ // listed, not sent yet (or a window opened before it could be)
15
+ | 'pending'
16
+ | 'uploaded'
17
+ // stays on the device only: no endpoint, no native upload, no push config
18
+ | 'local'
19
+ // the API refused it (size, kind); retrying cannot help
20
+ | 'rejected'
21
+ // gave up after retries
22
+ | 'failed';
23
+
24
+ export interface ArtifactEntry {
25
+ name: string;
26
+ kind: ArtifactKind;
27
+ reasons: ArtifactReason[];
28
+ windows: number[];
29
+ bytes: number | null;
30
+ state: ArtifactState;
31
+ artifactId: string | null;
32
+ gzBytes: number | null;
33
+ sha256: string | null;
34
+ attempts: number;
35
+ error: string | null;
36
+ // The run token current when the file was recorded: it goes up under this
37
+ // one only, also after a later link changed the token. In memory only; the
38
+ // manifest on disk leaves it out. Absent: the token given to upload().
39
+ runToken?: string | null;
40
+ }
41
+
42
+ export const LONG_TASK_MS = 50;
43
+
44
+ // The API budget for one run's artifact calls (apps/api/src/modules/artifacts/service.ts has the same three numbers;
45
+ // a test in the api package keeps them equal): the per-run file cap, the calls one file costs (announce + complete)
46
+ // and the calls per minute the run may make. A full export therefore fits in
47
+ // 500 files x 2 calls / 200 calls per minute = 5 minutes, which is the longest the SDK keeps uploading.
48
+ export const ARTIFACT_MAX_FILES_PER_RUN = 500;
49
+ export const ARTIFACT_CALLS_PER_FILE = 2;
50
+ export const ARTIFACT_CALLS_PER_MINUTE = 200;
51
+ export const ARTIFACT_UPLOAD_DEADLINE_MS =
52
+ ((ARTIFACT_MAX_FILES_PER_RUN * ARTIFACT_CALLS_PER_FILE) / ARTIFACT_CALLS_PER_MINUTE) * 60_000;
53
+ export const ARTIFACT_REQUEST_TIMEOUT_MS = 15_000;
54
+ export const ARTIFACT_PUT_TIMEOUT_MS = 60_000;
55
+ // One deadline per flush, started at the flush link (core.ts flushOnce) and shared by the lines push, the clock
56
+ // report and the artifact upload (this file's ARTIFACT_UPLOAD_DEADLINE_MS is that deadline). The end report gets its
57
+ // own request timeout after it, so `perflab.flushed` is raised at most ARTIFACT_FLUSH_WORST_CASE_MS after the link.
58
+ // The e2e driver's wait for it is derived from this.
59
+ export const ARTIFACT_FLUSH_WORST_CASE_MS = ARTIFACT_UPLOAD_DEADLINE_MS + ARTIFACT_REQUEST_TIMEOUT_MS;
60
+ // Longest pause between two attempts at one file. SDK choice: half the Retry-After cap below, so a failing file never
61
+ // waits longer than a rate-limited one is told to.
62
+ export const ARTIFACT_BACKOFF_MAX_MS = 30_000;
63
+
64
+ // 429 without a usable Retry-After: wait this long. Source: the API's per-run budget is 200 calls a minute
65
+ // (apps/api/src/modules/artifacts/service.ts), so a one second pause lets a few calls refill.
66
+ export const ARTIFACT_DEFAULT_429_WAIT_MS = 1000;
67
+ // The longest Retry-After the SDK honours (the API's rate-limit window is one minute).
68
+ export const ARTIFACT_MAX_RETRY_AFTER_MS = 60_000;
69
+
70
+ export interface WrittenFile {
71
+ name: string;
72
+ windows: number[];
73
+ bytes: number | null;
74
+ }
75
+
76
+ // Diagnostic iteration: every file. Otherwise only files that cover a window
77
+ // that broke a budget (a CPU span covers several windows).
78
+ export const selectArtifacts = (input: {
79
+ diag: boolean;
80
+ runToken?: string | null;
81
+ breaks: ReadonlyMap<number, readonly ArtifactReason[]>;
82
+ cpu: WrittenFile | null;
83
+ exports: WrittenFile[];
84
+ /** Commit-stream segments: kept in every run, the backend splits work by cause from them. */
85
+ stream?: WrittenFile[];
86
+ }): ArtifactEntry[] => {
87
+ const out: ArtifactEntry[] = [];
88
+ const add = (kind: ArtifactKind, file: WrittenFile) => {
89
+ let reasons: ArtifactReason[];
90
+ if (kind === 'commit-stream') {
91
+ reasons = ['stream'];
92
+ } else if (input.diag) {
93
+ reasons = ['diag'];
94
+ } else {
95
+ const found = new Set<ArtifactReason>();
96
+ for (const id of file.windows) {
97
+ for (const reason of input.breaks.get(id) ?? []) {
98
+ found.add(reason);
99
+ }
100
+ }
101
+ reasons = [...found].sort();
102
+ }
103
+ if (reasons.length === 0) {
104
+ return;
105
+ }
106
+ out.push({
107
+ name: file.name,
108
+ kind,
109
+ reasons,
110
+ windows: [...file.windows],
111
+ bytes: file.bytes,
112
+ state: 'pending',
113
+ artifactId: null,
114
+ gzBytes: null,
115
+ sha256: null,
116
+ attempts: 0,
117
+ error: null,
118
+ ...(input.runToken !== undefined ? { runToken: input.runToken } : {}),
119
+ });
120
+ };
121
+ if (input.cpu) {
122
+ add('cpuprofile', input.cpu);
123
+ }
124
+ for (const file of input.exports) {
125
+ add('react-export', file);
126
+ }
127
+ for (const file of input.stream ?? []) {
128
+ add('commit-stream', file);
129
+ }
130
+ return out;
131
+ };
132
+
133
+ export const manifestName = (processId: string) => `artifacts-${processId}.json`;
134
+
135
+ export const buildManifest = (input: {
136
+ processId: string;
137
+ diag: boolean;
138
+ frameBudgetMs: number;
139
+ artifacts: ArtifactEntry[];
140
+ upload: ArtifactUploadSummary | null;
141
+ }) => ({
142
+ v: 1,
143
+ processId: input.processId,
144
+ diag: input.diag,
145
+ frameBudgetMs: input.frameBudgetMs,
146
+ artifacts: input.artifacts.map(withoutToken),
147
+ upload: input.upload,
148
+ });
149
+
150
+ const withoutToken = (entry: ArtifactEntry): Omit<ArtifactEntry, 'runToken'> => {
151
+ const { runToken: _token, ...rest } = entry;
152
+ return rest;
153
+ };
154
+
155
+ // Gzips the file (named as written by writeText / cpuProfileDump), hashes the
156
+ // gzip, PUTs it. status 0: no HTTP answer (network, timeout, missing file).
157
+ export interface ArtifactUploadNative {
158
+ artifactUpload(
159
+ fileName: string,
160
+ url: string,
161
+ headers: Record<string, string>,
162
+ timeoutMs: number,
163
+ ): Promise<{
164
+ status: number;
165
+ bytes?: number;
166
+ gzBytes?: number;
167
+ sha256?: string;
168
+ error?: string | null;
169
+ }>;
170
+ }
171
+
172
+ export interface ArtifactHttpResponse {
173
+ status: number;
174
+ headers?: { get(name: string): string | null };
175
+ json: () => Promise<unknown>;
176
+ }
177
+
178
+ export interface ArtifactUploaderDeps {
179
+ url: string;
180
+ key: string;
181
+ fetch: (url: string, init: Record<string, unknown>) => Promise<ArtifactHttpResponse>;
182
+ native: () => ArtifactUploadNative | null;
183
+ isWindowOpen: () => boolean;
184
+ now: () => number;
185
+ sleep: (ms: number) => Promise<void>;
186
+ log: (message: string) => void;
187
+ // Per artifact, the request, PUT and complete are each tried this many times.
188
+ maxAttempts?: number;
189
+ backoffBaseMs?: number;
190
+ requestTimeoutMs?: number;
191
+ putTimeoutMs?: number;
192
+ // Files in flight at once: the upload must finish inside the driver's wait for the
193
+ // flush signal, and one file after the other (about 0.85 s each) took 115 s for 135.
194
+ concurrency?: number;
195
+ // The whole upload stops here; the rest stays on the device as `failed` and goes up
196
+ // at the next flush. Default: the time the API's per-run budget needs for a full run.
197
+ deadlineMs?: number;
198
+ }
199
+
200
+ export interface ArtifactUploadSummary {
201
+ total: number;
202
+ uploaded: number;
203
+ local: number;
204
+ rejected: number;
205
+ failed: number;
206
+ pending: number;
207
+ ms: number;
208
+ // why the upload stopped early, if it did
209
+ stopped: string | null;
210
+ }
211
+
212
+ export const summarizeArtifacts = (
213
+ entries: ArtifactEntry[],
214
+ ms: number,
215
+ stopped: string | null,
216
+ ): ArtifactUploadSummary => {
217
+ const count = (state: ArtifactState) => entries.filter(e => e.state === state).length;
218
+ return {
219
+ total: entries.length,
220
+ uploaded: count('uploaded'),
221
+ local: count('local'),
222
+ rejected: count('rejected'),
223
+ failed: count('failed'),
224
+ pending: count('pending'),
225
+ ms,
226
+ stopped,
227
+ };
228
+ };
229
+
230
+ interface SignedUpload {
231
+ artifactId: string;
232
+ done: boolean;
233
+ url: string | null;
234
+ headers: Record<string, string>;
235
+ }
236
+
237
+ type Step<T> =
238
+ | { ok: true; value: T }
239
+ | {
240
+ ok: false;
241
+ retry: boolean;
242
+ stop: string | null;
243
+ state: ArtifactState;
244
+ error: string;
245
+ // 429: not an attempt; wait this long (the API's Retry-After) and ask again.
246
+ waitMs?: number;
247
+ };
248
+
249
+ const isObject = (value: unknown): value is Record<string, unknown> =>
250
+ value !== null && typeof value === 'object' && !Array.isArray(value);
251
+
252
+ const transient = (status: number) => status === 0 || status === 408 || status === 429 || status >= 500;
253
+
254
+ const stringHeaders = (value: unknown): Record<string, string> => {
255
+ const out: Record<string, string> = {};
256
+ if (isObject(value)) {
257
+ for (const [k, v] of Object.entries(value)) {
258
+ if (typeof v === 'string') {
259
+ out[k] = v;
260
+ }
261
+ }
262
+ }
263
+ return out;
264
+ };
265
+
266
+ export const parseSignedUpload = (body: unknown): SignedUpload | null => {
267
+ if (!isObject(body) || typeof body.artifact_id !== 'string') {
268
+ return null;
269
+ }
270
+ if (body.status === 'complete') {
271
+ return { artifactId: body.artifact_id, done: true, url: null, headers: {} };
272
+ }
273
+ const upload = isObject(body.upload) ? body.upload : null;
274
+ if (!upload || typeof upload.url !== 'string') {
275
+ return null;
276
+ }
277
+ if (upload.method !== undefined && upload.method !== 'PUT') {
278
+ return null;
279
+ }
280
+ return {
281
+ artifactId: body.artifact_id,
282
+ done: false,
283
+ url: upload.url,
284
+ headers: stringHeaders(upload.headers),
285
+ };
286
+ };
287
+
288
+ export const createArtifactUploader = (deps: ArtifactUploaderDeps) => {
289
+ const base = deps.url.replace(/\/+$/, '');
290
+ const maxAttempts = deps.maxAttempts ?? 3;
291
+ const backoffBase = deps.backoffBaseMs ?? 1000;
292
+ const requestTimeoutMs = deps.requestTimeoutMs ?? ARTIFACT_REQUEST_TIMEOUT_MS;
293
+ const putTimeoutMs = deps.putTimeoutMs ?? ARTIFACT_PUT_TIMEOUT_MS;
294
+ const deadlineMs = deps.deadlineMs ?? ARTIFACT_UPLOAD_DEADLINE_MS;
295
+ const concurrency = Math.max(1, deps.concurrency ?? 4);
296
+
297
+ // Rejects after `ms`: a hard bound for work that may ignore its abort signal (a hung fetch, a native PUT).
298
+ const timeoutAfter = (ms: number) => {
299
+ let timer: ReturnType<typeof setTimeout> | undefined;
300
+ const promise = new Promise<never>((_, reject) => {
301
+ timer = setTimeout(() => reject(new Error('timeout')), ms);
302
+ });
303
+ promise.catch(() => undefined);
304
+ return { promise, clear: () => clearTimeout(timer) };
305
+ };
306
+
307
+ // `limit` is the absolute time (deps.now) the call must be over by; the request timeout applies inside it.
308
+ const post = async (
309
+ path: string,
310
+ token: string,
311
+ body: Record<string, unknown>,
312
+ limit: number,
313
+ ): Promise<{ status: number; body: unknown; error: string | null; retryAfterMs: number | null }> => {
314
+ const budget = Math.min(requestTimeoutMs, limit - deps.now());
315
+ if (budget <= 0) {
316
+ return { status: 0, body: null, error: 'deadline', retryAfterMs: null };
317
+ }
318
+ const controller = typeof AbortController === 'function' ? new AbortController() : null;
319
+ const abortTimer = controller ? setTimeout(() => controller.abort(), budget) : null;
320
+ const hard = timeoutAfter(budget);
321
+ try {
322
+ const res = await Promise.race([
323
+ deps.fetch(`${base}${path}`, {
324
+ method: 'POST',
325
+ headers: {
326
+ 'content-type': 'application/json',
327
+ 'x-perflab-key': deps.key,
328
+ 'x-perflab-run-token': token,
329
+ },
330
+ body: JSON.stringify(body),
331
+ ...(controller ? { signal: controller.signal } : {}),
332
+ }),
333
+ hard.promise,
334
+ ]);
335
+ let parsed: unknown = null;
336
+ try {
337
+ parsed = await Promise.race([res.json(), hard.promise]);
338
+ } catch {
339
+ parsed = null;
340
+ }
341
+ const seconds = Number(res.headers?.get('retry-after'));
342
+ const retryAfterMs =
343
+ Number.isFinite(seconds) && seconds > 0 ? Math.min(ARTIFACT_MAX_RETRY_AFTER_MS, seconds * 1000) : null;
344
+ return { status: res.status, body: parsed, error: null, retryAfterMs };
345
+ } catch (error) {
346
+ return { status: 0, body: null, error: String(error), retryAfterMs: null };
347
+ } finally {
348
+ if (abortTimer !== null) {
349
+ clearTimeout(abortTimer);
350
+ }
351
+ hard.clear();
352
+ }
353
+ };
354
+
355
+ // Maps an API answer that is not a success to what happens next.
356
+ const failure = (
357
+ what: string,
358
+ status: number,
359
+ error: string | null,
360
+ retryAfterMs: number | null = null,
361
+ ): Step<never> => {
362
+ const text = `${what} ${status === 0 ? (error ?? 'no response') : `http ${status}`}`;
363
+ if (status === 404) {
364
+ return { ok: false, retry: false, stop: 'artifact endpoint absent', state: 'local', error: text };
365
+ }
366
+ if (status === 401 || status === 403) {
367
+ return { ok: false, retry: false, stop: `not authorised (${status})`, state: 'local', error: text };
368
+ }
369
+ if (status === 429) {
370
+ return { ok: false, retry: true, stop: null, state: 'failed', error: text, waitMs: retryAfterMs ?? ARTIFACT_DEFAULT_429_WAIT_MS };
371
+ }
372
+ if (transient(status)) {
373
+ return { ok: false, retry: true, stop: null, state: 'failed', error: text };
374
+ }
375
+ return { ok: false, retry: false, stop: null, state: 'rejected', error: text };
376
+ };
377
+
378
+ const request = async (
379
+ entry: ArtifactEntry,
380
+ token: string,
381
+ processId: string,
382
+ deadline: number,
383
+ ): Promise<Step<SignedUpload>> => {
384
+ const res = await post('/v1/artifacts', token, {
385
+ client_id: entry.name,
386
+ kind: entry.kind,
387
+ name: entry.name,
388
+ process_id: processId,
389
+ windows: entry.windows,
390
+ reasons: entry.reasons,
391
+ bytes: entry.bytes,
392
+ content_type: 'application/gzip',
393
+ }, deadline);
394
+ if (res.status === 200 || res.status === 201) {
395
+ const signed = parseSignedUpload(res.body);
396
+ return signed
397
+ ? { ok: true, value: signed }
398
+ : { ok: false, retry: false, stop: null, state: 'failed', error: 'request: unreadable answer' };
399
+ }
400
+ return failure('request', res.status, res.error, res.retryAfterMs);
401
+ };
402
+
403
+ const put = async (entry: ArtifactEntry, signed: SignedUpload, deadline: number): Promise<Step<null>> => {
404
+ const native = deps.native();
405
+ if (!native) {
406
+ return { ok: false, retry: false, stop: 'native upload unavailable', state: 'local', error: 'native upload unavailable' };
407
+ }
408
+ const budget = Math.min(putTimeoutMs, deadline - deps.now());
409
+ if (budget <= 0) {
410
+ return { ok: false, retry: false, stop: 'deadline', state: 'failed', error: 'put deadline' };
411
+ }
412
+ let result: Awaited<ReturnType<ArtifactUploadNative['artifactUpload']>>;
413
+ const hard = timeoutAfter(budget);
414
+ try {
415
+ result = await Promise.race([
416
+ native.artifactUpload(entry.name, signed.url as string, signed.headers, budget),
417
+ hard.promise,
418
+ ]);
419
+ } catch (error) {
420
+ return { ok: false, retry: true, stop: null, state: 'failed', error: `put ${String(error)}` };
421
+ } finally {
422
+ hard.clear();
423
+ }
424
+ if (typeof result.gzBytes === 'number') entry.gzBytes = result.gzBytes;
425
+ if (typeof result.sha256 === 'string') entry.sha256 = result.sha256;
426
+ if (typeof result.bytes === 'number') entry.bytes = result.bytes;
427
+ if (result.status >= 200 && result.status < 300) {
428
+ return { ok: true, value: null };
429
+ }
430
+ // A signed URL that expired or was refused: ask for a new one.
431
+ const retry = transient(result.status) || result.status === 400 || result.status === 403;
432
+ return {
433
+ ok: false,
434
+ retry,
435
+ stop: null,
436
+ state: 'failed',
437
+ error: `put ${result.status === 0 ? (result.error ?? 'no response') : `http ${result.status}`}`,
438
+ };
439
+ };
440
+
441
+ const complete = async (
442
+ entry: ArtifactEntry,
443
+ artifactId: string,
444
+ token: string,
445
+ deadline: number,
446
+ ): Promise<Step<null>> => {
447
+ const res = await post(`/v1/artifacts/${encodeURIComponent(artifactId)}/complete`, token, {
448
+ sha256: entry.sha256,
449
+ gz_bytes: entry.gzBytes,
450
+ bytes: entry.bytes,
451
+ }, deadline);
452
+ if (res.status === 200 || res.status === 201) {
453
+ return { ok: true, value: null };
454
+ }
455
+ if (res.status === 409) {
456
+ // The object in storage is not what we hashed: send it again.
457
+ return { ok: false, retry: true, stop: null, state: 'failed', error: 'complete http 409' };
458
+ }
459
+ return failure('complete', res.status, res.error, res.retryAfterMs);
460
+ };
461
+
462
+ // One artifact, request -> PUT -> complete, each failure retried as a whole.
463
+ const uploadOne = async (
464
+ entry: ArtifactEntry,
465
+ token: string,
466
+ processId: string,
467
+ deadline: number,
468
+ ): Promise<string | null> => {
469
+ while (entry.attempts < maxAttempts) {
470
+ if (deps.isWindowOpen()) {
471
+ return 'window open';
472
+ }
473
+ if (deps.now() > deadline) {
474
+ return 'deadline';
475
+ }
476
+ entry.attempts++;
477
+ const signed = await request(entry, token, processId, deadline);
478
+ let step: Step<unknown> = signed;
479
+ if (signed.ok) {
480
+ entry.artifactId = signed.value.artifactId;
481
+ if (signed.value.done) {
482
+ entry.state = 'uploaded';
483
+ entry.error = null;
484
+ return null;
485
+ }
486
+ if (deps.isWindowOpen()) {
487
+ // The PUT is the heavy part: it waits for the next flush instead.
488
+ entry.attempts--;
489
+ entry.state = 'pending';
490
+ return 'window open';
491
+ }
492
+ step = await put(entry, signed.value, deadline);
493
+ if (step.ok) {
494
+ step = await complete(entry, signed.value.artifactId, token, deadline);
495
+ }
496
+ }
497
+ if (step.ok) {
498
+ entry.state = 'uploaded';
499
+ entry.error = null;
500
+ return null;
501
+ }
502
+ entry.state = step.state;
503
+ entry.error = step.error;
504
+ if (step.stop !== null) {
505
+ return step.stop;
506
+ }
507
+ if (!step.retry) {
508
+ return null;
509
+ }
510
+ if (step.waitMs !== undefined) {
511
+ // Asked to slow down: not the file's fault, so not one of its attempts.
512
+ entry.attempts--;
513
+ if (deps.now() + step.waitMs >= deadline) {
514
+ return 'deadline';
515
+ }
516
+ await deps.sleep(step.waitMs);
517
+ continue;
518
+ }
519
+ if (entry.attempts < maxAttempts) {
520
+ const wait = Math.min(ARTIFACT_BACKOFF_MAX_MS, backoffBase * 2 ** (entry.attempts - 1));
521
+ if (deps.now() + wait >= deadline) {
522
+ return 'deadline';
523
+ }
524
+ await deps.sleep(wait);
525
+ }
526
+ }
527
+ return null;
528
+ };
529
+
530
+ // Tells the API how many files this flush holds and how it went, so a run whose upload
531
+ // was cut (the app killed, a stop) shows as partial in the data. Numbers only; the
532
+ // backend judges. Sent before the first file and after the last; never throws.
533
+ const report = async (
534
+ entries: ArtifactEntry[],
535
+ token: string | null,
536
+ phase: 'start' | 'end',
537
+ limit: number,
538
+ stoppedReason: string | null = null,
539
+ ) => {
540
+ const byToken = new Map<string, ArtifactEntry[]>();
541
+ for (const entry of entries) {
542
+ const own = entry.runToken !== undefined ? entry.runToken : token;
543
+ if (own !== null) {
544
+ byToken.set(own, [...(byToken.get(own) ?? []), entry]);
545
+ }
546
+ }
547
+ await Promise.all(
548
+ Array.from(byToken, ([own, group]) => {
549
+ const counts = summarizeArtifacts(group, 0, null);
550
+ return post('/v1/artifacts/flush', own, {
551
+ phase,
552
+ created: counts.total,
553
+ uploaded: counts.uploaded,
554
+ pending: counts.pending,
555
+ failed: counts.failed,
556
+ rejected: counts.rejected,
557
+ local: counts.local,
558
+ stopped: phase === 'end' ? stoppedReason : null,
559
+ }, limit);
560
+ }),
561
+ );
562
+ };
563
+
564
+ // Sends every pending entry under the run token it was recorded with;
565
+ // mutates their state. Never throws. A token the API refuses (401/403)
566
+ // leaves its files local and the files of other tokens go on.
567
+ const upload = async (
568
+ entries: ArtifactEntry[],
569
+ token: string | null,
570
+ processId: string,
571
+ flushDeadline?: number,
572
+ ): Promise<ArtifactUploadSummary> => {
573
+ const t0 = deps.now();
574
+ const deadline = flushDeadline ?? t0 + deadlineMs;
575
+ let stopped: string | null = null;
576
+ let refusedStop: string | null = null;
577
+ let noToken = false;
578
+ const refused = new Set<string>();
579
+ const todo = entries.filter(e => e.state === 'pending' || e.state === 'failed');
580
+ for (const entry of todo) {
581
+ entry.attempts = 0;
582
+ }
583
+ await report(entries, token, 'start', deadline);
584
+ const worker = async () => {
585
+ for (let entry = todo.shift(); entry !== undefined && stopped === null; entry = todo.shift()) {
586
+ const own = entry.runToken !== undefined ? entry.runToken : token;
587
+ if (own === null || refused.has(own)) {
588
+ noToken = noToken || own === null;
589
+ entry.state = 'local';
590
+ entry.error = entry.error ?? (own === null ? 'no run token' : refusedStop);
591
+ continue;
592
+ }
593
+ try {
594
+ const stop = await uploadOne(entry, own, processId, deadline);
595
+ if (stop !== null && stop.startsWith('not authorised')) {
596
+ refused.add(own);
597
+ refusedStop = stop;
598
+ } else if (stop !== null) {
599
+ stopped = stopped ?? stop;
600
+ }
601
+ } catch (error) {
602
+ entry.state = 'failed';
603
+ entry.error = String(error);
604
+ }
605
+ }
606
+ };
607
+ await Promise.all(Array.from({ length: Math.min(concurrency, todo.length) }, worker));
608
+ if (stopped !== null) {
609
+ for (const entry of entries) {
610
+ if (entry.state === 'pending') {
611
+ if (stopped === 'deadline') {
612
+ entry.state = 'failed';
613
+ } else if (stopped !== 'window open') {
614
+ entry.state = 'local';
615
+ }
616
+ entry.error = entry.error ?? stopped;
617
+ }
618
+ }
619
+ }
620
+ const reported = stopped ?? refusedStop ?? (noToken ? 'no run token' : null);
621
+ if (reported !== null) {
622
+ deps.log(`artifacts upload stopped: ${reported}`);
623
+ }
624
+ const summary = summarizeArtifacts(entries, deps.now() - t0, reported);
625
+ await report(entries, token, 'end', deps.now() + requestTimeoutMs, reported);
626
+ return summary;
627
+ };
628
+
629
+ return { upload };
630
+ };
631
+
632
+ export type ArtifactUploader = ReturnType<typeof createArtifactUploader>;
package/js/sdk/boot.ts ADDED
@@ -0,0 +1,21 @@
1
+ // perf-lab SDK entry. withPerfLab (metro.js) adds this module to the graph and
2
+ // runs it after InitializeCore, before the app's entry module. The React hook
3
+ // itself was installed earlier by hook.polyfill.js (unless dormant).
4
+ //
5
+ // The measuring SDK (active.ts and everything it imports) is required only when
6
+ // this process measures, so a dormant process never evaluates it.
7
+
8
+ import { type Decision, PERF_LAB_ENABLED, resolveDecision } from './activation';
9
+
10
+ if (PERF_LAB_ENABLED) {
11
+ try {
12
+ const decision: Decision = resolveDecision();
13
+ if (decision.active) {
14
+ (require('./active') as typeof import('./active')).startActive(decision);
15
+ } else {
16
+ (require('./dormant') as typeof import('./dormant')).startDormant(decision);
17
+ }
18
+ } catch (error) {
19
+ globalThis.console.log(`PERF_LAB boot failed ${String(error)}`);
20
+ }
21
+ }