Skip to content

Commit d4678c8

Browse files
authored
perf(platform-ios): adapt startup to host capabilities (#174)
## What is this? Harness now chooses how to prepare the iOS XCTest permission agent according to host memory and CPU capacity. Constrained machines build the agent before starting simulator preparation, while capable machines overlap the two operations. This replaces the unconditional overlap proposed in #171 and adds a reusable host-capabilities helper for future resource-aware behavior. The PR also makes the Metro block list consume the canonical cache root produced by `createHarnessCache` instead of duplicating the cache directory layout. ## How does it work? `getHostCapabilities()` reports total memory and available CPU parallelism from Node. An internal, argument-free startup policy enables overlap only when the host has strictly more than 8 GiB of memory and more than 6 available CPUs. The selected sequential or parallel strategy is logged at debug level. Sequential startup awaits the XCTest build before checking, booting, and preparing the simulator. Parallel startup begins the build immediately, prepares the simulator concurrently, then waits for both before starting the agent. Both parallel branches are settled before cleanup so build and simulator failures propagate without leaving the controller, simulator override, or Harness-started simulator behind. Metro receives `harnessCache.paths.root` when constructing its block list and derives a cross-platform, exact-root exclusion from that value. Cache-path construction therefore remains owned by `@react-native-harness/cache`, and the cache boundary check stays strict without an allowlist. ## Why is this useful? Resource-constrained macOS runners avoid making `xcodebuild` and the simulator compete for limited RAM and CPU, preserving a build-first startup path when contention would likely make startup slower. More powerful hosts retain the cold-start speedup from overlapping independent work. Reusing the canonical cache root also prevents Metro and the cache package from drifting onto different directory layouts.
1 parent 2a679e0 commit d4678c8

11 files changed

Lines changed: 441 additions & 111 deletions

File tree

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
'@react-native-harness/tools': patch
3+
'@react-native-harness/platform-apple': patch
4+
---
5+
6+
Harness now adapts iOS permission-agent startup to the host's available memory
7+
and CPU capacity, preserving build-first startup on constrained machines while
8+
overlapping safe preparation work on capable hosts.

packages/bundler-metro/src/__tests__/metro-block-list.test.ts

Lines changed: 40 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -20,28 +20,46 @@ const withBlockList = (
2020
blockList: NonNullable<MetroConfig['resolver']>['blockList']
2121
): MetroConfig => ({ resolver: { blockList } }) as MetroConfig;
2222

23+
const HARNESS_CACHE_ROOT = '/p/.harness/cache';
24+
25+
const getBlockList = (
26+
blockList: NonNullable<MetroConfig['resolver']>['blockList']
27+
) => getHarnessBlockList(withBlockList(blockList), HARNESS_CACHE_ROOT);
28+
2329
describe('getHarnessBlockList', () => {
2430
describe('harness-owned exclusions', () => {
2531
it('excludes the cache harness creates under .harness', () => {
26-
const { blockList } = getHarnessBlockList(withBlockList(undefined));
32+
const { blockList } = getBlockList(undefined);
2733

2834
expect(blockList.test('/p/.harness/cache/metro/ab/cdef')).toBe(true);
2935
expect(blockList.test('/p/.harness/cache/metro-file-map/map.v1')).toBe(
3036
true
3137
);
38+
expect(blockList.test('\\p\\.harness\\cache\\metro\\ab\\cdef')).toBe(
39+
true
40+
);
41+
});
42+
43+
it('only excludes the canonical cache root', () => {
44+
const { blockList } = getBlockList(undefined);
45+
46+
expect(blockList.test('/other/.harness/cache/metro/ab/cdef')).toBe(false);
47+
expect(blockList.test('/p/.harness/cache-backup/metro/ab/cdef')).toBe(
48+
false
49+
);
3250
});
3351

3452
it('keeps the harness manifest crawlable', () => {
3553
// The manifest is injected via `serializer.getPolyfills`, so a module
3654
// missing from the file map fails with `Failed to get the SHA-1`.
37-
const { blockList } = getHarnessBlockList(withBlockList(undefined));
55+
const { blockList } = getBlockList(undefined);
3856

3957
expect(blockList.test(getHarnessManifestPath('/p'))).toBe(false);
4058
expect(blockList.test('/p/.harness/manifest.js')).toBe(false);
4159
});
4260

4361
it('adds nothing else of its own', () => {
44-
const { blockList } = getHarnessBlockList(withBlockList(undefined));
62+
const { blockList } = getBlockList(undefined);
4563

4664
for (const path of [
4765
'/p/.nx/cache/a.js',
@@ -60,47 +78,40 @@ describe('getHarnessBlockList', () => {
6078

6179
describe('inheriting the project blockList', () => {
6280
it('honours a plain project pattern', () => {
63-
const { blockList, dropped } = getHarnessBlockList(
64-
withBlockList(/[/\\]fixtures[/\\]/)
65-
);
81+
const { blockList, dropped } = getBlockList(/[/\\]fixtures[/\\]/);
6682

6783
expect(dropped).toEqual([]);
6884
expect(blockList.test('/p/src/fixtures/big.json')).toBe(true);
6985
expect(blockList.test('/p/src/app.tsx')).toBe(false);
7086
});
7187

7288
it('honours an array of project patterns', () => {
73-
const { blockList, dropped } = getHarnessBlockList(
74-
withBlockList([/[/\\]fixtures[/\\]/, /\.snap$/])
75-
);
89+
const { blockList, dropped } = getBlockList([
90+
/[/\\]fixtures[/\\]/,
91+
/\.snap$/,
92+
]);
7693

7794
expect(dropped).toEqual([]);
7895
expect(blockList.test('/p/src/fixtures/big.json')).toBe(true);
7996
expect(blockList.test('/p/src/a.snap')).toBe(true);
8097
});
8198

8299
it('honours an anchored project pattern', () => {
83-
const { blockList } = getHarnessBlockList(
84-
withBlockList(/^\/p\/vendor\//)
85-
);
100+
const { blockList } = getBlockList(/^\/p\/vendor\//);
86101

87102
expect(blockList.test('/p/vendor/lib.js')).toBe(true);
88103
expect(blockList.test('/other/p/vendor/lib.js')).toBe(false);
89104
});
90105

91106
it('preserves group numbering so backreferences still work', () => {
92-
const { blockList } = getHarnessBlockList(
93-
withBlockList(/([/\\])dup\1/)
94-
);
107+
const { blockList } = getBlockList(/([/\\])dup\1/);
95108

96109
expect(blockList.test('/p/dup/dup')).toBe(true);
97110
expect(blockList.test('/p/dup\\dup')).toBe(false);
98111
});
99112

100113
it('adopts project flags so a case-insensitive pattern keeps working', () => {
101-
const { blockList, dropped } = getHarnessBlockList(
102-
withBlockList(/[/\\]FIXTURES[/\\]/i)
103-
);
114+
const { blockList, dropped } = getBlockList(/[/\\]FIXTURES[/\\]/i);
104115

105116
expect(dropped).toEqual([]);
106117
expect(blockList.flags).toBe('i');
@@ -110,9 +121,11 @@ describe('getHarnessBlockList', () => {
110121
it('drops minority-flag patterns instead of letting Metro throw', () => {
111122
// Metro's array handling throws when combining mismatched flags.
112123
const minority = /[/\\]other[/\\]/i;
113-
const { blockList, dropped } = getHarnessBlockList(
114-
withBlockList([/[/\\]a[/\\]/, /[/\\]b[/\\]/, minority])
115-
);
124+
const { blockList, dropped } = getBlockList([
125+
/[/\\]a[/\\]/,
126+
/[/\\]b[/\\]/,
127+
minority,
128+
]);
116129

117130
expect(dropped).toEqual([
118131
{ pattern: minority, reason: 'incompatible-flags' },
@@ -123,9 +136,7 @@ describe('getHarnessBlockList', () => {
123136
});
124137

125138
it('is stateless across calls when given a global pattern', () => {
126-
const { blockList } = getHarnessBlockList(
127-
withBlockList(/[/\\]fixtures[/\\]/g)
128-
);
139+
const { blockList } = getBlockList(/[/\\]fixtures[/\\]/g);
129140

130141
expect(blockList.test('/p/src/fixtures/a.json')).toBe(true);
131142
expect(blockList.test('/p/src/fixtures/a.json')).toBe(true);
@@ -134,9 +145,7 @@ describe('getHarnessBlockList', () => {
134145

135146
describe('carving __tests__ out of the inherited blockList', () => {
136147
it("keeps tests crawlable under Metro's stock blockList", () => {
137-
const { blockList, dropped } = getHarnessBlockList(
138-
withBlockList(exclusionList())
139-
);
148+
const { blockList, dropped } = getBlockList(exclusionList());
140149

141150
expect(dropped).toEqual([]);
142151
expect(blockList.test('/p/src/__tests__/smoke.harness.ts')).toBe(false);
@@ -145,8 +154,8 @@ describe('getHarnessBlockList', () => {
145154
it("keeps a project's own exclusions while still crawling tests", () => {
146155
// exclusionList fuses the project's patterns and the __tests__ rule into
147156
// a single alternation, so the two cannot be separated by inspection.
148-
const { blockList, dropped } = getHarnessBlockList(
149-
withBlockList(exclusionList([/ios\/build\/.*/]))
157+
const { blockList, dropped } = getBlockList(
158+
exclusionList([/ios\/build\/.*/])
150159
);
151160

152161
expect(dropped).toEqual([]);
@@ -156,9 +165,7 @@ describe('getHarnessBlockList', () => {
156165
});
157166

158167
it('keeps tests crawlable even inside an otherwise excluded directory', () => {
159-
const { blockList } = getHarnessBlockList(
160-
withBlockList(/[/\\]generated[/\\]/)
161-
);
168+
const { blockList } = getBlockList(/[/\\]generated[/\\]/);
162169

163170
expect(blockList.test('/p/generated/a.js')).toBe(true);
164171
expect(blockList.test('/p/generated/__tests__/a.harness.ts')).toBe(false);
@@ -183,7 +190,7 @@ describe('getHarnessBlockList', () => {
183190
];
184191

185192
for (const pattern of patterns) {
186-
const { blockList } = getHarnessBlockList(withBlockList(pattern));
193+
const { blockList } = getBlockList(pattern);
187194

188195
for (const path of paths) {
189196
const expected =

packages/bundler-metro/src/metro-block-list.ts

Lines changed: 10 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
import type { MetroConfig } from 'metro-config';
2+
import { escapeRegExp } from '@react-native-harness/tools';
23

34
type BlockList = NonNullable<MetroConfig['resolver']>['blockList'];
45

56
/**
6-
* The only directory harness excludes on its own behalf: the cache it creates
7-
* and maintains under `.harness`, which Metro never needs to resolve or read.
8-
*
9-
* Scoped to `.harness/cache` rather than all of `.harness` on purpose --
10-
* `.harness/manifest.js` is served as a polyfill via
11-
* `serializer.getPolyfills`, and a module that harness injects into the graph
12-
* but that is missing from the file map fails the build with
13-
* `Failed to get the SHA-1 for: <path>`.
14-
*
15-
* Nothing else is added here. Excluding third-party directories (build output,
16-
* tool caches, native dependency trees) is the project's call, expressed
17-
* through `resolver.blockList` in its own `metro.config.js`, which harness
18-
* inherits below.
7+
* Converts the canonical cache root into a cross-platform regular-expression
8+
* source without duplicating knowledge of the cache directory layout.
199
*/
20-
const HARNESS_OWNED_EXCLUSIONS = /[/\\]\.harness[/\\]cache(?:[/\\]|$)/;
10+
const getHarnessCacheRootPatternSource = (harnessCacheRoot: string): string =>
11+
escapeRegExp(harnessCacheRoot).replace(/\\\\|\//g, '[/\\\\]');
2112

2213
/**
2314
* Paths harness must be able to crawl, carved out of whatever the project
@@ -84,7 +75,8 @@ export type BlockListDrop = {
8475
* conflicts by dropping a pattern instead of failing the run.
8576
*/
8677
export const getHarnessBlockList = (
87-
metroConfig: MetroConfig
78+
metroConfig: MetroConfig,
79+
harnessCacheRoot: string
8880
): { blockList: RegExp; dropped: BlockListDrop[] } => {
8981
const dropped: BlockListDrop[] = [];
9082
const userPatterns = toPatternArray(metroConfig.resolver?.blockList);
@@ -100,7 +92,9 @@ export const getHarnessBlockList = (
10092
const flags =
10193
[...flagCounts.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? '';
10294

103-
const sources = [HARNESS_OWNED_EXCLUSIONS.source];
95+
const sources = [
96+
`^${getHarnessCacheRootPatternSource(harnessCacheRoot)}(?:[/\\\\]|$)`,
97+
];
10498
for (const pattern of userPatterns) {
10599
if (stripStatefulFlags(pattern.flags) === flags) {
106100
sources.push(carveOutProtectedPaths(pattern.source));

packages/bundler-metro/src/withRnHarness.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export const withRnHarness = <T extends MetroConfig>(
5555
getHarnessBabelTransformerPath(metroConfig);
5656

5757
const { blockList: harnessBlockList, dropped: droppedBlockListPatterns } =
58-
getHarnessBlockList(metroConfig);
58+
getHarnessBlockList(metroConfig, harnessCache.paths.root);
5959

6060
for (const { pattern } of droppedBlockListPatterns) {
6161
metroBlockListLogger.warn(

0 commit comments

Comments
 (0)