@expo/build-tools 21.3.0 → 21.5.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.
Files changed (37) hide show
  1. package/dist/android/gradleProfile.js +14 -2
  2. package/dist/builders/custom.js +20 -12
  3. package/dist/common/git.d.ts +4 -0
  4. package/dist/common/git.js +35 -2
  5. package/dist/common/jobHooks.js +10 -0
  6. package/dist/generic.js +15 -6
  7. package/dist/index.d.ts +2 -1
  8. package/dist/index.js +3 -1
  9. package/dist/logging/HttpLogStream.d.ts +28 -0
  10. package/dist/logging/HttpLogStream.js +137 -0
  11. package/dist/steps/compositeFunctions.d.ts +11 -0
  12. package/dist/steps/compositeFunctions.js +62 -0
  13. package/dist/steps/easFunctions.js +4 -0
  14. package/dist/steps/functions/checkout.js +26 -1
  15. package/dist/steps/functions/collectServeSimMetrics.d.ts +3 -0
  16. package/dist/steps/functions/collectServeSimMetrics.js +41 -0
  17. package/dist/steps/functions/startAgentDeviceRemoteSession.js +49 -41
  18. package/dist/steps/functions/startArgentRemoteSession.d.ts +8 -1
  19. package/dist/steps/functions/startArgentRemoteSession.js +52 -4
  20. package/dist/steps/functions/startServeSimMetrics.d.ts +2 -0
  21. package/dist/steps/functions/startServeSimMetrics.js +17 -0
  22. package/dist/steps/functions/startServeSimRemoteSession.js +19 -14
  23. package/dist/steps/utils/agentDeviceArtifacts.js +1 -1
  24. package/dist/steps/utils/agentDeviceEvents.d.ts +11 -0
  25. package/dist/steps/utils/agentDeviceEvents.js +116 -0
  26. package/dist/steps/utils/argentArtifacts.js +1 -1
  27. package/dist/steps/utils/argentEvents.d.ts +12 -0
  28. package/dist/steps/utils/argentEvents.js +131 -0
  29. package/dist/steps/utils/deviceRunSessionEvents.d.ts +43 -2
  30. package/dist/steps/utils/deviceRunSessionEvents.js +95 -115
  31. package/dist/steps/utils/remoteDeviceRunSession.d.ts +43 -5
  32. package/dist/steps/utils/remoteDeviceRunSession.js +249 -37
  33. package/dist/steps/utils/serveSimMetricsArtifacts.d.ts +9 -0
  34. package/dist/steps/utils/serveSimMetricsArtifacts.js +49 -0
  35. package/dist/steps/utils/serveSimMetricsRecorder.d.ts +30 -0
  36. package/dist/steps/utils/serveSimMetricsRecorder.js +232 -0
  37. package/package.json +4 -4
@@ -41,14 +41,19 @@ exports.getNgrokTunnelDomainOrThrow = getNgrokTunnelDomainOrThrow;
41
41
  exports.getNgrokAuthtokenOrThrow = getNgrokAuthtokenOrThrow;
42
42
  exports.selectXcodeDeveloperDirectoryAsync = selectXcodeDeveloperDirectoryAsync;
43
43
  exports.waitForDeviceRunSessionStoppedAsync = waitForDeviceRunSessionStoppedAsync;
44
+ exports.ensureFfmpegInstalledAsync = ensureFfmpegInstalledAsync;
44
45
  exports.turnIceServersToServeSimArgs = turnIceServersToServeSimArgs;
45
46
  exports.fetchServeSimTurnArgsAsync = fetchServeSimTurnArgsAsync;
46
47
  exports.uploadRemoteSessionConfigAsync = uploadRemoteSessionConfigAsync;
47
48
  exports.spawnDetached = spawnDetached;
49
+ exports.metricsCorsOriginToServeSimArgs = metricsCorsOriginToServeSimArgs;
50
+ exports.createServeSimArgs = createServeSimArgs;
51
+ exports.waitForServeSimReadyAsync = waitForServeSimReadyAsync;
48
52
  exports.startServeSimWithTunnelAsync = startServeSimWithTunnelAsync;
49
53
  exports.startNgrokTunnelAsync = startNgrokTunnelAsync;
50
54
  exports.waitForFileAsync = waitForFileAsync;
51
55
  const eas_build_job_1 = require("@expo/eas-build-job");
56
+ const results_1 = require("@expo/results");
52
57
  const steps_1 = require("@expo/steps");
53
58
  const turtle_spawn_1 = __importDefault(require("@expo/turtle-spawn"));
54
59
  const ngrok = __importStar(require("@ngrok/ngrok"));
@@ -57,11 +62,18 @@ const nullthrows_1 = __importDefault(require("nullthrows"));
57
62
  const zod_1 = require("zod");
58
63
  const node_crypto_1 = require("node:crypto");
59
64
  const node_fs_1 = __importDefault(require("node:fs"));
65
+ const node_net_1 = require("node:net");
60
66
  const promises_1 = require("node:timers/promises");
61
67
  const sentry_1 = require("../../sentry");
62
68
  const retry_1 = require("../../utils/retry");
63
69
  const turtleFetch_1 = require("../../utils/turtleFetch");
64
70
  const XCODE_DEVELOPER_DIR = '/Applications/Xcode.app/Contents/Developer';
71
+ const SERVE_SIM_PACKAGE_SPEC = '@expo/serve-sim@latest';
72
+ const SERVE_SIM_HOST = '127.0.0.1';
73
+ const SERVE_SIM_MAX_DIMENSION = '1280';
74
+ const SERVE_SIM_MJPEG_QUALITY = '0.55';
75
+ const SERVE_SIM_VIDEO_BITRATE = '3000000';
76
+ const SERVE_SIM_VIDEO_FPS = '60';
65
77
  const START_DEVICE_RUN_SESSION_MUTATION = (0, gql_tada_1.graphql)(`
66
78
  mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
67
79
  deviceRunSession {
@@ -178,6 +190,66 @@ async function sleepUntilAbortedAsync(timeoutMs, signal) {
178
190
  }
179
191
  }
180
192
  }
193
+ // Argent encodes screen recordings by piping simulator frames into `ffmpeg`,
194
+ // which it resolves from PATH. The tool-server inherits this step's env, so
195
+ // spawning ffmpeg resolves against the same PATH argent will search: it rejects
196
+ // with ENOENT when the binary is absent, and running it also proves it works.
197
+ async function isFfmpegAvailableAsync(env) {
198
+ return (await (0, results_1.asyncResult)((0, turtle_spawn_1.default)('ffmpeg', ['-version'], { env }))).ok;
199
+ }
200
+ async function installFfmpegWithHomebrewAsync({ env, logger, }) {
201
+ await (0, turtle_spawn_1.default)('brew', ['install', 'ffmpeg'], {
202
+ env: { ...env, HOMEBREW_NO_AUTO_UPDATE: '1' },
203
+ logger,
204
+ });
205
+ }
206
+ async function installFfmpegWithAptAsync({ env, logger, }) {
207
+ const aptEnv = { ...env, DEBIAN_FRONTEND: 'noninteractive' };
208
+ // The worker's package index can be older than the image it booted from, which
209
+ // makes the install 404 on a moved package. Refreshing first avoids that; a
210
+ // failed refresh is not fatal because the existing index may still resolve.
211
+ await (0, results_1.asyncResult)((0, turtle_spawn_1.default)('sudo', ['apt-get', 'update'], { env: aptEnv, logger }));
212
+ await (0, turtle_spawn_1.default)('sudo', ['apt-get', 'install', '-y', 'ffmpeg'], { env: aptEnv, logger });
213
+ }
214
+ /**
215
+ * Install ffmpeg when the runtime does not already provide it, so argent's
216
+ * `screen-recording-start` tool can encode a video. The worker images do not
217
+ * ship ffmpeg yet, so without this the tool fails with "`ffmpeg` was not found
218
+ * on PATH" — on macOS (iOS simulators) and Linux (Android emulators) alike.
219
+ *
220
+ * Best-effort by design: screen recording is one optional argent tool, so a
221
+ * failure here is logged and the session continues without it.
222
+ *
223
+ * The whole body is wrapped because the caller runs this in the background with
224
+ * `void`. There is no unhandledRejection handler in the worker, so a rejection
225
+ * escaping here would crash the process and take the live session with it.
226
+ * `spawn` is not an async function and can throw synchronously, which
227
+ * `asyncResult` cannot catch — it only wraps an already-created promise.
228
+ */
229
+ async function ensureFfmpegInstalledAsync({ runtimePlatform, env, logger, }) {
230
+ try {
231
+ if (await isFfmpegAvailableAsync(env)) {
232
+ logger.info('ffmpeg is already installed.');
233
+ return;
234
+ }
235
+ const isDarwin = runtimePlatform === steps_1.BuildRuntimePlatform.DARWIN;
236
+ logger.info(`ffmpeg is not installed, installing it with ${isDarwin ? 'Homebrew' : 'apt'} for argent screen recording.`);
237
+ if (isDarwin) {
238
+ await installFfmpegWithHomebrewAsync({ env, logger });
239
+ }
240
+ else {
241
+ await installFfmpegWithAptAsync({ env, logger });
242
+ }
243
+ logger.info('Installed ffmpeg.');
244
+ }
245
+ catch (err) {
246
+ const error = err instanceof Error ? err : new Error(String(err));
247
+ sentry_1.Sentry.capture('Could not install ffmpeg for argent screen recording', error, {
248
+ level: 'warning',
249
+ });
250
+ logger.warn({ err: error }, 'Could not install ffmpeg. Argent screen recording will not work in this session.');
251
+ }
252
+ }
181
253
  const TurnIceServersResponseSchema = zod_1.z.object({
182
254
  data: zod_1.z.object({
183
255
  iceServers: TurnIceServersSchema,
@@ -248,6 +320,49 @@ async function uploadRemoteSessionConfigAsync({ ctx, deviceRunSessionId, remoteC
248
320
  throw new eas_build_job_1.SystemError(`Failed to start device run session ${deviceRunSessionId}: ${result.error.message}`);
249
321
  }
250
322
  }
323
+ function isProcessRunning(pid) {
324
+ try {
325
+ process.kill(pid, 0);
326
+ return true;
327
+ }
328
+ catch {
329
+ return false;
330
+ }
331
+ }
332
+ async function stopDetachedProcessAsync(pid) {
333
+ if (pid === undefined || !isProcessRunning(pid)) {
334
+ return;
335
+ }
336
+ try {
337
+ // spawnDetached creates a dedicated process group. Signaling the group also
338
+ // terminates npx/bunx descendants instead of leaving the actual daemon alive.
339
+ process.kill(-pid, 'SIGTERM');
340
+ }
341
+ catch {
342
+ try {
343
+ process.kill(pid, 'SIGTERM');
344
+ }
345
+ catch {
346
+ return;
347
+ }
348
+ }
349
+ const deadline = Date.now() + 5_000;
350
+ while (Date.now() < deadline && isProcessRunning(pid)) {
351
+ await (0, retry_1.sleepAsync)(100);
352
+ }
353
+ if (!isProcessRunning(pid)) {
354
+ return;
355
+ }
356
+ try {
357
+ process.kill(-pid, 'SIGKILL');
358
+ }
359
+ catch {
360
+ try {
361
+ process.kill(pid, 'SIGKILL');
362
+ }
363
+ catch { }
364
+ }
365
+ }
251
366
  function spawnDetached({ command, args, cwd, env, }) {
252
367
  const promise = (0, turtle_spawn_1.default)(command, args, {
253
368
  cwd,
@@ -265,49 +380,133 @@ function spawnDetached({ command, args, cwd, env, }) {
265
380
  };
266
381
  promise.child.stdout?.on('data', appendChunk);
267
382
  promise.child.stderr?.on('data', appendChunk);
268
- return { pid: promise.child.pid, getOutput: () => output };
383
+ const pid = promise.child.pid;
384
+ return {
385
+ pid,
386
+ getOutput: () => output,
387
+ stopAsync: async () => await stopDetachedProcessAsync(pid),
388
+ };
269
389
  }
270
- async function startServeSimWithTunnelAsync(ctx, { baseDomain, env, logger, timeoutMs, }) {
271
- logger.info('Launching serve-sim with tunnel.');
272
- const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
273
- const serveSim = spawnDetached({
274
- command: 'npx',
275
- args: [
276
- 'serve-sim-szdziedzic@latest',
277
- '--tunnel',
278
- '--tunnel-provider',
279
- 'ngrok',
280
- '--tunnel-domain',
281
- baseDomain,
282
- '--stream-max-dimension',
283
- '1280',
284
- '--stream-quality',
285
- '0.55',
286
- '--codec',
287
- 'webrtc',
288
- ...turnArgs,
289
- ],
290
- env,
390
+ function metricsCorsOriginToServeSimArgs(env) {
391
+ const origin = env.EAS_SIMULATOR_METRICS_CORS_ORIGIN;
392
+ if (!origin) {
393
+ return [];
394
+ }
395
+ const args = [];
396
+ for (const value of origin.split(',')) {
397
+ const trimmed = value.trim();
398
+ if (trimmed) {
399
+ args.push('--metrics-cors-origin', trimmed);
400
+ }
401
+ }
402
+ return args;
403
+ }
404
+ function createServeSimArgs({ port, turnArgs = [], metricsCorsArgs = [], }) {
405
+ return [
406
+ '--yes',
407
+ SERVE_SIM_PACKAGE_SPEC,
408
+ '--port',
409
+ String(port),
410
+ '--host',
411
+ SERVE_SIM_HOST,
412
+ '--transport',
413
+ 'webrtc',
414
+ '--webrtc-codec',
415
+ 'vp8',
416
+ '--max-dimension',
417
+ SERVE_SIM_MAX_DIMENSION,
418
+ '--mjpeg-quality',
419
+ SERVE_SIM_MJPEG_QUALITY,
420
+ '--video-bitrate',
421
+ SERVE_SIM_VIDEO_BITRATE,
422
+ '--video-fps',
423
+ SERVE_SIM_VIDEO_FPS,
424
+ ...turnArgs,
425
+ ...metricsCorsArgs,
426
+ ];
427
+ }
428
+ async function findAvailablePortAsync() {
429
+ const server = (0, node_net_1.createServer)();
430
+ server.unref();
431
+ await new Promise((resolve, reject) => {
432
+ server.once('error', reject);
433
+ server.listen(0, SERVE_SIM_HOST, () => resolve());
434
+ });
435
+ const address = server.address();
436
+ await new Promise((resolve, reject) => {
437
+ server.close(err => (err ? reject(err) : resolve()));
291
438
  });
292
- logger.info('Waiting for serve-sim to report tunnel URL.');
439
+ if (!address || typeof address === 'string') {
440
+ throw new eas_build_job_1.SystemError('Could not allocate a local port for serve-sim.');
441
+ }
442
+ return address.port;
443
+ }
444
+ const ServeSimReadyResponseSchema = zod_1.z.object({
445
+ status: zod_1.z.literal('ready'),
446
+ device: zod_1.z.string(),
447
+ });
448
+ async function waitForServeSimReadyAsync({ serveSim, port, timeoutMs, }) {
449
+ const readyUrl = `http://${SERVE_SIM_HOST}:${port}/readyz`;
293
450
  const deadline = Date.now() + timeoutMs;
451
+ let lastError;
294
452
  while (Date.now() < deadline) {
295
- const output = serveSim.getOutput();
296
- const previewUrl = matchTunnelUrl({ output, baseDomain });
297
- if (previewUrl) {
298
- return { previewUrl };
453
+ if (serveSim.pid !== undefined && !isProcessRunning(serveSim.pid)) {
454
+ throw new eas_build_job_1.SystemError(`serve-sim exited before becoming ready. Last output:\n${serveSim.getOutput() || '<empty>'}`);
455
+ }
456
+ try {
457
+ const response = await (0, turtleFetch_1.turtleFetch)(readyUrl, 'GET', {
458
+ retries: 0,
459
+ timeout: 2_000,
460
+ });
461
+ ServeSimReadyResponseSchema.parse(await response.json());
462
+ return;
463
+ }
464
+ catch (error) {
465
+ lastError = error;
299
466
  }
300
467
  await (0, retry_1.sleepAsync)(1_000);
301
468
  }
302
- throw new eas_build_job_1.SystemError(`Timed out waiting for serve-sim to report Tunnel URL. Last output:\n${serveSim.getOutput() || '<empty>'}`);
469
+ throw new eas_build_job_1.SystemError(`Timed out waiting for serve-sim readiness at ${readyUrl}${lastError instanceof Error ? `: ${lastError.message}` : ''}. Last output:\n${serveSim.getOutput() || '<empty>'}`);
303
470
  }
304
- function matchTunnelUrl({ output, baseDomain, }) {
305
- const labelPattern = new RegExp(`Tunnel:\\s*(https:\\/\\/[a-z0-9-]+\\.${escapeRegExp(baseDomain)})`);
306
- const match = labelPattern.exec(output);
307
- return match ? match[1] : null;
471
+ async function startServeSimWithTunnelAsync(ctx, { baseDomain, env, logger, timeoutMs, }) {
472
+ const port = await findAvailablePortAsync();
473
+ logger.info(`Launching ${SERVE_SIM_PACKAGE_SPEC} on ${SERVE_SIM_HOST}:${port}.`);
474
+ const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
475
+ const metricsCorsArgs = metricsCorsOriginToServeSimArgs(env);
476
+ const serveSim = spawnDetached({
477
+ command: 'npx',
478
+ args: createServeSimArgs({ port, turnArgs, metricsCorsArgs }),
479
+ env,
480
+ });
481
+ try {
482
+ logger.info('Waiting for serve-sim to become ready.');
483
+ await waitForServeSimReadyAsync({ serveSim, port, timeoutMs });
484
+ const tunnel = await startNgrokTunnelAsync({
485
+ port,
486
+ subdomainPrefix: 'serve-sim',
487
+ baseDomain,
488
+ authtoken: getNgrokAuthtokenOrThrow(env),
489
+ logger,
490
+ });
491
+ return {
492
+ previewUrl: tunnel.url,
493
+ stopAsync: async () => {
494
+ const results = await Promise.allSettled([tunnel.stopAsync(), serveSim.stopAsync()]);
495
+ for (const result of results) {
496
+ if (result.status === 'rejected') {
497
+ logger.warn({ err: result.reason }, 'Could not stop a serve-sim preview resource.');
498
+ }
499
+ }
500
+ },
501
+ };
502
+ }
503
+ catch (error) {
504
+ await serveSim.stopAsync();
505
+ throw error;
506
+ }
308
507
  }
309
508
  async function startNgrokTunnelAsync({ port, subdomainPrefix, baseDomain, authtoken, rewriteHostHeader, logger, }) {
310
- const domain = `${subdomainPrefix}-${(0, node_crypto_1.randomBytes)(8).toString('hex')}.${baseDomain}`;
509
+ const domain = `${subdomainPrefix}-${(0, node_crypto_1.randomBytes)(16).toString('hex')}.${baseDomain}`;
311
510
  logger.info(`Starting ngrok tunnel ${domain} -> http://localhost:${port}.`);
312
511
  // Run the ngrok agent in-process via the SDK; it keeps the session alive until
313
512
  // the process exits, and the step blocks forever to hold it open.
@@ -319,12 +518,25 @@ async function startNgrokTunnelAsync({ port, subdomainPrefix, baseDomain, authto
319
518
  });
320
519
  const url = listener.url();
321
520
  if (!url) {
521
+ await listener.close();
322
522
  throw new eas_build_job_1.SystemError(`ngrok tunnel for ${domain} did not return a public URL.`);
323
523
  }
324
- return url;
325
- }
326
- function escapeRegExp(value) {
327
- return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
524
+ let stopped = false;
525
+ return {
526
+ url,
527
+ stopAsync: async () => {
528
+ if (stopped) {
529
+ return;
530
+ }
531
+ stopped = true;
532
+ try {
533
+ await listener.close();
534
+ }
535
+ catch (error) {
536
+ logger.warn({ err: error }, `Could not stop ngrok tunnel ${domain}.`);
537
+ }
538
+ },
539
+ };
328
540
  }
329
541
  async function waitForFileAsync({ filePath, timeoutMs, description, parse, }) {
330
542
  const deadline = Date.now() + timeoutMs;
@@ -0,0 +1,9 @@
1
+ import { type bunyan } from '@expo/logger';
2
+ import { CustomBuildContext } from '../../customBuildContext';
3
+ export declare function uploadServeSimMetricsFileAsync(ctx: CustomBuildContext, { deviceRunSessionId, udid, filePath, metadata, logger, }: {
4
+ deviceRunSessionId: string;
5
+ udid: string;
6
+ filePath: string;
7
+ metadata?: Record<string, unknown>;
8
+ logger: bunyan;
9
+ }): Promise<void>;
@@ -0,0 +1,49 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.uploadServeSimMetricsFileAsync = uploadServeSimMetricsFileAsync;
4
+ const node_fs_1 = require("node:fs");
5
+ const promises_1 = require("node:fs/promises");
6
+ const sentry_1 = require("../../sentry");
7
+ const artifacts_1 = require("../../utils/artifacts");
8
+ const deviceRunSessionArtifacts_1 = require("./deviceRunSessionArtifacts");
9
+ const METRICS_ARTIFACT_FILENAME = 'metrics.ndjson';
10
+ async function uploadServeSimMetricsFileAsync(ctx, { deviceRunSessionId, udid, filePath, metadata, logger, }) {
11
+ let size;
12
+ try {
13
+ ({ size } = await (0, promises_1.stat)(filePath));
14
+ }
15
+ catch {
16
+ logger.info(`No serve-sim metrics file was written for ${udid}; skipping upload.`);
17
+ return;
18
+ }
19
+ if (size === 0) {
20
+ logger.info(`serve-sim metrics file for ${udid} is empty; skipping upload.`);
21
+ return;
22
+ }
23
+ try {
24
+ logger.info(`Uploading serve-sim metrics for ${udid} (${(0, artifacts_1.formatBytes)(size)}).`);
25
+ await (0, deviceRunSessionArtifacts_1.uploadDeviceRunSessionArtifactAsync)(ctx, {
26
+ deviceRunSessionId,
27
+ artifactId: `metrics-${udid}`,
28
+ name: `Performance metrics (${udid.slice(0, 8)})`,
29
+ filename: METRICS_ARTIFACT_FILENAME,
30
+ kind: 'performance-metrics',
31
+ metadata: {
32
+ __eas_type: 'performance-metrics',
33
+ udid,
34
+ ...(metadata && {
35
+ hostCores: metadata.hostCores,
36
+ sampleIntervalMs: metadata.sampleIntervalMs,
37
+ schemaVersion: metadata.schemaVersion,
38
+ }),
39
+ },
40
+ size,
41
+ stream: (0, node_fs_1.createReadStream)(filePath),
42
+ });
43
+ }
44
+ catch (err) {
45
+ const error = err instanceof Error ? err : new Error(String(err));
46
+ sentry_1.Sentry.capture('Could not upload serve-sim metrics artifact', error);
47
+ logger.warn({ err: error }, `Could not upload serve-sim metrics artifact for ${udid}.`);
48
+ }
49
+ }
@@ -0,0 +1,30 @@
1
+ import { type bunyan } from '@expo/logger';
2
+ type ServeSimServer = {
3
+ udid: string;
4
+ url: string;
5
+ };
6
+ export declare namespace ServeSimMetricsRecorder {
7
+ function startAsync({ logger, stateDir, pollIntervalMs, }: {
8
+ logger: bunyan;
9
+ stateDir?: string;
10
+ pollIntervalMs?: number;
11
+ }): Promise<void>;
12
+ function finishAsync({ logger, }: {
13
+ logger: bunyan;
14
+ }): Promise<{
15
+ udid: string;
16
+ filePath: string;
17
+ metadata: Record<string, unknown> | undefined;
18
+ }[]>;
19
+ }
20
+ export declare function readServeSimServersAsync(stateDir: string): Promise<ServeSimServer[]>;
21
+ export declare function streamServeSimMetricsToFileAsync({ serveSimUrl, filePath, signal, logger, }: {
22
+ serveSimUrl: string;
23
+ filePath: string;
24
+ signal: AbortSignal;
25
+ logger: bunyan;
26
+ }): Promise<{
27
+ receivedData: boolean;
28
+ metadata: Record<string, unknown> | undefined;
29
+ }>;
30
+ export {};
@@ -0,0 +1,232 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.ServeSimMetricsRecorder = void 0;
7
+ exports.readServeSimServersAsync = readServeSimServersAsync;
8
+ exports.streamServeSimMetricsToFileAsync = streamServeSimMetricsToFileAsync;
9
+ const node_fetch_1 = __importDefault(require("node-fetch"));
10
+ const node_fs_1 = require("node:fs");
11
+ const promises_1 = require("node:fs/promises");
12
+ const node_os_1 = __importDefault(require("node:os"));
13
+ const node_path_1 = __importDefault(require("node:path"));
14
+ const promises_2 = require("node:timers/promises");
15
+ const sentry_1 = require("../../sentry");
16
+ const POLL_INTERVAL_MS = 2_000;
17
+ // Consecutive failed connects per device; reset once a stream yields a sample.
18
+ const MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE = 10;
19
+ const END_STREAM_TIMEOUT_MS = 2_000;
20
+ // serve-sim's own per-device registry while serving: `${tmpdir}/serve-sim/server-<udid>.json`.
21
+ const SERVE_SIM_STATE_DIR = node_path_1.default.join(node_os_1.default.tmpdir(), 'serve-sim');
22
+ let activeSession = null;
23
+ var ServeSimMetricsRecorder;
24
+ (function (ServeSimMetricsRecorder) {
25
+ async function startAsync({ logger, stateDir = SERVE_SIM_STATE_DIR, pollIntervalMs = POLL_INTERVAL_MS, }) {
26
+ if (activeSession) {
27
+ logger.info('serve-sim metrics polling is already running.');
28
+ return;
29
+ }
30
+ const outputDirectory = await (0, promises_1.mkdtemp)(node_path_1.default.join(node_os_1.default.tmpdir(), 'serve-sim-metrics-'));
31
+ const session = {
32
+ logger,
33
+ stateDir,
34
+ pollIntervalMs,
35
+ outputDirectory,
36
+ files: new Map(),
37
+ attempts: new Map(),
38
+ metadata: new Map(),
39
+ activeStreams: new Map(),
40
+ pollingPromise: Promise.resolve(),
41
+ abortController: new AbortController(),
42
+ };
43
+ activeSession = session;
44
+ logger.info('Started polling serve-sim for cpu/mem metrics.');
45
+ session.pollingPromise = pollServeSimMetricsAsync(session).catch(err => {
46
+ const error = err instanceof Error ? err : new Error(String(err));
47
+ sentry_1.Sentry.capture('serve-sim metrics poller failed', error);
48
+ logger.warn({ err: error }, 'serve-sim metrics poller failed.');
49
+ });
50
+ }
51
+ ServeSimMetricsRecorder.startAsync = startAsync;
52
+ async function finishAsync({ logger, }) {
53
+ const session = activeSession;
54
+ if (!session) {
55
+ logger.info('No serve-sim metrics polling is running.');
56
+ return [];
57
+ }
58
+ activeSession = null;
59
+ session.abortController.abort();
60
+ await session.pollingPromise;
61
+ await Promise.all([...session.activeStreams.values()].map(async (stream) => {
62
+ stream.abortController.abort();
63
+ await stream.donePromise;
64
+ }));
65
+ return [...session.files.entries()].map(([udid, filePath]) => ({
66
+ udid,
67
+ filePath,
68
+ metadata: session.metadata.get(udid),
69
+ }));
70
+ }
71
+ ServeSimMetricsRecorder.finishAsync = finishAsync;
72
+ })(ServeSimMetricsRecorder || (exports.ServeSimMetricsRecorder = ServeSimMetricsRecorder = {}));
73
+ async function pollServeSimMetricsAsync(session) {
74
+ const { abortController, logger } = session;
75
+ while (!abortController.signal.aborted) {
76
+ const servers = await readServeSimServersAsync(session.stateDir);
77
+ // Forget the failure count for a device that has left the registry, so a
78
+ // serve-sim that restarts for the same udid is retried (mirrors the recordings poller).
79
+ const present = new Set(servers.map(server => server.udid));
80
+ for (const udid of session.attempts.keys()) {
81
+ if (!present.has(udid)) {
82
+ session.attempts.delete(udid);
83
+ }
84
+ }
85
+ for (const server of servers) {
86
+ if (session.activeStreams.has(server.udid)) {
87
+ continue;
88
+ }
89
+ const failures = session.attempts.get(server.udid) ?? 0;
90
+ if (failures >= MAX_CONSECUTIVE_STREAM_FAILURES_PER_DEVICE) {
91
+ continue;
92
+ }
93
+ session.attempts.set(server.udid, failures + 1);
94
+ let filePath = session.files.get(server.udid);
95
+ if (!filePath) {
96
+ filePath = node_path_1.default.join(session.outputDirectory, `${server.udid}.ndjson`);
97
+ session.files.set(server.udid, filePath);
98
+ }
99
+ const streamAbortController = new AbortController();
100
+ const streamSignal = AbortSignal.any([abortController.signal, streamAbortController.signal]);
101
+ logger.info(`Collecting serve-sim metrics for ${server.udid}.`);
102
+ const donePromise = streamServeSimMetricsToFileAsync({
103
+ serveSimUrl: server.url,
104
+ filePath,
105
+ signal: streamSignal,
106
+ logger,
107
+ })
108
+ .then(({ receivedData, metadata }) => {
109
+ if (receivedData) {
110
+ session.attempts.set(server.udid, 0);
111
+ }
112
+ if (metadata) {
113
+ session.metadata.set(server.udid, metadata);
114
+ }
115
+ })
116
+ .finally(() => {
117
+ session.activeStreams.delete(server.udid);
118
+ });
119
+ session.activeStreams.set(server.udid, {
120
+ abortController: streamAbortController,
121
+ donePromise,
122
+ });
123
+ }
124
+ await (0, promises_2.setTimeout)(session.pollIntervalMs, undefined, { signal: abortController.signal }).catch(() => { });
125
+ }
126
+ }
127
+ async function readServeSimServersAsync(stateDir) {
128
+ let entries;
129
+ try {
130
+ entries = await (0, promises_1.readdir)(stateDir);
131
+ }
132
+ catch {
133
+ return [];
134
+ }
135
+ const servers = [];
136
+ for (const entry of entries) {
137
+ if (!/^server-.+\.json$/.test(entry)) {
138
+ continue;
139
+ }
140
+ try {
141
+ const state = JSON.parse(await (0, promises_1.readFile)(node_path_1.default.join(stateDir, entry), 'utf-8'));
142
+ if (typeof state.device === 'string' && typeof state.url === 'string') {
143
+ servers.push({ udid: state.device, url: state.url });
144
+ }
145
+ }
146
+ catch {
147
+ continue;
148
+ }
149
+ }
150
+ return servers;
151
+ }
152
+ async function streamServeSimMetricsToFileAsync({ serveSimUrl, filePath, signal, logger, }) {
153
+ const file = (0, node_fs_1.createWriteStream)(filePath, { flags: 'a' });
154
+ file.on('error', err => {
155
+ logger.warn({ err }, `serve-sim metrics file write failed for ${filePath}.`);
156
+ });
157
+ let receivedData = false;
158
+ let metadata;
159
+ try {
160
+ const response = await (0, node_fetch_1.default)(new URL('/metrics', serveSimUrl).toString(), { signal });
161
+ if (!response.ok || !response.body) {
162
+ logger.warn(`serve-sim /metrics responded ${response.status} for ${serveSimUrl}.`);
163
+ return { receivedData, metadata };
164
+ }
165
+ // One decoder so a multi-byte character split across chunks isn't corrupted.
166
+ const decoder = new TextDecoder();
167
+ let buffer = '';
168
+ let event = 'message';
169
+ for await (const chunk of response.body) {
170
+ buffer += decoder.decode(chunk, { stream: true });
171
+ let newline;
172
+ while ((newline = buffer.indexOf('\n')) !== -1) {
173
+ const line = buffer.slice(0, newline);
174
+ buffer = buffer.slice(newline + 1);
175
+ if (line === '') {
176
+ event = 'message';
177
+ }
178
+ else if (line.startsWith('event:')) {
179
+ event = line.slice('event:'.length).trim();
180
+ }
181
+ else if (line.startsWith('data:')) {
182
+ const payload = line.slice('data:'.length).trim();
183
+ if (payload && event === 'meta') {
184
+ // Keep the metadata out of the NDJSON (so it stays homogeneous and reconnects
185
+ // don't re-append it) but hold onto it for the artifact metadata.
186
+ try {
187
+ metadata = JSON.parse(payload);
188
+ }
189
+ catch {
190
+ // ignore a malformed metadata frame
191
+ }
192
+ }
193
+ else if (payload) {
194
+ file.write(payload + '\n');
195
+ receivedData = true;
196
+ }
197
+ }
198
+ }
199
+ }
200
+ }
201
+ catch (err) {
202
+ // Stream ends (teardown abort, serve-sim exit/restart) are expected and reconnected by the
203
+ // poller, so log rather than report — best-effort telemetry shouldn't page.
204
+ if (!signal.aborted) {
205
+ logger.warn({ err }, `serve-sim /metrics stream ended for ${serveSimUrl}.`);
206
+ }
207
+ }
208
+ finally {
209
+ await endWriteStreamAsync(file);
210
+ }
211
+ return { receivedData, metadata };
212
+ }
213
+ // An errored write stream may never fire `end`'s callback, so also settle on
214
+ // close/error and a timeout to avoid hanging teardown.
215
+ async function endWriteStreamAsync(file) {
216
+ await new Promise(resolve => {
217
+ let settled = false;
218
+ const done = () => {
219
+ if (settled) {
220
+ return;
221
+ }
222
+ settled = true;
223
+ clearTimeout(timer);
224
+ resolve();
225
+ };
226
+ const timer = setTimeout(done, END_STREAM_TIMEOUT_MS);
227
+ timer.unref();
228
+ file.once('close', done);
229
+ file.once('error', done);
230
+ file.end(done);
231
+ });
232
+ }