@react-native-harness/platform-android 1.4.0-rc.1 → 1.4.1

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 (78) hide show
  1. package/dist/adb.d.ts +3 -1
  2. package/dist/adb.d.ts.map +1 -1
  3. package/dist/adb.js +30 -21
  4. package/dist/app-session.d.ts +11 -2
  5. package/dist/app-session.d.ts.map +1 -1
  6. package/dist/app-session.js +17 -12
  7. package/dist/avd-config.d.ts +1 -0
  8. package/dist/avd-config.d.ts.map +1 -1
  9. package/dist/avd-config.js +11 -0
  10. package/dist/emulator-startup.d.ts +23 -0
  11. package/dist/emulator-startup.d.ts.map +1 -1
  12. package/dist/emulator-startup.js +58 -0
  13. package/dist/index.d.ts +1 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +1 -0
  16. package/dist/instance.d.ts.map +1 -1
  17. package/dist/instance.js +21 -15
  18. package/dist/runner.d.ts +2 -2
  19. package/dist/runner.d.ts.map +1 -1
  20. package/package.json +4 -4
  21. package/src/adb.ts +72 -65
  22. package/src/app-session.ts +28 -12
  23. package/src/avd-config.ts +17 -0
  24. package/src/emulator-startup.ts +74 -0
  25. package/src/index.ts +1 -0
  26. package/src/instance.ts +30 -16
  27. package/src/runner.ts +5 -9
  28. package/dist/__tests__/adb-id.test.d.ts +0 -2
  29. package/dist/__tests__/adb-id.test.d.ts.map +0 -1
  30. package/dist/__tests__/adb-id.test.js +0 -41
  31. package/dist/__tests__/adb.test.d.ts +0 -2
  32. package/dist/__tests__/adb.test.d.ts.map +0 -1
  33. package/dist/__tests__/adb.test.js +0 -552
  34. package/dist/__tests__/app-monitor.test.d.ts +0 -2
  35. package/dist/__tests__/app-monitor.test.d.ts.map +0 -1
  36. package/dist/__tests__/app-monitor.test.js +0 -257
  37. package/dist/__tests__/avd-config.test.d.ts +0 -2
  38. package/dist/__tests__/avd-config.test.d.ts.map +0 -1
  39. package/dist/__tests__/avd-config.test.js +0 -174
  40. package/dist/__tests__/ci-action.test.d.ts +0 -2
  41. package/dist/__tests__/ci-action.test.d.ts.map +0 -1
  42. package/dist/__tests__/ci-action.test.js +0 -46
  43. package/dist/__tests__/crash-diagnostics.test.d.ts +0 -2
  44. package/dist/__tests__/crash-diagnostics.test.d.ts.map +0 -1
  45. package/dist/__tests__/crash-diagnostics.test.js +0 -119
  46. package/dist/__tests__/crash-parser.test.d.ts +0 -2
  47. package/dist/__tests__/crash-parser.test.d.ts.map +0 -1
  48. package/dist/__tests__/crash-parser.test.js +0 -45
  49. package/dist/__tests__/crash-reporter.test.d.ts +0 -2
  50. package/dist/__tests__/crash-reporter.test.d.ts.map +0 -1
  51. package/dist/__tests__/crash-reporter.test.js +0 -150
  52. package/dist/__tests__/emulator-startup.test.d.ts +0 -2
  53. package/dist/__tests__/emulator-startup.test.d.ts.map +0 -1
  54. package/dist/__tests__/emulator-startup.test.js +0 -20
  55. package/dist/__tests__/environment.test.d.ts +0 -2
  56. package/dist/__tests__/environment.test.d.ts.map +0 -1
  57. package/dist/__tests__/environment.test.js +0 -117
  58. package/dist/__tests__/instance.test.d.ts +0 -2
  59. package/dist/__tests__/instance.test.d.ts.map +0 -1
  60. package/dist/__tests__/instance.test.js +0 -593
  61. package/dist/__tests__/shared-prefs.test.d.ts +0 -2
  62. package/dist/__tests__/shared-prefs.test.d.ts.map +0 -1
  63. package/dist/__tests__/shared-prefs.test.js +0 -87
  64. package/dist/__tests__/targets.test.d.ts +0 -2
  65. package/dist/__tests__/targets.test.d.ts.map +0 -1
  66. package/dist/__tests__/targets.test.js +0 -49
  67. package/dist/tsconfig.lib.tsbuildinfo +0 -1
  68. package/src/__tests__/adb-id.test.ts +0 -51
  69. package/src/__tests__/avd-config.test.ts +0 -206
  70. package/src/__tests__/ci-action.test.ts +0 -81
  71. package/src/__tests__/crash-diagnostics.test.ts +0 -144
  72. package/src/__tests__/crash-parser.test.ts +0 -52
  73. package/src/__tests__/crash-reporter.test.ts +0 -167
  74. package/src/__tests__/emulator-startup.test.ts +0 -35
  75. package/src/__tests__/instance.test.ts +0 -845
  76. package/src/__tests__/shared-prefs.test.ts +0 -144
  77. package/src/__tests__/targets.test.ts +0 -53
  78. package/tsconfig.tsbuildinfo +0 -1
package/src/adb.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms';
2
2
  import {
3
+ delay,
3
4
  logger,
4
5
  spawn,
5
6
  SubprocessError,
7
+ waitForAbort,
6
8
  type Subprocess,
7
9
  } from '@react-native-harness/tools';
8
10
  import { spawn as nodeSpawn } from 'node:child_process';
@@ -25,6 +27,7 @@ import {
25
27
  getRequiredAndroidSdkPackages,
26
28
  } from './environment.js';
27
29
  import {
30
+ getEmulatorCpuCores,
28
31
  getEmulatorStartupArgs,
29
32
  type EmulatorBootMode,
30
33
  } from './emulator-startup.js';
@@ -33,37 +36,26 @@ import {
33
36
  AdbPermissionGrantError,
34
37
  } from './adb-errors.js';
35
38
 
36
- const wait = async (ms: number): Promise<void> => {
37
- await new Promise((resolve) => {
38
- setTimeout(resolve, ms);
39
- });
40
- };
41
-
42
- const waitForAbort = (signal: AbortSignal): Promise<never> => {
43
- if (signal.aborted) {
44
- return Promise.reject(signal.reason);
45
- }
46
-
47
- return new Promise((_, reject) => {
48
- signal.addEventListener(
49
- 'abort',
50
- () => {
51
- reject(signal.reason);
52
- },
53
- { once: true },
54
- );
55
- });
56
- };
57
-
58
39
  const waitWithSignal = async (
59
40
  ms: number,
60
- signal: AbortSignal,
41
+ signal: AbortSignal
61
42
  ): Promise<void> => {
62
43
  if (signal.aborted) {
63
44
  throw signal.reason;
64
45
  }
65
46
 
66
- await Promise.race([wait(ms), waitForAbort(signal)]);
47
+ // Both sides of this race must be torn down explicitly when they lose:
48
+ // an uncancelled timer keeps a ref'd Timeout alive, and an uncancelled
49
+ // abort wait leaks a listener on `signal`, which is a session-lifetime
50
+ // signal here and gets polled against once per second.
51
+ const timer = delay(ms);
52
+ const abort = waitForAbort(signal);
53
+ try {
54
+ await Promise.race([timer.promise, abort.promise]);
55
+ } finally {
56
+ timer.cancel();
57
+ abort.cancel();
58
+ }
67
59
  };
68
60
 
69
61
  const EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS = 5000;
@@ -73,7 +65,7 @@ const androidAdbLogger = logger.child('android-adb');
73
65
  export const emulatorProcess = {
74
66
  startDetachedProcess: (
75
67
  file: string,
76
- args: readonly string[],
68
+ args: readonly string[]
77
69
  ): ChildProcessByStdio<null, Readable, Readable> =>
78
70
  nodeSpawn(file, args, {
79
71
  detached: true,
@@ -84,7 +76,7 @@ export const emulatorProcess = {
84
76
  const appendBoundedOutput = (
85
77
  output: string,
86
78
  chunk: string,
87
- limit: number = EMULATOR_OUTPUT_BUFFER_LIMIT,
79
+ limit: number = EMULATOR_OUTPUT_BUFFER_LIMIT
88
80
  ): string => {
89
81
  const nextOutput = output + chunk;
90
82
 
@@ -165,7 +157,7 @@ export const getRequiredEmulatorPackages = (apiLevel: number): string[] => {
165
157
  };
166
158
 
167
159
  export const verifyAndroidEmulatorSdk = async (
168
- apiLevel: number,
160
+ apiLevel: number
169
161
  ): Promise<void> => {
170
162
  await ensureAndroidSdkPackages(getRequiredEmulatorPackages(apiLevel));
171
163
  };
@@ -192,7 +184,7 @@ const ensureAvdProfileAvailable = async (profile: string): Promise<void> => {
192
184
  : 'None reported by avdmanager.';
193
185
 
194
186
  throw new Error(
195
- `Android AVD profile "${profile}" is not available on this machine. Available profiles: ${availableProfilesList}`,
187
+ `Android AVD profile "${profile}" is not available on this machine. Available profiles: ${availableProfilesList}`
196
188
  );
197
189
  };
198
190
 
@@ -201,7 +193,7 @@ const ensureAvdConfigExists = async (name: string): Promise<string> => {
201
193
 
202
194
  if ((await readAvdConfig(name)) == null) {
203
195
  throw new Error(
204
- `Android AVD "${name}" was created, but config.ini was not found at ${configPath}.`,
196
+ `Android AVD "${name}" was created, but config.ini was not found at ${configPath}.`
205
197
  );
206
198
  }
207
199
 
@@ -235,7 +227,7 @@ const ensureAvdIniExists = async ({
235
227
  export const getStartAppArgs = (
236
228
  bundleId: string,
237
229
  activityName: string,
238
- options?: AndroidAppLaunchOptions,
230
+ options?: AndroidAppLaunchOptions
239
231
  ): string[] => {
240
232
  const args = [
241
233
  'shell',
@@ -264,7 +256,7 @@ export const getStartAppArgs = (
264
256
 
265
257
  if (!Number.isSafeInteger(value)) {
266
258
  throw new Error(
267
- `Android app launch option "${key}" must be a safe integer.`,
259
+ `Android app launch option "${key}" must be a safe integer.`
268
260
  );
269
261
  }
270
262
 
@@ -276,7 +268,7 @@ export const getStartAppArgs = (
276
268
 
277
269
  export const isAppInstalled = async (
278
270
  adbId: string,
279
- bundleId: string,
271
+ bundleId: string
280
272
  ): Promise<boolean> => {
281
273
  const { stdout } = await spawn(getAdbBinaryPath(), [
282
274
  '-s',
@@ -293,7 +285,7 @@ export const isAppInstalled = async (
293
285
  export const reversePort = async (
294
286
  adbId: string,
295
287
  port: number,
296
- hostPort: number = port,
288
+ hostPort: number = port
297
289
  ): Promise<void> => {
298
290
  await spawn(getAdbBinaryPath(), [
299
291
  '-s',
@@ -306,7 +298,7 @@ export const reversePort = async (
306
298
 
307
299
  export const stopApp = async (
308
300
  adbId: string,
309
- bundleId: string,
301
+ bundleId: string
310
302
  ): Promise<void> => {
311
303
  await spawn(getAdbBinaryPath(), [
312
304
  '-s',
@@ -322,7 +314,7 @@ export const startApp = async (
322
314
  adbId: string,
323
315
  bundleId: string,
324
316
  activityName: string,
325
- options?: AndroidAppLaunchOptions,
317
+ options?: AndroidAppLaunchOptions
326
318
  ): Promise<void> => {
327
319
  await spawn(getAdbBinaryPath(), [
328
320
  '-s',
@@ -341,7 +333,7 @@ export const getDeviceIds = async (): Promise<string[]> => {
341
333
  };
342
334
 
343
335
  export const getEmulatorName = async (
344
- adbId: string,
336
+ adbId: string
345
337
  ): Promise<string | null> => {
346
338
  const { stdout } = await spawn(getAdbBinaryPath(), [
347
339
  '-s',
@@ -355,7 +347,7 @@ export const getEmulatorName = async (
355
347
 
356
348
  export const getShellProperty = async (
357
349
  adbId: string,
358
- property: string,
350
+ property: string
359
351
  ): Promise<string | null> => {
360
352
  const { stdout } = await spawn(getAdbBinaryPath(), [
361
353
  '-s',
@@ -377,7 +369,7 @@ export type DeviceInfo = {
377
369
  };
378
370
 
379
371
  export const getDeviceInfo = async (
380
- adbId: string,
372
+ adbId: string
381
373
  ): Promise<DeviceInfo | null> => {
382
374
  const manufacturer = await getShellProperty(adbId, 'ro.product.manufacturer');
383
375
  const model = await getShellProperty(adbId, 'ro.product.model');
@@ -386,7 +378,7 @@ export const getDeviceInfo = async (
386
378
 
387
379
  const getRequestedPermissions = async (
388
380
  adbId: string,
389
- bundleId: string,
381
+ bundleId: string
390
382
  ): Promise<string[]> => {
391
383
  const { stdout } = await spawn(getAdbBinaryPath(), [
392
384
  '-s',
@@ -476,14 +468,14 @@ export const stopEmulator = async (adbId: string): Promise<void> => {
476
468
 
477
469
  export const installApp = async (
478
470
  adbId: string,
479
- appPath: string,
471
+ appPath: string
480
472
  ): Promise<void> => {
481
473
  await spawn(getAdbBinaryPath(), ['-s', adbId, 'install', '-r', appPath]);
482
474
  };
483
475
 
484
476
  export const uninstallApp = async (
485
477
  adbId: string,
486
- bundleId: string,
478
+ bundleId: string
487
479
  ): Promise<void> => {
488
480
  await spawn(getAdbBinaryPath(), ['-s', adbId, 'uninstall', bundleId]);
489
481
  };
@@ -502,20 +494,27 @@ export const createAvd = async ({
502
494
  }: CreateAvdOptions): Promise<void> => {
503
495
  const systemImagePackage = getAndroidSystemImagePackage(
504
496
  apiLevel,
505
- getHostAndroidSystemImageArch(),
497
+ getHostAndroidSystemImageArch()
506
498
  );
507
499
 
508
500
  await verifyAndroidEmulatorSdk(apiLevel);
509
501
  await ensureAvdProfileAvailable(profile);
510
502
  await spawn('bash', [
511
503
  '-lc',
512
- `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}" -p "${getAvdDirectory(name)}"`,
504
+ `printf 'no\n' | "${getAvdManagerBinaryPath()}" create avd --force --name "${name}" --package "${systemImagePackage}" --device "${profile}" -p "${getAvdDirectory(
505
+ name
506
+ )}"`,
513
507
  ]);
514
508
  await ensureAvdIniExists({ name, apiLevel });
515
509
  const configPath = await ensureAvdConfigExists(name);
510
+ // hw.cpu.ncore is baked into config.ini (rather than passed as a `-cores`
511
+ // boot flag) so it's part of the AVD's persisted hardware profile: boot
512
+ // snapshots require an identical hardware config to load, so the vCPU
513
+ // count must be fixed at AVD-creation time and stay consistent across
514
+ // boots.
516
515
  await spawn('bash', [
517
516
  '-lc',
518
- `printf '%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' >> "${configPath}"`,
517
+ `printf '%s\n%s\n%s\n' 'disk.dataPartition.size=${diskSize}' 'vm.heapSize=${heapSize}' 'hw.cpu.ncore=${getEmulatorCpuCores()}' >> "${configPath}"`,
519
518
  ]);
520
519
  };
521
520
 
@@ -527,7 +526,7 @@ export const deleteAvd = async (name: string): Promise<void> => {
527
526
  {
528
527
  force: true,
529
528
  recursive: true,
530
- },
529
+ }
531
530
  );
532
531
  await rm(
533
532
  `${
@@ -535,18 +534,18 @@ export const deleteAvd = async (name: string): Promise<void> => {
535
534
  }/${name}.ini`,
536
535
  {
537
536
  force: true,
538
- },
537
+ }
539
538
  );
540
539
  };
541
540
 
542
541
  export const startEmulator = async (
543
542
  name: string,
544
- mode: EmulatorBootMode = 'default-boot',
543
+ mode: EmulatorBootMode = 'default-boot'
545
544
  ): Promise<void> => {
546
545
  const emulatorBinaryPath = await ensureEmulatorInstalled();
547
546
  const childProcess = emulatorProcess.startDetachedProcess(
548
547
  emulatorBinaryPath,
549
- getEmulatorStartupArgs(name, mode),
548
+ getEmulatorStartupArgs(name, mode)
550
549
  );
551
550
 
552
551
  let stdout = '';
@@ -582,7 +581,7 @@ export const startEmulator = async (
582
581
  stdout,
583
582
  stderr,
584
583
  error,
585
- }),
584
+ })
586
585
  );
587
586
  });
588
587
 
@@ -594,7 +593,7 @@ export const startEmulator = async (
594
593
  stderr,
595
594
  exitCode,
596
595
  signal,
597
- }),
596
+ })
598
597
  );
599
598
  });
600
599
  });
@@ -609,13 +608,19 @@ export const startEmulator = async (
609
608
  throw error;
610
609
  });
611
610
 
612
- const observationTimeout = wait(EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS).then(
613
- () => 'timeout' as const,
611
+ const observationTimer = delay(EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS);
612
+ const observationTimeout = observationTimer.promise.then(
613
+ () => 'timeout' as const
614
614
  );
615
615
 
616
616
  try {
617
617
  await Promise.race([earlyExit, observedBoot, observationTimeout]);
618
618
  } finally {
619
+ // If `earlyExit` or `observedBoot` won the race, this timer would
620
+ // otherwise sit ref'd in the event loop for the rest of its 5s;
621
+ // cancelling here is a no-op on the happy "proceed detached" path
622
+ // where the timer itself won.
623
+ observationTimer.cancel();
619
624
  cleanup();
620
625
  }
621
626
 
@@ -626,7 +631,7 @@ export const startEmulator = async (
626
631
 
627
632
  export const waitForEmulator = async (
628
633
  name: string,
629
- signal: AbortSignal,
634
+ signal: AbortSignal
630
635
  ): Promise<string> => {
631
636
  while (!signal.aborted) {
632
637
  const adbIds = await getDeviceIds();
@@ -651,7 +656,7 @@ export const waitForEmulator = async (
651
656
 
652
657
  export const waitForEmulatorDisconnect = async (
653
658
  adbId: string,
654
- signal: AbortSignal,
659
+ signal: AbortSignal
655
660
  ): Promise<void> => {
656
661
  while (!signal.aborted) {
657
662
  const adbIds = await getDeviceIds();
@@ -668,7 +673,7 @@ export const waitForEmulatorDisconnect = async (
668
673
 
669
674
  export const waitForBoot = async (
670
675
  name: string,
671
- signal: AbortSignal,
676
+ signal: AbortSignal
672
677
  ): Promise<string> => {
673
678
  while (!signal.aborted) {
674
679
  const adbIds = await getDeviceIds();
@@ -697,14 +702,14 @@ export const waitForBoot = async (
697
702
 
698
703
  export const isAppRunning = async (
699
704
  adbId: string,
700
- bundleId: string,
705
+ bundleId: string
701
706
  ): Promise<boolean> => {
702
707
  return (await getAppPid(adbId, bundleId)) != null;
703
708
  };
704
709
 
705
710
  export const getAppPid = async (
706
711
  adbId: string,
707
- bundleId: string,
712
+ bundleId: string
708
713
  ): Promise<number | null> => {
709
714
  try {
710
715
  const { stdout } = await spawn(getAdbBinaryPath(), [
@@ -731,7 +736,7 @@ export const getAppPid = async (
731
736
 
732
737
  export const getAppUid = async (
733
738
  adbId: string,
734
- bundleId: string,
739
+ bundleId: string
735
740
  ): Promise<number> => {
736
741
  const { stdout } = await spawn(getAdbBinaryPath(), [
737
742
  '-s',
@@ -756,7 +761,7 @@ export const getAppUid = async (
756
761
 
757
762
  export const setHideErrorDialogs = async (
758
763
  adbId: string,
759
- hide: boolean,
764
+ hide: boolean
760
765
  ): Promise<void> => {
761
766
  await spawn(getAdbBinaryPath(), [
762
767
  '-s',
@@ -785,10 +790,12 @@ export const getLogcatTimestamp = async (adbId: string): Promise<string> => {
785
790
  export const startLogcat = (
786
791
  adbId: string,
787
792
  args: readonly string[],
793
+ options?: { signal?: AbortSignal }
788
794
  ): Subprocess =>
789
795
  spawn(getAdbBinaryPath(), ['-s', adbId, ...args], {
790
796
  stdout: 'pipe',
791
797
  stderr: 'pipe',
798
+ signal: options?.signal,
792
799
  });
793
800
 
794
801
  export const DROPBOX_CRASH_TAGS = [
@@ -798,7 +805,7 @@ export const DROPBOX_CRASH_TAGS = [
798
805
 
799
806
  export const getDropboxPrint = async (
800
807
  adbId: string,
801
- tags: readonly string[] = DROPBOX_CRASH_TAGS,
808
+ tags: readonly string[] = DROPBOX_CRASH_TAGS
802
809
  ): Promise<string> => {
803
810
  // Android treats multiple args after --print as one search string, so each
804
811
  // tag must be queried separately and merged on the host.
@@ -815,7 +822,7 @@ export const getDropboxPrint = async (
815
822
  ]);
816
823
 
817
824
  return stdout;
818
- }),
825
+ })
819
826
  );
820
827
 
821
828
  return outputs.join('\n');
@@ -823,7 +830,7 @@ export const getDropboxPrint = async (
823
830
 
824
831
  export const getActivityExitInfo = async (
825
832
  adbId: string,
826
- bundleId: string,
833
+ bundleId: string
827
834
  ): Promise<string> => {
828
835
  const { stdout } = await spawn(getAdbBinaryPath(), [
829
836
  '-s',
@@ -891,7 +898,7 @@ export const getConnectedDevices = async (): Promise<AdbDevice[]> => {
891
898
 
892
899
  export const grantPermissions = async (
893
900
  adbId: string,
894
- bundleId: string,
901
+ bundleId: string
895
902
  ): Promise<void> => {
896
903
  androidAdbLogger.debug('grantPermissions:start %o', {
897
904
  adbId,
@@ -908,7 +915,7 @@ export const grantPermissions = async (
908
915
  getDangerousPermissions(adbId),
909
916
  ]);
910
917
  const permissions = requestedPermissions.filter((permission) =>
911
- dangerousPermissions.has(permission),
918
+ dangerousPermissions.has(permission)
912
919
  );
913
920
 
914
921
  androidAdbLogger.debug('grantPermissions:resolved %o', {
@@ -944,7 +951,7 @@ export const grantPermissions = async (
944
951
  });
945
952
 
946
953
  await Promise.all(
947
- grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[])),
954
+ grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[]))
948
955
  );
949
956
 
950
957
  androidAdbLogger.debug('grantPermissions:success %o', {
@@ -62,14 +62,6 @@ const isCrashSignal = (line: string, bundleId: string): boolean => {
62
62
  );
63
63
  };
64
64
 
65
- const stopSubprocess = async (child: Subprocess) => {
66
- try {
67
- (await child.nodeChildProcess).kill();
68
- } catch {
69
- // Ignore termination failures for already-ended background processes.
70
- }
71
- };
72
-
73
65
  const isExitedState = (
74
66
  state: AppSessionState
75
67
  ): state is Extract<AppSessionState, { status: 'exited' }> =>
@@ -82,10 +74,20 @@ type CreateAndroidAppSessionOptions = {
82
74
  stopApp: () => Promise<void>;
83
75
  getAppPid: () => Promise<number | null>;
84
76
  getLogcatTimestamp: () => Promise<string>;
85
- startLogcat: (args: readonly string[]) => Subprocess;
77
+ startLogcat: (
78
+ args: readonly string[],
79
+ options: { signal: AbortSignal }
80
+ ) => Subprocess;
86
81
  getDropboxOutput?: () => Promise<string>;
87
82
  getExitInfo?: () => Promise<string>;
88
83
  crashArtifactWriter?: CrashArtifactWriter;
84
+ /**
85
+ * Session-lifetime abort signal (see HarnessPlatformInitOptions.signal).
86
+ * Combined with the app session's own logcatAbortController so that
87
+ * aborting the session stops the logcat stream even if dispose() hasn't
88
+ * been called yet (e.g. SIGTERM during teardown of other resources).
89
+ */
90
+ signal?: AbortSignal;
89
91
  };
90
92
 
91
93
  export const createAndroidAppSession = async ({
@@ -99,6 +101,7 @@ export const createAndroidAppSession = async ({
99
101
  getDropboxOutput,
100
102
  getExitInfo,
101
103
  crashArtifactWriter,
104
+ signal,
102
105
  }: CreateAndroidAppSessionOptions): Promise<AppSession> => {
103
106
  const emitter = createAppSessionEmitter();
104
107
  const logBuffer = createBoundedLogBuffer();
@@ -193,7 +196,20 @@ export const createAndroidAppSession = async ({
193
196
 
194
197
  const logcatTimestamp = await getLogcatTimestamp();
195
198
  const sessionStartedAt = Date.now();
196
- const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp));
199
+ const logcatAbortController = new AbortController();
200
+ const logcatSignal = signal
201
+ ? AbortSignal.any([signal, logcatAbortController.signal])
202
+ : logcatAbortController.signal;
203
+ const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp), {
204
+ signal: logcatSignal,
205
+ });
206
+ // Aborting is nano-spawn's own cancellation path, so the resulting
207
+ // SubprocessError is settled the same way regardless of which of the
208
+ // merged stdout/stderr iterators observes it first. Without this, killing
209
+ // the underlying process directly can make both iterators reject at once,
210
+ // and nano-spawn's internal `Promise.race` only reports one of them,
211
+ // leaving the other an unhandled rejection.
212
+ logcatProcess.catch(() => undefined);
197
213
  const crashReporter = createAndroidCrashReporter({
198
214
  bundleId,
199
215
  crashArtifactWriter,
@@ -239,7 +255,7 @@ export const createAndroidAppSession = async ({
239
255
  disposed = true;
240
256
  stopPolling = true;
241
257
  emitter.clear();
242
- await stopSubprocess(logcatProcess);
258
+ logcatAbortController.abort();
243
259
  await Promise.allSettled([logTask]);
244
260
  throw error;
245
261
  }
@@ -287,7 +303,7 @@ export const createAndroidAppSession = async ({
287
303
  cancelPendingPollDelay();
288
304
 
289
305
  emitter.clear();
290
- await stopSubprocess(logcatProcess);
306
+ logcatAbortController.abort();
291
307
  await stopApp();
292
308
  await Promise.allSettled([logTask, pollTask]);
293
309
  },
package/src/avd-config.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { access, readFile } from 'node:fs/promises';
2
2
  import type { AndroidSystemImageArch } from './environment.js';
3
3
  import type { AndroidEmulator, AndroidEmulatorAVDConfig } from './config.js';
4
+ import { getEmulatorCpuCores } from './emulator-startup.js';
4
5
 
5
6
  export type AvdConfig = {
6
7
  imageSysdir1?: string;
@@ -8,6 +9,7 @@ export type AvdConfig = {
8
9
  hwDeviceName?: string;
9
10
  diskDataPartitionSize?: string;
10
11
  vmHeapSize?: string;
12
+ hwCpuNcore?: string;
11
13
  };
12
14
 
13
15
  export type AvdCompatibilityResult =
@@ -123,6 +125,9 @@ export const parseAvdConfig = (contents: string): AvdConfig => {
123
125
  case 'vm.heapSize':
124
126
  config.vmHeapSize = value;
125
127
  break;
128
+ case 'hw.cpu.ncore':
129
+ config.hwCpuNcore = value;
130
+ break;
126
131
  default:
127
132
  break;
128
133
  }
@@ -235,6 +240,18 @@ export const isAvdCompatible = ({
235
240
  };
236
241
  }
237
242
 
243
+ if (
244
+ normalizeConfigValue(avdConfig.hwCpuNcore ?? '') !==
245
+ normalizeConfigValue(String(getEmulatorCpuCores()))
246
+ ) {
247
+ return {
248
+ compatible: false,
249
+ reason: `CPU core count mismatch: expected ${getEmulatorCpuCores()}, got ${
250
+ avdConfig.hwCpuNcore ?? 'missing'
251
+ }.`,
252
+ };
253
+ }
254
+
238
255
  return { compatible: true };
239
256
  };
240
257
 
@@ -1,14 +1,37 @@
1
+ import {
2
+ getDeviceCoreBudget,
3
+ logger,
4
+ spawn,
5
+ SubprocessError,
6
+ } from '@react-native-harness/tools';
7
+ import os from 'node:os';
8
+ import { getEmulatorBinaryPath } from './environment.js';
9
+
10
+ const emulatorStartupLogger = logger.child('android-emulator-startup');
11
+
1
12
  export type EmulatorBootMode =
2
13
  | 'default-boot'
3
14
  | 'clean-snapshot-generation'
4
15
  | 'snapshot-reuse';
5
16
 
17
+ /**
18
+ * Returns the vCPU count baked into an AVD for this runner.
19
+ *
20
+ * AVD snapshots require an identical hardware configuration to load, so all
21
+ * AVD creation, compatibility, and cache-key callers must use this value on
22
+ * the same host.
23
+ */
24
+ export const getEmulatorCpuCores = (): number => {
25
+ return getDeviceCoreBudget(os.availableParallelism());
26
+ };
27
+
6
28
  const COMMON_EMULATOR_ARGS = [
7
29
  '-no-window',
8
30
  '-gpu',
9
31
  'swiftshader_indirect',
10
32
  '-noaudio',
11
33
  '-no-boot-anim',
34
+ '-no-metrics',
12
35
  ] as const;
13
36
 
14
37
  export const getEmulatorStartupArgs = (
@@ -24,3 +47,54 @@ export const getEmulatorStartupArgs = (
24
47
 
25
48
  return [`@${name}`, ...modeArgs, ...COMMON_EMULATOR_ARGS];
26
49
  };
50
+
51
+ /**
52
+ * Parses the output of `emulator -accel-check` to determine whether
53
+ * hardware acceleration (KVM on Linux, HVF on macOS) is usable. The
54
+ * emulator prints a message containing "is installed and usable" when
55
+ * acceleration is available.
56
+ */
57
+ export const isAccelerationUsable = (accelCheckOutput: string): boolean => {
58
+ return accelCheckOutput.includes('is installed and usable');
59
+ };
60
+
61
+ /**
62
+ * Runs `emulator -accel-check` and logs a non-fatal warning when hardware
63
+ * acceleration (KVM/HVF) appears unavailable, in which case the emulator
64
+ * falls back to very slow software emulation. This check must never fail or
65
+ * block emulator startup - any error running it is swallowed and only
66
+ * debug-logged.
67
+ */
68
+ export const warnIfEmulatorAccelerationUnavailable =
69
+ async (): Promise<void> => {
70
+ let combinedOutput: string;
71
+
72
+ try {
73
+ const emulatorBinaryPath = getEmulatorBinaryPath();
74
+ const { stdout, stderr } = await spawn(emulatorBinaryPath, [
75
+ '-accel-check',
76
+ ]);
77
+ combinedOutput = `${stdout}\n${stderr}`;
78
+ } catch (error) {
79
+ if (error instanceof SubprocessError) {
80
+ // The check itself ran but exited non-zero - this is how
81
+ // `-accel-check` reports that acceleration is unavailable, so treat
82
+ // its output as a normal (non-fatal) result rather than swallowing it.
83
+ combinedOutput = `${error.stdout}\n${error.stderr}`;
84
+ } else {
85
+ // Failed to even run the check (e.g. binary missing). This must never
86
+ // fail or block emulator startup.
87
+ emulatorStartupLogger.debug(
88
+ 'Failed to run "emulator -accel-check": %o',
89
+ error
90
+ );
91
+ return;
92
+ }
93
+ }
94
+
95
+ if (!isAccelerationUsable(combinedOutput)) {
96
+ logger.warn(
97
+ 'Hardware acceleration (KVM/HVF) appears unavailable for the Android emulator. It will fall back to very slow software emulation.'
98
+ );
99
+ }
100
+ };
package/src/index.ts CHANGED
@@ -9,5 +9,6 @@ export {
9
9
  resolveAvdCachingEnabled,
10
10
  } from './avd-config.js';
11
11
  export { getHostAndroidSystemImageArch } from './environment.js';
12
+ export { getEmulatorCpuCores } from './emulator-startup.js';
12
13
  export { HarnessAppPathError, HarnessEmulatorConfigError } from './errors.js';
13
14
  export { getRunTargets } from './targets.js';