Skip to content

Commit 3ebb342

Browse files
committed
perf: cache app resolution
1 parent d1a7641 commit 3ebb342

7 files changed

Lines changed: 438 additions & 56 deletions

File tree

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { test } from 'vitest';
2+
import assert from 'node:assert/strict';
3+
import { createAppResolutionCache } from '../app-resolution-cache.ts';
4+
5+
test('app resolution cache returns values until the expiry boundary', () => {
6+
let nowMs = 1_000;
7+
const cache = createAppResolutionCache<string>({ ttlMs: 50, nowMs: () => nowMs });
8+
const scope = { platform: 'android', deviceId: 'device-a' } as const;
9+
10+
assert.equal(cache.set(scope, 'Maps', 'com.example.maps'), 'com.example.maps');
11+
assert.equal(cache.get(scope, 'maps'), 'com.example.maps');
12+
13+
nowMs = 1_049;
14+
assert.equal(cache.get(scope, 'Maps'), 'com.example.maps');
15+
16+
nowMs = 1_050;
17+
assert.equal(cache.get(scope, 'Maps'), undefined);
18+
assert.equal(cache.get(scope, 'Maps'), undefined);
19+
});
20+
21+
test('app resolution cache clear removes all variants for one device', () => {
22+
const cache = createAppResolutionCache<string>({ nowMs: () => 0 });
23+
const mobile = { platform: 'android', deviceId: 'device-a', variant: 'mobile' } as const;
24+
const tv = { platform: 'android', deviceId: 'device-a', variant: 'tv' } as const;
25+
const otherDevice = { platform: 'android', deviceId: 'device-b', variant: 'mobile' } as const;
26+
const otherPlatform = { platform: 'ios', deviceId: 'device-a', variant: 'simulator' } as const;
27+
28+
cache.set(mobile, 'Maps', 'com.example.mobile.maps');
29+
cache.set(tv, 'Maps', 'com.example.tv.maps');
30+
cache.set(otherDevice, 'Maps', 'com.example.other.maps');
31+
cache.set(otherPlatform, 'Maps', 'com.example.ios.maps');
32+
33+
cache.clear(mobile);
34+
35+
assert.equal(cache.get(mobile, 'Maps'), undefined);
36+
assert.equal(cache.get(tv, 'Maps'), undefined);
37+
assert.equal(cache.get(otherDevice, 'Maps'), 'com.example.other.maps');
38+
assert.equal(cache.get(otherPlatform, 'Maps'), 'com.example.ios.maps');
39+
});

src/platforms/android/__tests__/index.test.ts

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getAndroidKeyboardState,
1111
inferAndroidAppName,
1212
installAndroidApp,
13+
installAndroidInstallablePath,
1314
isAmStartError,
1415
listAndroidApps,
1516
openAndroidApp,
@@ -978,6 +979,82 @@ test('resolveAndroidApp does not treat file paths as package names', async () =>
978979
);
979980
});
980981

982+
test('resolveAndroidApp caches display-name package matches but bypasses exact package ids', async () => {
983+
await withMockedAdb(
984+
'agent-device-android-resolve-cache-',
985+
[
986+
'#!/bin/sh',
987+
'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
988+
'if [ "$1" = "-s" ]; then shift; shift; fi',
989+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then',
990+
' echo "package:com.example.cachemaps"',
991+
' exit 0',
992+
'fi',
993+
'exit 1',
994+
'',
995+
].join('\n'),
996+
async ({ argsLogPath, device }) => {
997+
const first = await resolveAndroidApp(device, 'cachemaps');
998+
const second = await resolveAndroidApp(device, 'cachemaps');
999+
const exact = await resolveAndroidApp(device, 'com.example.cachemaps');
1000+
1001+
assert.deepEqual(first, { type: 'package', value: 'com.example.cachemaps' });
1002+
assert.deepEqual(second, first);
1003+
assert.deepEqual(exact, { type: 'package', value: 'com.example.cachemaps' });
1004+
1005+
const logged = await fs.readFile(argsLogPath, 'utf8');
1006+
assert.equal((logged.match(/pm list packages/g) ?? []).length, 1);
1007+
},
1008+
);
1009+
});
1010+
1011+
test('installAndroidInstallablePath invalidates cached display-name package matches', async () => {
1012+
await withMockedAdb(
1013+
'agent-device-android-install-cache-',
1014+
[
1015+
'#!/bin/sh',
1016+
'printf "%s\\n" "$*" >> "$AGENT_DEVICE_TEST_ARGS_FILE"',
1017+
'if [ "$1" = "-s" ]; then shift; shift; fi',
1018+
'if [ "$1" = "shell" ] && [ "$2" = "pm" ] && [ "$3" = "list" ] && [ "$4" = "packages" ]; then',
1019+
' if [ -f "$AGENT_DEVICE_TEST_INSTALL_MARKER" ]; then',
1020+
' echo "package:com.example.installedcachemaps"',
1021+
' else',
1022+
' echo "package:com.example.cachemaps"',
1023+
' fi',
1024+
' exit 0',
1025+
'fi',
1026+
'if [ "$1" = "install" ] && [ "$2" = "-r" ]; then',
1027+
' : > "$AGENT_DEVICE_TEST_INSTALL_MARKER"',
1028+
' exit 0',
1029+
'fi',
1030+
'exit 1',
1031+
'',
1032+
].join('\n'),
1033+
async ({ device }) => {
1034+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'agent-device-android-cache-apk-'));
1035+
const apkPath = path.join(tmpDir, 'App.apk');
1036+
const previousMarker = process.env.AGENT_DEVICE_TEST_INSTALL_MARKER;
1037+
process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = path.join(tmpDir, 'installed.marker');
1038+
try {
1039+
await fs.writeFile(apkPath, '', 'utf8');
1040+
const before = await resolveAndroidApp(device, 'cachemaps');
1041+
await installAndroidInstallablePath(device, apkPath);
1042+
const after = await resolveAndroidApp(device, 'cachemaps');
1043+
1044+
assert.deepEqual(before, { type: 'package', value: 'com.example.cachemaps' });
1045+
assert.deepEqual(after, { type: 'package', value: 'com.example.installedcachemaps' });
1046+
} finally {
1047+
if (previousMarker === undefined) {
1048+
delete process.env.AGENT_DEVICE_TEST_INSTALL_MARKER;
1049+
} else {
1050+
process.env.AGENT_DEVICE_TEST_INSTALL_MARKER = previousMarker;
1051+
}
1052+
await fs.rm(tmpDir, { recursive: true, force: true });
1053+
}
1054+
},
1055+
);
1056+
});
1057+
9811058
test('openAndroidApp default launch uses -p package flag', async () => {
9821059
await withMockedAdb(
9831060
'agent-device-android-open-default-',

src/platforms/android/app-lifecycle.ts

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { resolveFileOverridePath, runCmd, whichCmd } from '../../utils/exec.ts';
55
import { AppError } from '../../utils/errors.ts';
66
import type { DeviceInfo } from '../../utils/device.ts';
77
import { isDeepLinkTarget } from '../../core/open-target.ts';
8+
import { createAppResolutionCache, type AppResolutionCacheScope } from '../app-resolution-cache.ts';
89
import { waitForAndroidBoot } from './devices.ts';
910
import { adbArgs } from './adb.ts';
1011
import { classifyAndroidAppTarget } from './open-target.ts';
@@ -34,16 +35,28 @@ const ANDROID_APPS_DISCOVERY_HINT =
3435
const ANDROID_AMBIGUOUS_APP_HINT =
3536
'Run agent-device apps --platform android to see the exact installed package names before retrying open.';
3637

38+
type AndroidAppResolution = { type: 'intent' | 'package'; value: string };
39+
40+
const androidAppResolutionCache = createAppResolutionCache<AndroidAppResolution>();
41+
42+
function androidAppResolutionScope(device: DeviceInfo): AppResolutionCacheScope {
43+
return { platform: 'android', deviceId: device.id, variant: device.target ?? '' };
44+
}
45+
3746
export async function resolveAndroidApp(
3847
device: DeviceInfo,
3948
app: string,
40-
): Promise<{ type: 'intent' | 'package'; value: string }> {
49+
): Promise<AndroidAppResolution> {
4150
const trimmed = app.trim();
4251
if (classifyAndroidAppTarget(trimmed) === 'package') return { type: 'package', value: trimmed };
4352

4453
const alias = ALIASES[trimmed.toLowerCase()];
4554
if (alias) return alias;
4655

56+
const cacheScope = androidAppResolutionScope(device);
57+
const cached = androidAppResolutionCache.get(cacheScope, trimmed);
58+
if (cached) return cached;
59+
4760
const result = await runCmd('adb', adbArgs(device, ['shell', 'pm', 'list', 'packages']));
4861
const packages = result.stdout
4962
.split('\n')
@@ -54,7 +67,10 @@ export async function resolveAndroidApp(
5467
pkg.toLowerCase().includes(trimmed.toLowerCase()),
5568
);
5669
if (matches.length === 1) {
57-
return { type: 'package', value: matches[0] };
70+
return androidAppResolutionCache.set(cacheScope, trimmed, {
71+
type: 'package',
72+
value: matches[0],
73+
});
5874
}
5975

6076
if (matches.length > 1) {
@@ -560,10 +576,17 @@ export async function installAndroidInstallablePath(
560576
device: DeviceInfo,
561577
installablePath: string,
562578
): Promise<void> {
579+
const cacheScope = androidAppResolutionScope(device);
580+
androidAppResolutionCache.clear(cacheScope);
563581
if (!device.booted) {
564582
await waitForAndroidBoot(device.id);
565583
}
566-
await installAndroidAppFiles(device, installablePath);
584+
try {
585+
await installAndroidAppFiles(device, installablePath);
586+
} finally {
587+
// A concurrent name lookup can finish after the initial clear and repopulate stale data.
588+
androidAppResolutionCache.clear(cacheScope);
589+
}
567590
}
568591

569592
export async function installAndroidInstallablePathAndResolvePackageName(
@@ -617,18 +640,24 @@ export async function reinstallAndroidApp(
617640
app: string,
618641
appPath: string,
619642
): Promise<{ package: string }> {
643+
const cacheScope = androidAppResolutionScope(device);
644+
androidAppResolutionCache.clear(cacheScope);
620645
if (!device.booted) {
621646
await waitForAndroidBoot(device.id);
622647
}
623-
const { package: pkg } = await uninstallAndroidApp(device, app);
624-
const prepared = await prepareAndroidInstallArtifact(
625-
{ kind: 'path', path: appPath },
626-
{ resolveIdentity: false },
627-
);
628648
try {
629-
await installAndroidInstallablePath(device, prepared.installablePath);
649+
const { package: pkg } = await uninstallAndroidApp(device, app);
650+
const prepared = await prepareAndroidInstallArtifact(
651+
{ kind: 'path', path: appPath },
652+
{ resolveIdentity: false },
653+
);
654+
try {
655+
await installAndroidInstallablePath(device, prepared.installablePath);
656+
} finally {
657+
await prepared.cleanup();
658+
}
630659
return { package: pkg };
631660
} finally {
632-
await prepared.cleanup();
661+
androidAppResolutionCache.clear(cacheScope);
633662
}
634663
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
const APP_RESOLUTION_CACHE_TTL_MS = 30_000;
2+
3+
export type AppResolutionCacheScope = {
4+
platform: 'android' | 'ios' | 'macos';
5+
deviceId: string;
6+
variant?: string;
7+
};
8+
9+
type AppResolutionCacheEntry<T> = {
10+
value: T;
11+
expiresAtMs: number;
12+
};
13+
14+
type AppResolutionCache<T> = {
15+
get(scope: AppResolutionCacheScope, target: string): T | undefined;
16+
set(scope: AppResolutionCacheScope, target: string, value: T): T;
17+
clear(scope: AppResolutionCacheScope): void;
18+
};
19+
20+
export function createAppResolutionCache<T>(
21+
options: { ttlMs?: number; nowMs?: () => number } = {},
22+
): AppResolutionCache<T> {
23+
const ttlMs = options.ttlMs ?? APP_RESOLUTION_CACHE_TTL_MS;
24+
const nowMs = options.nowMs ?? Date.now;
25+
const entries = new Map<string, AppResolutionCacheEntry<T>>();
26+
27+
return {
28+
get(scope, target) {
29+
const key = buildAppResolutionCacheKey(scope, target);
30+
const entry = entries.get(key);
31+
if (!entry) return undefined;
32+
if (entry.expiresAtMs <= nowMs()) {
33+
entries.delete(key);
34+
return undefined;
35+
}
36+
return entry.value;
37+
},
38+
set(scope, target, value) {
39+
entries.set(buildAppResolutionCacheKey(scope, target), {
40+
value,
41+
expiresAtMs: nowMs() + ttlMs,
42+
});
43+
return value;
44+
},
45+
clear(scope) {
46+
const prefix = buildAppResolutionCacheScopePrefix(scope);
47+
for (const key of entries.keys()) {
48+
if (key.startsWith(prefix)) {
49+
entries.delete(key);
50+
}
51+
}
52+
},
53+
};
54+
}
55+
56+
function buildAppResolutionCacheKey(scope: AppResolutionCacheScope, target: string): string {
57+
return [scope.platform, scope.deviceId, scope.variant ?? '', target.trim().toLowerCase()].join(
58+
'\0',
59+
);
60+
}
61+
62+
function buildAppResolutionCacheScopePrefix(scope: AppResolutionCacheScope): string {
63+
return [scope.platform, scope.deviceId, ''].join('\0');
64+
}

0 commit comments

Comments
 (0)