Skip to content

Commit 4ec5915

Browse files
committed
fix(android): include artifact sha in helper session identity; evict stale install memo entries (PR #1281 re-review)
Same-version binary replacement changes only the APK sha, so identity guards keyed on package/runner/version/versionCode could not detect a crossover between two artifacts that differ only in bytes: - The artifact sha256 now joins the helper identity end-to-end: AndroidSnapshotHelperCaptureOptions gains helperSha256 (snapshot.ts passes artifact.manifest.sha256 alongside version/versionCode), the session record stores it, createSessionIdentity includes it (making snapshot session reuse sha-aware, consistent with the install path's existing sha check), and the touch identity guard compares it via the same both-defined rule. - ensureAndroidSnapshotHelper's install memo now evicts every other cached decision for the same device+package when it records an install/current decision, so installing B invalidates A's stale 'current' memo and a later command selecting A re-inspects the device instead of skipping the sha check. Regressions: 'a same-version artifact with a different sha stops the live session and runs one-shot' (touch-helper-session.test.ts — B owns the live session, a gesture selecting same-version different-sha A sends zero commands to B's socket, stops it, and completes one-shot) and 'installing a same-version different-sha helper evicts the stale install memo' (snapshot-helper.test.ts — A:current cached, B installed, selecting A re-inspects and reinstalls instead of serving the stale memo). Both verified to fail without their fix.
1 parent 3d0aad9 commit 4ec5915

7 files changed

Lines changed: 163 additions & 3 deletions

File tree

‎src/platforms/android/__tests__/snapshot-helper.test.ts‎

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,74 @@ test('ensureAndroidSnapshotHelper replaces same-version helper when APK bytes di
249249
assert.deepEqual(installs, [apkPath]);
250250
});
251251

252+
test('installing a same-version different-sha helper evicts the stale install memo', async () => {
253+
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-memo-evict-'));
254+
const apkPathA = path.join(tmpDir, 'helper-a.apk');
255+
const apkPathB = path.join(tmpDir, 'helper-b.apk');
256+
await fs.writeFile(apkPathA, 'helper-apk-a');
257+
await fs.writeFile(apkPathB, 'helper-apk-b');
258+
const manifestA = { ...manifest, sha256: sha256Text('helper-apk-a') };
259+
const manifestB = { ...manifest, sha256: sha256Text('helper-apk-b') };
260+
const deviceKey = 'android:memo-evict';
261+
let installedBytes = 'helper-apk-a';
262+
const installs: string[] = [];
263+
const adb: AndroidAdbExecutor = async (args) => {
264+
if (args.includes('--show-versioncode')) {
265+
return {
266+
exitCode: 0,
267+
stdout: `package:${manifest.packageName} versionCode:${manifest.versionCode}`,
268+
stderr: '',
269+
};
270+
}
271+
if (args[0] === 'shell' && args[1] === 'pm' && args[2] === 'path') {
272+
return { exitCode: 0, stdout: 'package:/data/app/helper/base.apk\n', stderr: '' };
273+
}
274+
throw new Error(`unexpected adb call: ${args.join(' ')}`);
275+
};
276+
const adbProvider = {
277+
exec: adb,
278+
pull: async (_remotePath: string, localPath: string) => {
279+
await fs.writeFile(localPath, installedBytes);
280+
return { exitCode: 0, stdout: '', stderr: '' };
281+
},
282+
install: async (pathToInstall: string) => {
283+
installs.push(pathToInstall);
284+
installedBytes = await fs.readFile(pathToInstall, 'utf8');
285+
return { exitCode: 0, stdout: '', stderr: '' };
286+
},
287+
};
288+
289+
const first = await ensureAndroidSnapshotHelper({
290+
adb,
291+
adbProvider,
292+
artifact: { apkPath: apkPathA, manifest: manifestA },
293+
deviceKey,
294+
});
295+
assert.equal(first.reason, 'current');
296+
297+
// B replaces A in place: same packageName/versionCode, different APK bytes.
298+
const replaced = await ensureAndroidSnapshotHelper({
299+
adb,
300+
adbProvider,
301+
artifact: { apkPath: apkPathB, manifest: manifestB },
302+
deviceKey,
303+
});
304+
assert.equal(replaced.reason, 'mismatched');
305+
assert.equal(replaced.installed, true);
306+
307+
// Selecting A again must re-inspect against the swapped binary instead of serving A's stale
308+
// 'current' memo, and reinstall A.
309+
const reinstalled = await ensureAndroidSnapshotHelper({
310+
adb,
311+
adbProvider,
312+
artifact: { apkPath: apkPathA, manifest: manifestA },
313+
deviceKey,
314+
});
315+
assert.equal(reinstalled.reason, 'mismatched');
316+
assert.equal(reinstalled.installed, true);
317+
assert.deepEqual(installs, [apkPathB, apkPathA]);
318+
});
319+
252320
test('ensureAndroidSnapshotHelper caches successful install checks per device and helper version', async () => {
253321
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'snapshot-helper-install-cache-'));
254322
const apkPath = path.join(tmpDir, 'helper.apk');

‎src/platforms/android/__tests__/touch-helper-session.test.ts‎

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,11 @@ function createFakeTouchHelperSessionProvider(
141141
async function startFakeTouchHelperSession(
142142
device: DeviceInfo,
143143
handleCommand: TouchSessionCommandHandler,
144+
helperIdentity: {
145+
helperVersion?: string;
146+
helperVersionCode?: number;
147+
helperSha256?: string;
148+
} = {},
144149
): Promise<{ deviceKey: string; isSessionAlive: () => Promise<boolean> }> {
145150
const deviceKey = getAndroidSnapshotHelperSessionDeviceKey(device);
146151
const provider = createFakeTouchHelperSessionProvider((command, requestId) => {
@@ -152,6 +157,7 @@ async function startFakeTouchHelperSession(
152157
adb: provider.exec,
153158
adbProvider: provider,
154159
deviceKey,
160+
...helperIdentity,
155161
});
156162
const output = await capture();
157163
assert.ok(output, 'expected the fake snapshot helper session to start');
@@ -325,6 +331,77 @@ test('a provider artifact that mismatches the live session helper stops it and r
325331
assert.equal(await session.isSessionAlive(), false);
326332
});
327333

334+
test('a same-version artifact with a different sha stops the live session and runs one-shot', async () => {
335+
const device = makeIsolatedDevice();
336+
// Artifacts A and B share packageName/runner/version/versionCode and differ only in APK bytes
337+
// (sha256) — the supported same-version replacement. B owns the live session; a command that
338+
// selects A must not be served by B's binary.
339+
const artifactA = ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT;
340+
const shaB = 'b'.repeat(64);
341+
let sessionGestureCallCount = 0;
342+
const session = await startFakeTouchHelperSession(
343+
device,
344+
(command, requestId) => {
345+
if (command.startsWith('gesture')) {
346+
sessionGestureCallCount += 1;
347+
return sessionHeaderResponse({
348+
agentDeviceProtocol: 'android-snapshot-helper-v1',
349+
requestId,
350+
ok: 'true',
351+
kind: 'swipe',
352+
injectedEvents: '6',
353+
elapsedMs: '9',
354+
});
355+
}
356+
return sessionHeaderResponse({
357+
agentDeviceProtocol: 'android-snapshot-helper-v1',
358+
requestId,
359+
ok: 'true',
360+
});
361+
},
362+
{
363+
helperVersion: artifactA.manifest.version,
364+
helperVersionCode: artifactA.manifest.versionCode,
365+
helperSha256: shaB,
366+
},
367+
);
368+
369+
let instrumentArgs: string[] | undefined;
370+
const result = await withAndroidAdbProvider(
371+
{
372+
// A is reported already current by versionCode, so no install occurs: only the sha in the
373+
// session helper identity can reveal that the live session runs B's binary.
374+
exec: async (args) => {
375+
if (args.includes('--show-versioncode')) {
376+
return {
377+
exitCode: 0,
378+
stdout: `package:${artifactA.manifest.packageName} versionCode:999999`,
379+
stderr: '',
380+
};
381+
}
382+
instrumentArgs = [...args];
383+
return {
384+
exitCode: 0,
385+
stdout: [
386+
resultRecord({ ok: 'true', kind: 'swipe', injectedEvents: '4', elapsedMs: '12' }),
387+
'INSTRUMENTATION_CODE: 0',
388+
].join('\n'),
389+
stderr: '',
390+
};
391+
},
392+
snapshotHelperArtifact: artifactA,
393+
},
394+
{ serial: device.id },
395+
async () => await executeAndroidTouchHelperPlan(device, flingPlan()),
396+
);
397+
398+
assert.equal(result.installReason, 'current');
399+
assert.equal(result.helperTransport, 'instrumentation');
400+
assert.equal(instrumentArgs?.at(-1), artifactA.manifest.instrumentationRunner);
401+
assert.equal(sessionGestureCallCount, 0);
402+
assert.equal(await session.isSessionAlive(), false);
403+
});
404+
328405
test('a structured ok=false session gesture response throws but leaves the session alive', async () => {
329406
const device = makeIsolatedDevice();
330407
const session = await startFakeTouchHelperSession(device, (command, requestId) => {

‎src/platforms/android/snapshot-helper-install.ts‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,16 @@ function rememberInstalledSnapshotHelper(
4747
cacheKey: string | undefined,
4848
installedVersionCode: number,
4949
): void {
50-
if (cacheKey) {
51-
installedSnapshotHelpers.set(cacheKey, installedVersionCode);
50+
if (!cacheKey) return;
51+
// Same-version replacement changes only the sha, so recording this helper as installed must
52+
// evict every other cached decision for the same device+package: a stale 'current' memo for the
53+
// previous binary would otherwise skip the sha re-inspection after the swap.
54+
const [deviceKey, packageName] = cacheKey.split('\0');
55+
const prefix = `${deviceKey}\0${packageName}\0`;
56+
for (const key of installedSnapshotHelpers.keys()) {
57+
if (key !== cacheKey && key.startsWith(prefix)) installedSnapshotHelpers.delete(key);
5258
}
59+
installedSnapshotHelpers.set(cacheKey, installedVersionCode);
5360
}
5461

5562
function forgetInstalledSnapshotHelper(cacheKey: string | undefined): void {

‎src/platforms/android/snapshot-helper-session.ts‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ type AndroidSnapshotHelperSessionHelperIdentity = {
3030
runner: string;
3131
helperVersion?: string;
3232
helperVersionCode?: number;
33+
sha256?: string;
3334
};
3435

3536
type AndroidSnapshotHelperSession = {
@@ -164,7 +165,8 @@ function matchesSessionHelperIdentity(
164165
session.packageName === requested.packageName &&
165166
session.runner === requested.runner &&
166167
matchesWhenBothDefined(session.helperVersion, requested.helperVersion) &&
167-
matchesWhenBothDefined(session.helperVersionCode, requested.helperVersionCode)
168+
matchesWhenBothDefined(session.helperVersionCode, requested.helperVersionCode) &&
169+
matchesWhenBothDefined(session.sha256, requested.sha256)
168170
);
169171
}
170172

@@ -294,6 +296,7 @@ async function startAndroidSnapshotHelperSession(params: {
294296
runner: params.resolved.runner,
295297
helperVersion: params.options.helperVersion,
296298
helperVersionCode: params.options.helperVersionCode,
299+
sha256: params.options.helperSha256,
297300
},
298301
port,
299302
adb: params.options.adb,
@@ -558,6 +561,7 @@ function createSessionIdentity(
558561
runner: resolved.runner,
559562
helperVersion: options.helperVersion,
560563
helperVersionCode: options.helperVersionCode,
564+
helperSha256: options.helperSha256,
561565
waitForIdleTimeoutMs: resolved.waitForIdleTimeoutMs,
562566
waitForIdleQuietMs: resolved.waitForIdleQuietMs,
563567
timeoutMs: resolved.timeoutMs,

‎src/platforms/android/snapshot-helper-types.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export type AndroidSnapshotHelperCaptureOptions = {
6464
deviceKey?: string;
6565
helperVersion?: string;
6666
helperVersionCode?: number;
67+
helperSha256?: string;
6768
packageName?: string;
6869
instrumentationRunner?: string;
6970
waitForIdleTimeoutMs?: number;

‎src/platforms/android/snapshot.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,7 @@ async function captureAndroidUiHierarchyFromHelper(params: {
311311
deviceKey: helperDeviceKey,
312312
helperVersion: artifact.manifest.version,
313313
helperVersionCode: artifact.manifest.versionCode,
314+
helperSha256: artifact.manifest.sha256,
314315
packageName: artifact.manifest.packageName,
315316
instrumentationRunner: artifact.manifest.instrumentationRunner,
316317
waitForIdleTimeoutMs:

‎src/platforms/android/touch-helper.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,12 +141,14 @@ function touchSessionHelperIdentity(artifact: AndroidSnapshotHelperArtifact): {
141141
runner: string;
142142
helperVersion: string;
143143
helperVersionCode: number;
144+
sha256: string;
144145
} {
145146
return {
146147
packageName: artifact.manifest.packageName,
147148
runner: artifact.manifest.instrumentationRunner,
148149
helperVersion: artifact.manifest.version,
149150
helperVersionCode: artifact.manifest.versionCode,
151+
sha256: artifact.manifest.sha256,
150152
};
151153
}
152154

0 commit comments

Comments
 (0)