Skip to content

Commit 3a4cfb6

Browse files
committed
perf: cache safe device hot paths
1 parent d1a7641 commit 3a4cfb6

6 files changed

Lines changed: 457 additions & 151 deletions

File tree

src/core/__tests__/dispatch-resolve.test.ts

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,27 @@
11
import { beforeEach, test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
33

4+
const { mockFindBootableIosSimulator, mockListAppleDevices } = vi.hoisted(() => ({
5+
mockFindBootableIosSimulator: vi.fn(),
6+
mockListAppleDevices: vi.fn(),
7+
}));
8+
49
vi.mock('../../platforms/ios/devices.ts', async (importOriginal) => {
510
const actual = await importOriginal<typeof import('../../platforms/ios/devices.ts')>();
6-
return { ...actual, findBootableIosSimulator: vi.fn() };
11+
return {
12+
...actual,
13+
findBootableIosSimulator: mockFindBootableIosSimulator,
14+
listAppleDevices: mockListAppleDevices,
15+
};
716
});
817

9-
import { resolveIosDevice } from '../dispatch-resolve.ts';
18+
import {
19+
resolveIosDevice,
20+
resolveTargetDevice,
21+
withResolveTargetDeviceCacheScope,
22+
} from '../dispatch-resolve.ts';
1023
import type { DeviceInfo } from '../../utils/device.ts';
1124
import { AppError } from '../../utils/errors.ts';
12-
import { findBootableIosSimulator } from '../../platforms/ios/devices.ts';
1325

1426
const physical: DeviceInfo = {
1527
platform: 'ios',
@@ -38,11 +50,10 @@ const bootedSimulator: DeviceInfo = {
3850
booted: true,
3951
};
4052

41-
const mockFindBootableIosSimulator = vi.mocked(findBootableIosSimulator);
42-
4353
beforeEach(() => {
4454
mockFindBootableIosSimulator.mockReset();
4555
mockFindBootableIosSimulator.mockResolvedValue(null);
56+
mockListAppleDevices.mockReset();
4657
});
4758

4859
// --- Physical device rejected in favour of simulator fallback ---
@@ -112,3 +123,53 @@ test('resolveIosDevice returns simulator directly when present in device list',
112123
assert.equal(result.kind, 'simulator');
113124
assert.equal(mockFindBootableIosSimulator.mock.calls.length, 0);
114125
});
126+
127+
test('resolveTargetDevice reuses request-scoped device resolution cache for identical selectors', async () => {
128+
mockListAppleDevices.mockResolvedValue([bootedSimulator]);
129+
130+
const [first, second] = await withResolveTargetDeviceCacheScope(async () => [
131+
await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }),
132+
await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }),
133+
]);
134+
135+
assert.equal(first.id, 'sim-2');
136+
assert.equal(second.id, 'sim-2');
137+
assert.equal(mockListAppleDevices.mock.calls.length, 1);
138+
});
139+
140+
test('resolveTargetDevice request cache key separates device selectors', async () => {
141+
mockListAppleDevices.mockResolvedValue([simulator, bootedSimulator]);
142+
143+
await withResolveTargetDeviceCacheScope(async () => {
144+
await resolveTargetDevice({ platform: 'ios', device: 'iPhone 16' });
145+
await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' });
146+
});
147+
148+
assert.equal(mockListAppleDevices.mock.calls.length, 2);
149+
});
150+
151+
test('resolveTargetDevice does not reuse cache across request scopes', async () => {
152+
mockListAppleDevices.mockResolvedValue([bootedSimulator]);
153+
154+
await withResolveTargetDeviceCacheScope(
155+
async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }),
156+
);
157+
await withResolveTargetDeviceCacheScope(
158+
async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }),
159+
);
160+
161+
assert.equal(mockListAppleDevices.mock.calls.length, 2);
162+
});
163+
164+
test('resolveTargetDevice reuses cache across nested request scopes', async () => {
165+
mockListAppleDevices.mockResolvedValue([bootedSimulator]);
166+
167+
await withResolveTargetDeviceCacheScope(async () => {
168+
await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' });
169+
await withResolveTargetDeviceCacheScope(
170+
async () => await resolveTargetDevice({ platform: 'ios', device: 'iPhone 15' }),
171+
);
172+
});
173+
174+
assert.equal(mockListAppleDevices.mock.calls.length, 1);
175+
});

src/core/dispatch-resolve.ts

Lines changed: 60 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AsyncLocalStorage } from 'node:async_hooks';
12
import { AppError } from '../utils/errors.ts';
23
import {
34
normalizePlatformSelector,
@@ -28,6 +29,8 @@ type ResolveDeviceFlags = Pick<
2829
| 'androidDeviceAllowlist'
2930
>;
3031

32+
const resolveTargetDeviceCacheScope = new AsyncLocalStorage<Map<string, DeviceInfo>>();
33+
3134
type AppleDeviceSelector = {
3235
platform?: Exclude<PlatformSelector, 'android'>;
3336
target?: DeviceTarget;
@@ -98,6 +101,14 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise<De
98101
target: flags.target,
99102
});
100103
const androidSerialAllowlist = resolveAndroidSerialAllowlist(flags.androidDeviceAllowlist);
104+
const cacheKey = buildResolveTargetDeviceCacheKey({
105+
flags,
106+
normalizedPlatform,
107+
iosSimulatorSetPath,
108+
androidSerialAllowlist,
109+
});
110+
const cached = readResolveTargetDeviceCache(cacheKey);
111+
if (cached) return cached;
101112
return await withDiagnosticTimer(
102113
'resolve_target_device',
103114
async () => {
@@ -117,20 +128,23 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise<De
117128

118129
if (selector.platform === 'linux') {
119130
const devices = await listLinuxDevices();
120-
return await resolveDevice(devices, selector);
131+
return cacheResolvedTargetDevice(cacheKey, await resolveDevice(devices, selector));
121132
}
122133

123134
if (selector.platform === 'android') {
124135
await ensureAdb();
125136
const devices = await listAndroidDevices({ serialAllowlist: androidSerialAllowlist });
126-
return await resolveDevice(devices, selector);
137+
return cacheResolvedTargetDevice(cacheKey, await resolveDevice(devices, selector));
127138
}
128139

129140
if (selector.platform) {
130141
const devices = await listAppleDevices({ simulatorSetPath: iosSimulatorSetPath });
131-
return await resolveAppleDevice(devices, selector as AppleDeviceSelector, {
132-
simulatorSetPath: iosSimulatorSetPath,
133-
});
142+
return cacheResolvedTargetDevice(
143+
cacheKey,
144+
await resolveAppleDevice(devices, selector as AppleDeviceSelector, {
145+
simulatorSetPath: iosSimulatorSetPath,
146+
}),
147+
);
134148
}
135149

136150
const devices: DeviceInfo[] = [];
@@ -145,11 +159,51 @@ export async function resolveTargetDevice(flags: ResolveDeviceFlags): Promise<De
145159
try {
146160
devices.push(...(await listLinuxDevices()));
147161
} catch {}
148-
return await resolveDevice(devices, selector, { simulatorSetPath: iosSimulatorSetPath });
162+
return cacheResolvedTargetDevice(
163+
cacheKey,
164+
await resolveDevice(devices, selector, { simulatorSetPath: iosSimulatorSetPath }),
165+
);
149166
},
150167
{
151168
platform: normalizedPlatform,
152169
target: flags.target,
153170
},
154171
);
155172
}
173+
174+
export async function withResolveTargetDeviceCacheScope<T>(task: () => Promise<T>): Promise<T> {
175+
if (resolveTargetDeviceCacheScope.getStore()) return await task();
176+
return await resolveTargetDeviceCacheScope.run(new Map(), task);
177+
}
178+
179+
function readResolveTargetDeviceCache(cacheKey: string): DeviceInfo | undefined {
180+
const cache = resolveTargetDeviceCacheScope.getStore();
181+
const cached = cache?.get(cacheKey);
182+
if (!cached) return undefined;
183+
return { ...cached };
184+
}
185+
186+
function cacheResolvedTargetDevice(cacheKey: string, device: DeviceInfo): DeviceInfo {
187+
resolveTargetDeviceCacheScope.getStore()?.set(cacheKey, { ...device });
188+
return device;
189+
}
190+
191+
function buildResolveTargetDeviceCacheKey(params: {
192+
flags: ResolveDeviceFlags;
193+
normalizedPlatform?: PlatformSelector;
194+
iosSimulatorSetPath?: string;
195+
androidSerialAllowlist?: ReadonlySet<string>;
196+
}): string {
197+
const { flags, normalizedPlatform, iosSimulatorSetPath, androidSerialAllowlist } = params;
198+
return JSON.stringify({
199+
platform: normalizedPlatform,
200+
target: flags.target,
201+
device: flags.device,
202+
udid: flags.udid,
203+
serial: flags.serial,
204+
iosSimulatorSetPath,
205+
androidSerialAllowlist: androidSerialAllowlist
206+
? Array.from(androidSerialAllowlist).sort()
207+
: undefined,
208+
});
209+
}

src/core/dispatch.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
import { readNotificationPayload } from './dispatch-payload.ts';
4040
import { parseDeviceRotation } from './device-rotation.ts';
4141

42-
export { resolveTargetDevice } from './dispatch-resolve.ts';
42+
export { resolveTargetDevice, withResolveTargetDeviceCacheScope } from './dispatch-resolve.ts';
4343
export { shouldUseIosTapSeries, shouldUseIosDragSeries };
4444

4545
export type BatchStep = {

0 commit comments

Comments
 (0)