From 5961b9247cf7a10ec7499a86c42aa571471f4387 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 16 Jul 2026 11:30:48 +0200 Subject: [PATCH] fix(android): stop a structurally-failed helper session before the one-shot viewport retry A structured ok=false viewport response leaves the session process alive, and Android permits only one instrumentation owner of UiAutomation - running the one-shot fallback against a still-live helper contends with it and masks the original structured failure. Stop the session first; regression pins that the one-shot retry only executes once the session is gone. --- .../android/__tests__/touch-helper.test.ts | 45 +++++++++++++++++++ src/platforms/android/touch-helper.ts | 6 ++- 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/platforms/android/__tests__/touch-helper.test.ts b/src/platforms/android/__tests__/touch-helper.test.ts index 04e1ff810..3bdb761c7 100644 --- a/src/platforms/android/__tests__/touch-helper.test.ts +++ b/src/platforms/android/__tests__/touch-helper.test.ts @@ -672,3 +672,48 @@ test('viewport falls back to one-shot instrumentation after a session error', as assert.ok(oneShotArgs?.includes('viewport')); assert.equal(await session.isSessionAlive(), false); }); + +test('a structured ok=false viewport response stops the session before the one-shot retry', async () => { + const device = makeIsolatedDevice(); + const session = await startFakeTouchHelperSession(device, (command, requestId) => { + if (command.startsWith('viewport')) { + return sessionHeaderResponse({ + agentDeviceProtocol: 'android-snapshot-helper-v1', + requestId, + ok: 'false', + errorType: 'java.lang.IllegalStateException', + message: 'Active application interaction viewport is unavailable', + }); + } + return sessionHeaderResponse({ + agentDeviceProtocol: 'android-snapshot-helper-v1', + requestId, + ok: 'true', + }); + }); + + let oneShotArgs: string[] | undefined; + const viewportResult = await withAndroidAdbProvider( + { + exec: currentVersionAdb(async (args) => { + // One instrumentation may own UiAutomation: the one-shot retry must only run once the + // structurally-failed session has been stopped. + assert.equal(await session.isSessionAlive(), false); + oneShotArgs = args; + return { + exitCode: 0, + stdout: [ + resultRecord({ ok: 'true', x: '5', y: '6', width: '300', height: '400' }), + 'INSTRUMENTATION_CODE: 0', + ].join('\n'), + stderr: '', + }; + }), + }, + { serial: device.id }, + async () => await readAndroidTouchHelperViewport(device), + ); + + assert.deepEqual(viewportResult, { x: 5, y: 6, width: 300, height: 400 }); + assert.ok(oneShotArgs?.includes('viewport')); +}); diff --git a/src/platforms/android/touch-helper.ts b/src/platforms/android/touch-helper.ts index 3b78ebe36..5bd54b3c9 100644 --- a/src/platforms/android/touch-helper.ts +++ b/src/platforms/android/touch-helper.ts @@ -17,6 +17,7 @@ import { ensureAndroidSnapshotHelper } from './snapshot-helper-install.ts'; import { getAndroidSnapshotHelperSessionDeviceKey, runAndroidSnapshotHelperSessionTouchCommand, + stopAndroidSnapshotHelperSession, } from './snapshot-helper-session.ts'; import { ANDROID_SNAPSHOT_HELPER_PROTOCOL, @@ -112,7 +113,10 @@ export async function readAndroidTouchHelperViewport(device: DeviceInfo): Promis }); if (sessionHeaders) return readViewportResult(sessionHeaders); } catch (error) { - // Viewport reads are idempotent; after the failed session is stopped, retry one-shot below. + // Viewport reads are idempotent, so a fresh one-shot run may still answer. The session must + // be stopped first: Android permits one instrumentation owner of UiAutomation, and a helper + // left alive after a structured failure would contend with the one-shot instrumentation. + await stopAndroidSnapshotHelperSession(prepared.deviceKey); emitDiagnostic({ level: 'warn', phase: 'android_touch_helper_viewport_session_fallback',