Skip to content

Commit 2c73e39

Browse files
authored
perf: cache app resolution (#466)
1 parent 999b475 commit 2c73e39

7 files changed

Lines changed: 460 additions & 66 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
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+
});
40+
41+
test('app resolution cache invalidates before and after an operation', async () => {
42+
const cache = createAppResolutionCache<string>({ nowMs: () => 0 });
43+
const scope = { platform: 'ios', deviceId: 'device-a', variant: 'simulator' } as const;
44+
45+
cache.set(scope, 'Maps', 'com.example.before');
46+
47+
const result = await cache.invalidateWhile(scope, async () => {
48+
assert.equal(cache.get(scope, 'Maps'), undefined);
49+
cache.set(scope, 'Maps', 'com.example.during');
50+
return 'installed';
51+
});
52+
53+
assert.equal(result, 'installed');
54+
assert.equal(cache.get(scope, 'Maps'), undefined);
55+
});

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: 42 additions & 19 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,12 @@ export async function installAndroidInstallablePath(
560576
device: DeviceInfo,
561577
installablePath: string,
562578
): Promise<void> {
563-
if (!device.booted) {
564-
await waitForAndroidBoot(device.id);
565-
}
566-
await installAndroidAppFiles(device, installablePath);
579+
await androidAppResolutionCache.invalidateWhile(androidAppResolutionScope(device), async () => {
580+
if (!device.booted) {
581+
await waitForAndroidBoot(device.id);
582+
}
583+
await installAndroidAppFiles(device, installablePath);
584+
});
567585
}
568586

569587
export async function installAndroidInstallablePathAndResolvePackageName(
@@ -617,18 +635,23 @@ export async function reinstallAndroidApp(
617635
app: string,
618636
appPath: string,
619637
): Promise<{ package: string }> {
620-
if (!device.booted) {
621-
await waitForAndroidBoot(device.id);
622-
}
623-
const { package: pkg } = await uninstallAndroidApp(device, app);
624-
const prepared = await prepareAndroidInstallArtifact(
625-
{ kind: 'path', path: appPath },
626-
{ resolveIdentity: false },
638+
return await androidAppResolutionCache.invalidateWhile(
639+
androidAppResolutionScope(device),
640+
async () => {
641+
if (!device.booted) {
642+
await waitForAndroidBoot(device.id);
643+
}
644+
const { package: pkg } = await uninstallAndroidApp(device, app);
645+
const prepared = await prepareAndroidInstallArtifact(
646+
{ kind: 'path', path: appPath },
647+
{ resolveIdentity: false },
648+
);
649+
try {
650+
await installAndroidInstallablePath(device, prepared.installablePath);
651+
} finally {
652+
await prepared.cleanup();
653+
}
654+
return { package: pkg };
655+
},
627656
);
628-
try {
629-
await installAndroidInstallablePath(device, prepared.installablePath);
630-
return { package: pkg };
631-
} finally {
632-
await prepared.cleanup();
633-
}
634657
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
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+
invalidateWhile<Result>(
19+
scope: AppResolutionCacheScope,
20+
operation: () => Promise<Result>,
21+
): Promise<Result>;
22+
};
23+
24+
export function createAppResolutionCache<T>(
25+
options: { ttlMs?: number; nowMs?: () => number } = {},
26+
): AppResolutionCache<T> {
27+
const ttlMs = options.ttlMs ?? APP_RESOLUTION_CACHE_TTL_MS;
28+
const nowMs = options.nowMs ?? Date.now;
29+
const entries = new Map<string, AppResolutionCacheEntry<T>>();
30+
const clearScope = (scope: AppResolutionCacheScope): void => {
31+
const prefix = buildAppResolutionCacheScopePrefix(scope);
32+
for (const key of entries.keys()) {
33+
if (key.startsWith(prefix)) {
34+
entries.delete(key);
35+
}
36+
}
37+
};
38+
39+
return {
40+
get(scope, target) {
41+
const key = buildAppResolutionCacheKey(scope, target);
42+
const entry = entries.get(key);
43+
if (!entry) return undefined;
44+
if (entry.expiresAtMs <= nowMs()) {
45+
entries.delete(key);
46+
return undefined;
47+
}
48+
return entry.value;
49+
},
50+
set(scope, target, value) {
51+
entries.set(buildAppResolutionCacheKey(scope, target), {
52+
value,
53+
expiresAtMs: nowMs() + ttlMs,
54+
});
55+
return value;
56+
},
57+
clear(scope) {
58+
clearScope(scope);
59+
},
60+
async invalidateWhile(scope, operation) {
61+
clearScope(scope);
62+
try {
63+
return await operation();
64+
} finally {
65+
// A concurrent name lookup can finish after the initial clear and repopulate stale data.
66+
clearScope(scope);
67+
}
68+
},
69+
};
70+
}
71+
72+
function buildAppResolutionCacheKey(scope: AppResolutionCacheScope, target: string): string {
73+
return [scope.platform, scope.deviceId, scope.variant ?? '', target.trim().toLowerCase()].join(
74+
'\0',
75+
);
76+
}
77+
78+
function buildAppResolutionCacheScopePrefix(scope: AppResolutionCacheScope): string {
79+
return [scope.platform, scope.deviceId, ''].join('\0');
80+
}

0 commit comments

Comments
 (0)