From b0a08204e1266fe490414d8d2adba6b55d49aa90 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Sat, 3 Oct 2026 14:01:29 +0200 Subject: [PATCH 1/5] fix(android): back off a timed-out snapshot helper session and bound content re-captures A session capture that failed is torn down and its helper build stays one-shot for the fallback budget plus the start backoff, instead of being respawned and timing out again on every attempt and command. Content re-captures stop after a 10s window so slow attempts cannot run a snapshot past the 90s request envelope. A helper failure reported under am exit 0 keeps its own reason instead of 'output could not be parsed'. --- .../snapshot-content-recapture.test.ts | 85 +++++++++++++++++++ .../__tests__/snapshot-helper-capture.test.ts | 28 ++++++ .../snapshot-helper-session-lifecycle.test.ts | 37 ++++++-- .../__tests__/snapshot-helper-session.test.ts | 3 +- .../src/snapshot-helper-capture.ts | 6 +- .../src/snapshot-helper-session-lifecycle.ts | 75 +++++++++++----- .../src/snapshot-helper-session.ts | 13 ++- packages/platform-android/src/snapshot.ts | 15 +++- 8 files changed, 224 insertions(+), 38 deletions(-) create mode 100644 packages/platform-android/src/__tests__/snapshot-content-recapture.test.ts diff --git a/packages/platform-android/src/__tests__/snapshot-content-recapture.test.ts b/packages/platform-android/src/__tests__/snapshot-content-recapture.test.ts new file mode 100644 index 0000000000..81211fbb9b --- /dev/null +++ b/packages/platform-android/src/__tests__/snapshot-content-recapture.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, test, vi } from 'vitest'; +import assert from 'node:assert/strict'; + +vi.mock('../adb.ts', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, sleep: vi.fn(async () => {}) }; +}); + +import type { DeviceInfo } from '@agent-device/kernel/device'; +import { AppError } from '@agent-device/kernel/errors'; +import { snapshotAndroid } from '../snapshot.ts'; +import { resetAndroidSnapshotHelperInstallCache } from '../snapshot-helper-install.ts'; +import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; +import { ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT } from './test-utils/android-snapshot-helper.ts'; +import { + androidSystemWindowOnlyXml, + createPersistentSnapshotHelperProvider, + type FakeAndroidProcess, +} from './snapshot-helper-session.fixtures.ts'; + +const device: DeviceInfo = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +}; + +beforeEach(async () => { + await resetAndroidSnapshotHelperSessions(); + resetAndroidSnapshotHelperInstallCache(); +}); + +afterEach(async () => { + vi.restoreAllMocks(); + await resetAndroidSnapshotHelperSessions(); +}); + +/** Captures of a busy screen that each answer with system chrome only, after `captureCostMs`. */ +async function captureBusyScreen(captureCostMs: number): Promise<{ + error: unknown; + sessionCaptures: number; +}> { + let clockOffsetMs = 0; + const realNow = Date.now.bind(Date); + vi.spyOn(Date, 'now').mockImplementation(() => realNow() + clockOffsetMs); + let sessionCaptures = 0; + const provider = createPersistentSnapshotHelperProvider({ + calls: [], + spawnArgs: [], + processes: [] as FakeAndroidProcess[], + sessionXml: () => { + sessionCaptures += 1; + clockOffsetMs += captureCostMs; + return androidSystemWindowOnlyXml(); + }, + }); + const error = await snapshotAndroid(device, { + helperAdb: provider, + helperArtifact: ANDROID_SNAPSHOT_HELPER_FIXTURE_ARTIFACT, + appBundleId: 'com.example.app', + }).then( + () => undefined, + (error: unknown) => error, + ); + return { error, sessionCaptures }; +} + +test('a busy screen whose captures are cheap is re-captured before its verdict', async () => { + const { error, sessionCaptures } = await captureBusyScreen(10); + + assert.ok(error instanceof AppError); + assert.equal(error.details?.attempts, 3); + assert.equal(sessionCaptures, 3); +}); + +test('a busy screen whose captures are slow is not re-captured past the window', async () => { + // One attempt that spends a whole helper command budget leaves no room in the daemon request + // envelope for two more of the same. + const { error, sessionCaptures } = await captureBusyScreen(30_000); + + assert.ok(error instanceof AppError); + assert.equal(error.details?.attempts, 1); + assert.equal(sessionCaptures, 1); +}); diff --git a/packages/platform-android/src/__tests__/snapshot-helper-capture.test.ts b/packages/platform-android/src/__tests__/snapshot-helper-capture.test.ts index 1c6bccb8b0..ebf5fb59b7 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-capture.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-capture.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { beforeEach, test } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; import { captureAndroidSnapshotWithHelper } from '../snapshot-helper-capture.ts'; import { resetAndroidSnapshotHelperRetirements } from '../snapshot-helper-retirement.ts'; import type { AndroidAdbExecutor } from '../snapshot-helper-types.ts'; @@ -130,6 +131,33 @@ test('canceled one-shot capture reports the cancellation and the next capture re ]); }); +test('a helper failure reported under a zero am exit status keeps its own reason', async () => { + // `am instrument` exits 0 after the helper finished with ok=false, as an emulator answered a + // capture of an app whose main thread never served its window. + const adb: AndroidAdbExecutor = async (args) => { + if (isAndroidHelperRuntimeProbe(args)) return androidHelperRuntimeProbeResult(); + return { + exitCode: 0, + stdout: [ + 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', + 'INSTRUMENTATION_RESULT: errorType=com.callstack.agentdevice.snapshothelper.AccessibilityCaptureStabilizer$IncompleteCaptureException', + 'INSTRUMENTATION_RESULT: message=Android accessibility capture remained incomplete after 500 ms', + 'INSTRUMENTATION_RESULT: ok=false', + 'INSTRUMENTATION_CODE: 1', + ].join('\n'), + stderr: '', + }; + }; + + await assert.rejects( + captureAndroidSnapshotWithHelper({ adb, deviceKey: 'android:emulator-5554' }), + (error: unknown) => + error instanceof AppError && + error.message === 'Android accessibility capture remained incomplete after 500 ms' && + typeof error.details?.helper === 'object', + ); +}); + function helperOutput(xml: string): string { return [ 'INSTRUMENTATION_STATUS: agentDeviceProtocol=android-snapshot-helper-v1', diff --git a/packages/platform-android/src/__tests__/snapshot-helper-session-lifecycle.test.ts b/packages/platform-android/src/__tests__/snapshot-helper-session-lifecycle.test.ts index c4d26d14d3..db4517f2a4 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-session-lifecycle.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-session-lifecycle.test.ts @@ -137,27 +137,28 @@ test('a generous caller budget buys a slow start, and never more than the caller test('a session that reaches ready settles a release the device could not confirm', async () => { const calls: (readonly string[])[] = []; const spawnArgs: (readonly string[])[] = []; + const deviceKey = 'android:emulator-5554'; // The device answers every process read with an adb error no classifier lists, and the first - // command's session stalls, so that command's teardown records a release nothing could prove. + // session ignores its quit, so that teardown records a release nothing could prove. const provider = createSessionProvider({ calls, spawnArgs, - stalledSnapshots: 1, + quitResponseMode: 'malformed', runtimeRelease: 'closed', }); - const stalled = await captureAndroidSnapshotWithHelperSession({ + await captureAndroidSnapshotWithHelperSession({ adb: provider.exec, adbProvider: provider, - deviceKey: 'android:emulator-5554', + deviceKey, commandTimeoutMs: 400, }); - assert.equal(stalled, undefined); + await stopAndroidSnapshotHelperSession(deviceKey); const started = await captureAndroidSnapshotWithHelperSession({ adb: provider.exec, adbProvider: provider, - deviceKey: 'android:emulator-5554', + deviceKey, commandTimeoutMs: 400, }); assert.equal(started?.metadata.sessionReused, false); @@ -169,7 +170,7 @@ test('a session that reaches ready settles a release the device could not confir const reused = await captureAndroidSnapshotWithHelperSession({ adb: provider.exec, adbProvider: provider, - deviceKey: 'android:emulator-5554', + deviceKey, commandTimeoutMs: 400, }); @@ -181,6 +182,28 @@ test('a session that reaches ready settles a release the device could not confir ); }); +test('a session whose capture timed out is not restarted by the next command', async () => { + const calls: (readonly string[])[] = []; + const spawnArgs: (readonly string[])[] = []; + const provider = createSessionProvider({ calls, spawnArgs, stalledSnapshots: 1 }); + const capture = () => + captureAndroidSnapshotWithHelperSession({ + adb: provider.exec, + adbProvider: provider, + deviceKey: 'android:emulator-5554', + commandTimeoutMs: 400, + }); + + assert.equal(await capture(), undefined, 'the timed-out session falls back to one-shot'); + assert.equal(await capture(), undefined, 'the next command answers one-shot as well'); + assert.equal(spawnArgs.length, 1, 'a timed-out session earns a backoff, not another spawn'); + assert.equal( + calls.some(isAndroidHelperRuntimeForceStop), + true, + 'the timed-out helper is stopped', + ); +}); + test('starts and reuses a persistent Android snapshot helper session', async () => { const calls: (readonly string[])[] = []; const spawnArgs: (readonly string[])[] = []; diff --git a/packages/platform-android/src/__tests__/snapshot-helper-session.test.ts b/packages/platform-android/src/__tests__/snapshot-helper-session.test.ts index fe02bc6e17..7b2f2a3bd4 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-session.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-session.test.ts @@ -147,7 +147,8 @@ test('release the transport could not confirm falls back instead of failing the }); assert.equal(output, undefined, `attempt ${attempt}`); } - assert.equal(processes.length, 2); + // The malformed answer backs the session off, so the second command goes straight to one-shot. + assert.equal(processes.length, 1); }); test('capture refuses a device the previous teardown found the helper still running', async () => { diff --git a/packages/platform-android/src/snapshot-helper-capture.ts b/packages/platform-android/src/snapshot-helper-capture.ts index f4a2d710b0..5ad2399e75 100644 --- a/packages/platform-android/src/snapshot-helper-capture.ts +++ b/packages/platform-android/src/snapshot-helper-capture.ts @@ -228,9 +228,13 @@ async function readFallbackHelperOutputOrThrow( result: Awaited>, error: unknown, ): Promise { - if (error instanceof AppError && result.exitCode !== 0 && error.details?.helper) throw error; + const helperFailure = error instanceof AppError && error.details?.helper ? error : undefined; + if (helperFailure && result.exitCode !== 0) throw helperFailure; const fileOutput = await readFallbackHelperOutputFile(options, resolved, result); if (fileOutput) return { output: fileOutput, cleanupDone: true }; + // `am instrument` exits 0 after a helper that reported its own failure, so that report is the + // answer whenever no output file stands in for it. + if (helperFailure) throw helperFailure; // exec-guard-allow: reachable at exit 0 (helper output unparseable); the // message already branches on the exit code. throw new AppError( diff --git a/packages/platform-android/src/snapshot-helper-session-lifecycle.ts b/packages/platform-android/src/snapshot-helper-session-lifecycle.ts index 7712347289..ae507aafa0 100644 --- a/packages/platform-android/src/snapshot-helper-session-lifecycle.ts +++ b/packages/platform-android/src/snapshot-helper-session-lifecycle.ts @@ -58,13 +58,13 @@ const SESSION_REQUEST_OVERHEAD_MS = 3_000; const FORWARD_TIMEOUT_MS = 5_000; // A helper that cannot start spends its whole start budget failing, and the one-shot transport that // answers afterwards still has to run. Retrying that on the very next command is what made commands -// on the slow hosts of #2553 take roughly twice as long, so a failed start keeps the persistent path -// away for at least this long. -const SESSION_START_RETRY_FLOOR_MS = 10_000; -// …and for no longer than this, however long the start took. The floor keeps a burst of commands +// on the slow hosts of #2553 take roughly twice as long, so a failed start or session capture keeps +// the persistent path away for at least this long. +const SESSION_RETRY_FLOOR_MS = 10_000; +// …and for no longer than this, however long the failure took. The floor keeps a burst of commands // from re-paying an instant failure; the ceiling keeps a host whose helper is simply broken from // being written off for longer than a working session would have lasted. -const SESSION_START_RETRY_CEILING_MS = 60_000; +const SESSION_RETRY_CEILING_MS = 60_000; export type AndroidSnapshotHelperSessionHelperIdentity = { packageName: string; @@ -93,8 +93,11 @@ export type AndroidSnapshotHelperSessionAcquisition = { }; const sessions = new Map(); -/** Capture identity → when this process may spawn that helper build again after a failed start. */ -const failedStarts = new Map(); +/** + * Capture identity → when this process may spawn that helper build again after a failed start or a + * failed session capture. + */ +const failedSessions = new Map(); /** * Starts (or reuses) the session without capturing, so a helper-backed read that is not a snapshot @@ -150,7 +153,7 @@ async function resolveAndroidSnapshotHelperSession(params: { resolved: AndroidSnapshotHelperResolvedCaptureOptions; startBudgetMs: number; }): Promise { - if (isAndroidSnapshotHelperStartBackedOff(params.identity)) return undefined; + if (isAndroidSnapshotHelperSessionBackedOff(params.identity)) return undefined; await retireUnusableAndroidSnapshotHelperSession(params.deviceKey, params.identity); return sessions.get(params.deviceKey) ?? (await tryStartAndroidSnapshotHelperSession(params)); } @@ -186,9 +189,9 @@ async function tryStartAndroidSnapshotHelperSession(params: { return await startAndroidSnapshotHelperSession(params); } catch (error) { params.options.signal?.throwIfAborted(); - failedStarts.set( + failedSessions.set( params.identity, - Date.now() + androidSnapshotHelperStartRetryAfterMs(Date.now() - startedAtMs), + Date.now() + androidSnapshotHelperSessionRetryAfterMs(Date.now() - startedAtMs), ); emitDiagnostic({ level: 'warn', @@ -203,24 +206,52 @@ async function tryStartAndroidSnapshotHelperSession(params: { } } -/** A helper build whose last start failed is left alone until that start's backoff has run out. */ -function isAndroidSnapshotHelperStartBackedOff(identity: string): boolean { - const retryAtMs = failedStarts.get(identity); +/** A helper build whose last start or capture failed is left alone until its backoff has run out. */ +function isAndroidSnapshotHelperSessionBackedOff(identity: string): boolean { + const retryAtMs = failedSessions.get(identity); if (retryAtMs === undefined) return false; if (retryAtMs > Date.now()) return true; - failedStarts.delete(identity); + failedSessions.delete(identity); return false; } /** - * How long a failed start earns: as long as it spent failing, because a start that burned half a - * minute on a wedged device would burn another half minute on the next command, bounded so a burst + * How long a failed start or capture earns: as long as it spent failing, because a start that burned + * half a minute on a wedged device would burn another half minute on the next command, bounded so a burst * of commands neither re-pays an instant failure nor writes a device off for the rest of the run. */ -function androidSnapshotHelperStartRetryAfterMs(startDurationMs: number): number { - return Math.min( - Math.max(startDurationMs, SESSION_START_RETRY_FLOOR_MS), - SESSION_START_RETRY_CEILING_MS, +function androidSnapshotHelperSessionRetryAfterMs(failureDurationMs: number): number { + return Math.min(Math.max(failureDurationMs, SESSION_RETRY_FLOOR_MS), SESSION_RETRY_CEILING_MS); +} + +/** + * Tears down a session whose capture failed and keeps its helper build off the persistent path for + * as long as a failed start would, counted from when the one-shot capture that answers instead has + * had its whole budget. A session that timed out once times out again on the same screen, so + * restarting it on the next attempt or command only spends its budget again before the one-shot + * transport answers. A cancellation is the caller's deadline, not evidence about the helper, so it + * earns no backoff. + */ +export async function retireFailedAndroidSnapshotHelperSession(params: { + deviceKey: string; + failedAfterMs: number; + /** Budget of the one-shot capture that answers in place of the failed session. */ + fallbackBudgetMs: number; + signal?: AbortSignal; + cause: unknown; +}): Promise { + const identity = sessions.get(params.deviceKey)?.identity; + await stopAndroidSnapshotHelperSession(params.deviceKey, { + force: true, + signal: params.signal, + cause: params.cause, + }); + if (identity === undefined || params.signal?.aborted) return; + failedSessions.set( + identity, + Date.now() + + params.fallbackBudgetMs + + androidSnapshotHelperSessionRetryAfterMs(params.failedAfterMs), ); } @@ -285,7 +316,7 @@ async function startAndroidSnapshotHelperSession(params: { params.options.signal, ); sessions.set(params.deviceKey, session); - failedStarts.delete(params.identity); + failedSessions.delete(params.identity); // `am instrument` force-stops whatever is already instrumenting this package, so a helper that // reported itself ready is the only helper process the device has left, and the release the // previous teardown could not prove went away with the process that owed it. Leaving the entry @@ -530,7 +561,7 @@ export async function resetAndroidSnapshotHelperSessions(): Promise { // One teardown that throws must not leave the next caller believing a session, a pending // retirement, or a failed start is still standing. sessions.clear(); - failedStarts.clear(); + failedSessions.clear(); resetAndroidSnapshotHelperRetirements(); resetAndroidAdbShellProtocolProbes(); } diff --git a/packages/platform-android/src/snapshot-helper-session.ts b/packages/platform-android/src/snapshot-helper-session.ts index 09943069b3..b5e395d549 100644 --- a/packages/platform-android/src/snapshot-helper-session.ts +++ b/packages/platform-android/src/snapshot-helper-session.ts @@ -11,7 +11,10 @@ import type { AndroidSnapshotHelperCaptureOptions, AndroidSnapshotHelperOutput, } from './snapshot-helper-types.ts'; -import type { AndroidSnapshotHelperResolvedCaptureOptions } from './snapshot-helper-capture.ts'; +import { + resolveAndroidSnapshotHelperCaptureOptions, + type AndroidSnapshotHelperResolvedCaptureOptions, +} from './snapshot-helper-capture.ts'; import { assertAndroidSnapshotHelperTouchSessionHeaders, parseAndroidSnapshotHelperSessionHeaders, @@ -21,6 +24,7 @@ import { import { acquireAndroidSnapshotHelperSession, getLiveAndroidSnapshotHelperSession, + retireFailedAndroidSnapshotHelperSession, stopAndroidSnapshotHelperSession, type AndroidSnapshotHelperSession, type AndroidSnapshotHelperSessionHelperIdentity, @@ -46,6 +50,7 @@ async function captureFromAndroidSnapshotHelperSession(params: { resolved: AndroidSnapshotHelperResolvedCaptureOptions; }): Promise { const { session, deviceKey, options, resolved } = params; + const requestedAtMs = Date.now(); try { const reused = session.capturedCount > 0; const output = await requestAndroidSnapshotHelperSessionSnapshot({ @@ -64,8 +69,10 @@ async function captureFromAndroidSnapshotHelperSession(params: { }, }; } catch (error) { - await stopAndroidSnapshotHelperSession(deviceKey, { - force: true, + await retireFailedAndroidSnapshotHelperSession({ + deviceKey, + failedAfterMs: Date.now() - requestedAtMs, + fallbackBudgetMs: resolveAndroidSnapshotHelperCaptureOptions(options).commandTimeoutMs, signal: options.signal, cause: error, }); diff --git a/packages/platform-android/src/snapshot.ts b/packages/platform-android/src/snapshot.ts index 0ed1b5e1fb..de0d2ab341 100644 --- a/packages/platform-android/src/snapshot.ts +++ b/packages/platform-android/src/snapshot.ts @@ -84,6 +84,12 @@ const HELPER_INSTALL_TIMEOUT_MS = 30_000; */ const HELPER_CONTENT_CAPTURE_ATTEMPTS = 3; const HELPER_CONTENT_RECAPTURE_DELAY_MS = 250; +/** + * No re-capture starts this long after the first attempt did. One attempt can spend a session start, + * a timed-out session request, and the whole one-shot command budget, so attempts counted alone let + * a busy screen run a snapshot past the daemon request envelope. + */ +const HELPER_CONTENT_RECAPTURE_WINDOW_MS = 10_000; export type AndroidSnapshotOptions = SnapshotOptions & { appBundleId?: string; signal?: AbortSignal; @@ -283,6 +289,10 @@ async function captureAndroidUiHierarchyWithHelper( const helperDeviceKey = getAndroidSnapshotHelperSessionDeviceKey(device); const adbProvider = resolveAndroidAdbProvider(device, options.helperAdb); const releaseHelperSession = releasesHelperSessionAfterCapture(options, helperDeviceKey); + const recaptureDeadlineMs = Math.min( + Date.now() + HELPER_CONTENT_RECAPTURE_WINDOW_MS, + options.transient?.settleBy ?? Number.POSITIVE_INFINITY, + ); try { let previousContentReason: AndroidContentRecoveryReason | undefined; for (let attempt = 0; ; attempt += 1) { @@ -297,10 +307,7 @@ async function captureAndroidUiHierarchyWithHelper( previousContentReason, }); if (settled.outcome === 'captured') return settled.capture; - if ( - attempt + 1 >= HELPER_CONTENT_CAPTURE_ATTEMPTS || - (options.transient !== undefined && Date.now() >= options.transient.settleBy) - ) { + if (attempt + 1 >= HELPER_CONTENT_CAPTURE_ATTEMPTS || Date.now() >= recaptureDeadlineMs) { return await rejectAndroidHelperContentUnavailable({ contentRecovery: settled.decision, attempts: attempt + 1, From 60446b305336885899ee71291050a6034293e77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Sat, 3 Oct 2026 17:11:33 +0200 Subject: [PATCH 2/5] fix(android): recheck re-capture window after the delay before it --- .../snapshot-content-recapture.test.ts | 17 ++++++++- packages/platform-android/src/snapshot.ts | 36 ++++++++++++------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/platform-android/src/__tests__/snapshot-content-recapture.test.ts b/packages/platform-android/src/__tests__/snapshot-content-recapture.test.ts index 81211fbb9b..93cd1432b0 100644 --- a/packages/platform-android/src/__tests__/snapshot-content-recapture.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-content-recapture.test.ts @@ -8,6 +8,7 @@ vi.mock('../adb.ts', async (importOriginal) => { import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError } from '@agent-device/kernel/errors'; +import { sleep } from '../adb.ts'; import { snapshotAndroid } from '../snapshot.ts'; import { resetAndroidSnapshotHelperInstallCache } from '../snapshot-helper-install.ts'; import { resetAndroidSnapshotHelperSessions } from '../snapshot-helper-session-lifecycle.ts'; @@ -36,7 +37,10 @@ afterEach(async () => { await resetAndroidSnapshotHelperSessions(); }); -/** Captures of a busy screen that each answer with system chrome only, after `captureCostMs`. */ +/** + * Captures of a busy screen that each answer with system chrome only, after `captureCostMs`. + * Every sleep advances the clock by the time it was asked to wait. + */ async function captureBusyScreen(captureCostMs: number): Promise<{ error: unknown; sessionCaptures: number; @@ -44,6 +48,9 @@ async function captureBusyScreen(captureCostMs: number): Promise<{ let clockOffsetMs = 0; const realNow = Date.now.bind(Date); vi.spyOn(Date, 'now').mockImplementation(() => realNow() + clockOffsetMs); + vi.mocked(sleep).mockImplementation(async (ms: number) => { + clockOffsetMs += ms; + }); let sessionCaptures = 0; const provider = createPersistentSnapshotHelperProvider({ calls: [], @@ -83,3 +90,11 @@ test('a busy screen whose captures are slow is not re-captured past the window', assert.equal(error.details?.attempts, 1); assert.equal(sessionCaptures, 1); }); + +test('a busy screen is not re-captured when the delay before it crosses the window', async () => { + const { error, sessionCaptures } = await captureBusyScreen(9_900); + + assert.ok(error instanceof AppError); + assert.equal(error.details?.attempts, 1); + assert.equal(sessionCaptures, 1); +}); diff --git a/packages/platform-android/src/snapshot.ts b/packages/platform-android/src/snapshot.ts index de0d2ab341..4123e12330 100644 --- a/packages/platform-android/src/snapshot.ts +++ b/packages/platform-android/src/snapshot.ts @@ -293,10 +293,28 @@ async function captureAndroidUiHierarchyWithHelper( Date.now() + HELPER_CONTENT_RECAPTURE_WINDOW_MS, options.transient?.settleBy ?? Number.POSITIVE_INFINITY, ); + const rejectContentUnavailable = async ( + contentRecovery: AndroidHelperContentRecoveryDecision, + attempts: number, + ) => + await rejectAndroidHelperContentUnavailable({ + contentRecovery, + attempts, + helperDeviceKey, + artifact, + adb, + signal: options.signal, + retireHelper: options.transient === undefined, + }); try { - let previousContentReason: AndroidContentRecoveryReason | undefined; + let previousDecision: AndroidHelperContentRecoveryDecision | undefined; for (let attempt = 0; ; attempt += 1) { - if (attempt > 0) await delayBeforeContentRecapture(options.signal); + if (previousDecision) { + await delayBeforeContentRecapture(options.signal); + if (Date.now() >= recaptureDeadlineMs) { + return await rejectContentUnavailable(previousDecision, attempt); + } + } const settled = await captureAndroidHelperContentAttempt({ options, adb, @@ -304,21 +322,13 @@ async function captureAndroidUiHierarchyWithHelper( artifact, helperDeviceKey, attempt, - previousContentReason, + previousContentReason: previousDecision?.reason, }); if (settled.outcome === 'captured') return settled.capture; if (attempt + 1 >= HELPER_CONTENT_CAPTURE_ATTEMPTS || Date.now() >= recaptureDeadlineMs) { - return await rejectAndroidHelperContentUnavailable({ - contentRecovery: settled.decision, - attempts: attempt + 1, - helperDeviceKey, - artifact, - adb, - signal: options.signal, - retireHelper: options.transient === undefined, - }); + return await rejectContentUnavailable(settled.decision, attempt + 1); } - previousContentReason = settled.decision.reason; + previousDecision = settled.decision; } } finally { if (releaseHelperSession) { From da59b7a0a1b89b8ff1475b9aafa8202165f57d08 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Sat, 3 Oct 2026 17:13:57 +0200 Subject: [PATCH 3/5] refactor(android): retire a failed helper session by its own identity --- .../src/snapshot-helper-session-lifecycle.ts | 7 ++++--- packages/platform-android/src/snapshot-helper-session.ts | 1 + 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/packages/platform-android/src/snapshot-helper-session-lifecycle.ts b/packages/platform-android/src/snapshot-helper-session-lifecycle.ts index ae507aafa0..02de97d90e 100644 --- a/packages/platform-android/src/snapshot-helper-session-lifecycle.ts +++ b/packages/platform-android/src/snapshot-helper-session-lifecycle.ts @@ -234,21 +234,22 @@ function androidSnapshotHelperSessionRetryAfterMs(failureDurationMs: number): nu */ export async function retireFailedAndroidSnapshotHelperSession(params: { deviceKey: string; + /** Helper build of the session whose capture failed. */ + identity: string; failedAfterMs: number; /** Budget of the one-shot capture that answers in place of the failed session. */ fallbackBudgetMs: number; signal?: AbortSignal; cause: unknown; }): Promise { - const identity = sessions.get(params.deviceKey)?.identity; await stopAndroidSnapshotHelperSession(params.deviceKey, { force: true, signal: params.signal, cause: params.cause, }); - if (identity === undefined || params.signal?.aborted) return; + if (params.signal?.aborted) return; failedSessions.set( - identity, + params.identity, Date.now() + params.fallbackBudgetMs + androidSnapshotHelperSessionRetryAfterMs(params.failedAfterMs), diff --git a/packages/platform-android/src/snapshot-helper-session.ts b/packages/platform-android/src/snapshot-helper-session.ts index b5e395d549..854cca2338 100644 --- a/packages/platform-android/src/snapshot-helper-session.ts +++ b/packages/platform-android/src/snapshot-helper-session.ts @@ -71,6 +71,7 @@ async function captureFromAndroidSnapshotHelperSession(params: { } catch (error) { await retireFailedAndroidSnapshotHelperSession({ deviceKey, + identity: session.identity, failedAfterMs: Date.now() - requestedAtMs, fallbackBudgetMs: resolveAndroidSnapshotHelperCaptureOptions(options).commandTimeoutMs, signal: options.signal, From f93d8eae524c0d5639ae07bcca76c2bb87868a85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Sat, 3 Oct 2026 17:13:58 +0200 Subject: [PATCH 4/5] test(android): assert typed reason of a zero-exit helper failure --- .../src/__tests__/snapshot-helper-capture.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/platform-android/src/__tests__/snapshot-helper-capture.test.ts b/packages/platform-android/src/__tests__/snapshot-helper-capture.test.ts index ebf5fb59b7..db20cde242 100644 --- a/packages/platform-android/src/__tests__/snapshot-helper-capture.test.ts +++ b/packages/platform-android/src/__tests__/snapshot-helper-capture.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { beforeEach, test } from 'vitest'; +import { readAndroidCaptureFailureReason } from '@agent-device/contracts/android-snapshot-quality'; import { AppError } from '@agent-device/kernel/errors'; import { captureAndroidSnapshotWithHelper } from '../snapshot-helper-capture.ts'; import { resetAndroidSnapshotHelperRetirements } from '../snapshot-helper-retirement.ts'; @@ -140,8 +141,8 @@ test('a helper failure reported under a zero am exit status keeps its own reason exitCode: 0, stdout: [ 'INSTRUMENTATION_RESULT: agentDeviceProtocol=android-snapshot-helper-v1', - 'INSTRUMENTATION_RESULT: errorType=com.callstack.agentdevice.snapshothelper.AccessibilityCaptureStabilizer$IncompleteCaptureException', - 'INSTRUMENTATION_RESULT: message=Android accessibility capture remained incomplete after 500 ms', + 'INSTRUMENTATION_RESULT: errorType=java.util.concurrent.TimeoutException', + 'INSTRUMENTATION_RESULT: message=Timed out waiting for the accessibility hierarchy', 'INSTRUMENTATION_RESULT: ok=false', 'INSTRUMENTATION_CODE: 1', ].join('\n'), @@ -153,8 +154,8 @@ test('a helper failure reported under a zero am exit status keeps its own reason captureAndroidSnapshotWithHelper({ adb, deviceKey: 'android:emulator-5554' }), (error: unknown) => error instanceof AppError && - error.message === 'Android accessibility capture remained incomplete after 500 ms' && - typeof error.details?.helper === 'object', + readAndroidCaptureFailureReason(error) === 'accessibility-timeout' && + error.details?.errorType === 'java.util.concurrent.TimeoutException', ); }); From 12757c4320b27fd05f81d214b9e4893f4d15ffc6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Oskar=20Kwas=CC=81niewski?= Date: Sat, 3 Oct 2026 17:15:58 +0200 Subject: [PATCH 5/5] refactor(android): split content re-capture loop out of helper capture --- packages/platform-android/src/snapshot.ts | 87 ++++++++++++++--------- 1 file changed, 54 insertions(+), 33 deletions(-) diff --git a/packages/platform-android/src/snapshot.ts b/packages/platform-android/src/snapshot.ts index 4123e12330..cc94da3b31 100644 --- a/packages/platform-android/src/snapshot.ts +++ b/packages/platform-android/src/snapshot.ts @@ -287,12 +287,35 @@ async function captureAndroidUiHierarchyWithHelper( artifact: AndroidSnapshotHelperArtifact, ): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { const helperDeviceKey = getAndroidSnapshotHelperSessionDeviceKey(device); - const adbProvider = resolveAndroidAdbProvider(device, options.helperAdb); const releaseHelperSession = releasesHelperSessionAfterCapture(options, helperDeviceKey); - const recaptureDeadlineMs = Math.min( - Date.now() + HELPER_CONTENT_RECAPTURE_WINDOW_MS, - options.transient?.settleBy ?? Number.POSITIVE_INFINITY, - ); + try { + return await captureAndroidHelperContentWithinWindow({ + options, + adb, + adbProvider: resolveAndroidAdbProvider(device, options.helperAdb), + artifact, + helperDeviceKey, + }); + } finally { + if (releaseHelperSession) { + await stopAndroidSnapshotHelperSession(helperDeviceKey); + } + } +} + +/** + * Re-captures unusable content until a capture answers, the attempts run out, or the re-capture + * window has closed by the time the next attempt would start. + */ +async function captureAndroidHelperContentWithinWindow(params: { + options: AndroidSnapshotOptions; + adb: AndroidAdbExecutor; + adbProvider: AndroidAdbProvider; + artifact: AndroidSnapshotHelperArtifact; + helperDeviceKey: string; +}): Promise<{ xml: string; metadata: AndroidSnapshotBackendMetadata }> { + const { options } = params; + const recaptureDeadlineMs = resolveContentRecaptureDeadlineMs(options); const rejectContentUnavailable = async ( contentRecovery: AndroidHelperContentRecoveryDecision, attempts: number, @@ -300,43 +323,41 @@ async function captureAndroidUiHierarchyWithHelper( await rejectAndroidHelperContentUnavailable({ contentRecovery, attempts, - helperDeviceKey, - artifact, - adb, + helperDeviceKey: params.helperDeviceKey, + artifact: params.artifact, + adb: params.adb, signal: options.signal, retireHelper: options.transient === undefined, }); - try { - let previousDecision: AndroidHelperContentRecoveryDecision | undefined; - for (let attempt = 0; ; attempt += 1) { - if (previousDecision) { - await delayBeforeContentRecapture(options.signal); - if (Date.now() >= recaptureDeadlineMs) { - return await rejectContentUnavailable(previousDecision, attempt); - } - } - const settled = await captureAndroidHelperContentAttempt({ - options, - adb, - adbProvider, - artifact, - helperDeviceKey, - attempt, - previousContentReason: previousDecision?.reason, - }); - if (settled.outcome === 'captured') return settled.capture; - if (attempt + 1 >= HELPER_CONTENT_CAPTURE_ATTEMPTS || Date.now() >= recaptureDeadlineMs) { - return await rejectContentUnavailable(settled.decision, attempt + 1); + let previousDecision: AndroidHelperContentRecoveryDecision | undefined; + for (let attempt = 0; ; attempt += 1) { + if (previousDecision) { + await delayBeforeContentRecapture(options.signal); + if (Date.now() >= recaptureDeadlineMs) { + return await rejectContentUnavailable(previousDecision, attempt); } - previousDecision = settled.decision; } - } finally { - if (releaseHelperSession) { - await stopAndroidSnapshotHelperSession(helperDeviceKey); + const settled = await captureAndroidHelperContentAttempt({ + ...params, + attempt, + previousContentReason: previousDecision?.reason, + }); + if (settled.outcome === 'captured') return settled.capture; + if (attempt + 1 >= HELPER_CONTENT_CAPTURE_ATTEMPTS || Date.now() >= recaptureDeadlineMs) { + return await rejectContentUnavailable(settled.decision, attempt + 1); } + previousDecision = settled.decision; } } +/** No re-capture starts after this instant: the end of the window, or a transient read's settle-by. */ +function resolveContentRecaptureDeadlineMs(options: AndroidSnapshotOptions): number { + return Math.min( + Date.now() + HELPER_CONTENT_RECAPTURE_WINDOW_MS, + options.transient?.settleBy ?? Number.POSITIVE_INFINITY, + ); +} + /** A transient read keeps a session it found running and releases one it had to start. */ function releasesHelperSessionAfterCapture( options: AndroidSnapshotOptions,