From 724bc1e497d7787a9f06f7376703ad9a197f104f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 05:56:28 +0200 Subject: [PATCH 1/5] fix(android): refuse close when stop fences expire --- .../src/__tests__/app-lifecycle-open.test.ts | 51 ++++++++++++++++++- .../platform-android/src/app-lifecycle.ts | 20 ++++++++ .../platform-android/src/lifecycle.test.ts | 43 ++++++++++++++++ 3 files changed, 113 insertions(+), 1 deletion(-) diff --git a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts index 9c1ce01128..70d6866a61 100644 --- a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts +++ b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts @@ -1,4 +1,4 @@ -import { test } from 'vitest'; +import { test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { closeAndroidApp, openAndroidApp } from '../app-lifecycle.ts'; import { withAndroidAdbProvider } from '../adb-executor.ts'; @@ -162,6 +162,55 @@ test('closeAndroidApp waits until package process exits after force-stop', async ]); }); +test.each(['foreground', 'process'] as const)( + 'closeAndroidApp refuses an exhausted %s fence', + async (phase) => { + vi.useFakeTimers(); + const calls: (readonly string[])[] = []; + try { + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + const stdout = + args[1] === 'pidof' + ? '12345\n' + : args[1] === 'dumpsys' + ? `mCurrentFocus=Window{42 u0 ${phase === 'foreground' ? 'com.example.app/.MainActivity' : 'com.android.launcher/.Launcher'}}\n` + : ''; + return { stdout, stderr: '', exitCode: 0 }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => { + const rejection = assert.rejects( + closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.deepEqual(error.details, { + reason: 'android-app-close-timeout', + phase, + packageName: 'com.example.app', + timeoutMs: 2_000, + }); + return true; + }, + ); + await Promise.all([rejection, vi.runAllTimersAsync()]); + }, + ); + assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']); + assert.equal( + calls.filter((args) => args[1] === 'pidof').length, + phase === 'process' ? 40 : 0, + ); + } finally { + vi.useRealTimers(); + } + }, +); + test('openAndroidApp ensures Android reverse before localhost deep link launch', async () => { const device: DeviceInfo = { platform: 'android', diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index 0986d9f7b0..60f9bdf284 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -577,6 +577,16 @@ async function waitForAndroidPackageNotForeground( if (foreground?.package !== packageName) return; await sleep(ANDROID_CLOSE_FOCUS_POLL_MS); } + throw new AppError( + 'COMMAND_FAILED', + `Android app ${packageName} remained foreground after close`, + { + reason: 'android-app-close-timeout', + phase: 'foreground', + packageName, + timeoutMs: ANDROID_CLOSE_FOCUS_TIMEOUT_MS, + }, + ); } async function readAndroidForegroundApp(device: DeviceInfo): Promise { @@ -596,6 +606,16 @@ async function waitForAndroidPackageProcessGone( } await sleep(ANDROID_CLOSE_PROCESS_POLL_MS); } + throw new AppError( + 'COMMAND_FAILED', + `Android app ${packageName} process remained running after close`, + { + reason: 'android-app-close-timeout', + phase: 'process', + packageName, + timeoutMs: ANDROID_CLOSE_PROCESS_TIMEOUT_MS, + }, + ); } async function isAndroidPackageProcessRunning( diff --git a/packages/platform-android/src/lifecycle.test.ts b/packages/platform-android/src/lifecycle.test.ts index 6d19f02761..51e345d12c 100644 --- a/packages/platform-android/src/lifecycle.test.ts +++ b/packages/platform-android/src/lifecycle.test.ts @@ -9,6 +9,9 @@ import type { Interactor, SnapshotOptions } from '@agent-device/contracts/intera import type { DeviceInfo } from '@agent-device/kernel/device'; import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors'; import { bindAndroidApplicationLifecycle } from './lifecycle.ts'; +import { closeAndroidApp } from './app-lifecycle.ts'; +import { withAndroidAdbProvider } from './adb-executor.ts'; +import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts'; const device: DeviceInfo = { platform: 'android', @@ -20,6 +23,7 @@ const device: DeviceInfo = { }; afterEach(() => { + vi.useRealTimers(); vi.restoreAllMocks(); }); @@ -41,6 +45,7 @@ function createLifecycle( input: Readonly<{ stateDir: string; shutdownTarget: boolean; signal: AbortSignal }>, ) => Promise; shutdownTarget?: () => Promise; + close?: Interactor['close']; }> = {}, ): LifecycleFixture { const calls: string[] = []; @@ -60,6 +65,7 @@ function createLifecycle( openDevice: async () => {}, close: async (app: string) => { calls.push(`close:${app}`); + await params.close?.(app); }, setSetting: async () => {}, snapshot: async (options: SnapshotOptions) => { @@ -134,6 +140,43 @@ function openInput(overrides: Partial = {}): OpenApplicati }; } +test('relaunch refuses to open or observe after the Android process close fence expires', async () => { + vi.useFakeTimers(); + bindAndroidAdbHostStub({}); + const { calls, lifecycle } = createLifecycle({ + close: async (app) => await closeAndroidApp(device, app), + }); + await withAndroidAdbProvider( + { + exec: async (args) => ({ + stdout: + args[1] === 'pidof' + ? '12345\n' + : args[1] === 'dumpsys' + ? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n' + : '', + stderr: '', + exitCode: 0, + }), + }, + { serial: device.id }, + async () => { + const rejection = expect( + lifecycle.openApplication(openInput({ relaunch: true })), + ).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + details: { + reason: 'android-app-close-timeout', + phase: 'process', + packageName: 'com.example.app', + }, + }); + await Promise.all([rejection, vi.runAllTimersAsync()]); + }, + ); + expect(calls).toEqual(['close:com.example.app']); +}); + test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => { const { calls, lifecycle } = createLifecycle(); vi.spyOn(Date, 'now') From 50b82bc9280e75bf235fd19cdb2e9ae87ac36bfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:19:33 +0200 Subject: [PATCH 2/5] fix(android): reject close probes that finish after deadline --- .../src/__tests__/app-lifecycle-open.test.ts | 64 +++++++++++++++++++ .../platform-android/src/app-lifecycle.ts | 10 ++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts index 70d6866a61..7aab37f6ce 100644 --- a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts +++ b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts @@ -211,6 +211,70 @@ test.each(['foreground', 'process'] as const)( }, ); +test.each([ + ['foreground', 1, 1_999, true, 2], + ['foreground', 1, 2_000, false, 0], + ['foreground', 1, 2_001, false, 0], + ['process', 1, 1_849, true, 2], + ['process', 1, 1_850, false, 1], + ['process', 1, 2_000, false, 1], + ['process', 1, 2_001, false, 1], + ['process', 2, 1_999, true, 2], + ['process', 2, 2_000, false, 2], + ['process', 2, 2_001, false, 2], +] as const)( + 'closeAndroidApp admits %s probe %i completing at %ims only within its fence (success=%s)', + async (phase, probe, completionMs, succeeds, expectedProcessPolls) => { + vi.useFakeTimers(); + vi.setSystemTime(0); + let processPolls = 0; + try { + await withAndroidAdbProvider( + { + exec: async (args) => { + if (args[1] === 'pidof') processPolls += 1; + if ( + (phase === 'foreground' && args[1] === 'dumpsys') || + (phase === 'process' && args[1] === 'pidof' && processPolls === probe) + ) { + await new Promise((resolve) => setTimeout(resolve, completionMs - Date.now())); + } + return { + stdout: + args[1] === 'dumpsys' + ? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n' + : '', + stderr: '', + exitCode: args[1] === 'pidof' ? 1 : 0, + }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => { + const close = closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'); + const result = succeeds + ? close + : assert.rejects(close, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.deepEqual(error.details, { + reason: 'android-app-close-timeout', + phase, + packageName: 'com.example.app', + timeoutMs: 2_000, + }); + return true; + }); + await Promise.all([result, vi.runAllTimersAsync()]); + }, + ); + assert.equal(processPolls, expectedProcessPolls); + } finally { + vi.useRealTimers(); + } + }, +); + test('openAndroidApp ensures Android reverse before localhost deep link launch', async () => { const device: DeviceInfo = { platform: 'android', diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index 60f9bdf284..60e7d11789 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -574,6 +574,7 @@ async function waitForAndroidPackageNotForeground( const deadline = Date.now() + ANDROID_CLOSE_FOCUS_TIMEOUT_MS; while (Date.now() < deadline) { const foreground = await readAndroidForegroundApp(device); + if (Date.now() >= deadline) break; if (foreground?.package !== packageName) return; await sleep(ANDROID_CLOSE_FOCUS_POLL_MS); } @@ -600,9 +601,14 @@ async function waitForAndroidPackageProcessGone( ): Promise { const deadline = Date.now() + ANDROID_CLOSE_PROCESS_TIMEOUT_MS; while (Date.now() < deadline) { - if (!(await isAndroidPackageProcessRunning(device, packageName))) { + const processRunning = await isAndroidPackageProcessRunning(device, packageName); + if (Date.now() >= deadline) break; + if (!processRunning) { await sleep(ANDROID_CLOSE_PROCESS_GONE_STABLE_MS); - if (!(await isAndroidPackageProcessRunning(device, packageName))) return; + if (Date.now() >= deadline) break; + const processStillRunning = await isAndroidPackageProcessRunning(device, packageName); + if (Date.now() >= deadline) break; + if (!processStillRunning) return; } await sleep(ANDROID_CLOSE_PROCESS_POLL_MS); } From facb79f11a808043b324928ca5a918ab188cffc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Tue, 13 Oct 2026 21:19:33 +0200 Subject: [PATCH 3/5] refactor(android): bound close fences with Deadline Co-Authored-By: Claude Opus 5.5 --- .../platform-android/src/app-lifecycle.ts | 40 ++++++++++++------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/packages/platform-android/src/app-lifecycle.ts b/packages/platform-android/src/app-lifecycle.ts index 60e7d11789..c64868337d 100644 --- a/packages/platform-android/src/app-lifecycle.ts +++ b/packages/platform-android/src/app-lifecycle.ts @@ -1,6 +1,6 @@ import { AppError } from '@agent-device/kernel/errors'; import type { AppStateRuntimeResult } from '@agent-device/contracts/app-state-runtime'; -import { sleep } from '@agent-device/host-kit/retry'; +import { Deadline, sleep } from '@agent-device/host-kit/retry'; import type { AppsFilter } from '@agent-device/contracts/device'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { isDeepLinkTarget } from '@agent-device/contracts/command'; @@ -571,11 +571,11 @@ async function waitForAndroidPackageNotForeground( device: DeviceInfo, packageName: string, ): Promise { - const deadline = Date.now() + ANDROID_CLOSE_FOCUS_TIMEOUT_MS; - while (Date.now() < deadline) { - const foreground = await readAndroidForegroundApp(device); - if (Date.now() >= deadline) break; - if (foreground?.package !== packageName) return; + const deadline = Deadline.fromTimeoutMs(ANDROID_CLOSE_FOCUS_TIMEOUT_MS); + while (!deadline.isExpired()) { + const foreground = await probeBeforeDeadline(deadline, () => readAndroidForegroundApp(device)); + if (!foreground) break; + if (foreground.value?.package !== packageName) return; await sleep(ANDROID_CLOSE_FOCUS_POLL_MS); } throw new AppError( @@ -590,6 +590,14 @@ async function waitForAndroidPackageNotForeground( ); } +async function probeBeforeDeadline( + deadline: Deadline, + probe: () => Promise, +): Promise<{ value: T } | undefined> { + const value = await probe(); + return deadline.isExpired() ? undefined : { value }; +} + async function readAndroidForegroundApp(device: DeviceInfo): Promise { const foreground = await getAndroidAppState(device); return foreground.package ? foreground : null; @@ -599,16 +607,18 @@ async function waitForAndroidPackageProcessGone( device: DeviceInfo, packageName: string, ): Promise { - const deadline = Date.now() + ANDROID_CLOSE_PROCESS_TIMEOUT_MS; - while (Date.now() < deadline) { - const processRunning = await isAndroidPackageProcessRunning(device, packageName); - if (Date.now() >= deadline) break; - if (!processRunning) { + const deadline = Deadline.fromTimeoutMs(ANDROID_CLOSE_PROCESS_TIMEOUT_MS); + const probeProcessRunning = () => + probeBeforeDeadline(deadline, () => isAndroidPackageProcessRunning(device, packageName)); + while (!deadline.isExpired()) { + const processRunning = await probeProcessRunning(); + if (!processRunning) break; + if (!processRunning.value) { await sleep(ANDROID_CLOSE_PROCESS_GONE_STABLE_MS); - if (Date.now() >= deadline) break; - const processStillRunning = await isAndroidPackageProcessRunning(device, packageName); - if (Date.now() >= deadline) break; - if (!processStillRunning) return; + if (deadline.isExpired()) break; + const processStillRunning = await probeProcessRunning(); + if (!processStillRunning) break; + if (!processStillRunning.value) return; } await sleep(ANDROID_CLOSE_PROCESS_POLL_MS); } From 46805b02678a40791b2dc638c59d9012aa4a77c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 08:18:45 +0200 Subject: [PATCH 4/5] docs(close): state the Android ADB close fence timeout Co-Authored-By: Claude Opus 5.5 --- src/commands/management/app.ts | 2 +- website/docs/docs/commands.md | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/commands/management/app.ts b/src/commands/management/app.ts index 2b1367079c..17f4e9fbce 100644 --- a/src/commands/management/app.ts +++ b/src/commands/management/app.ts @@ -82,7 +82,7 @@ const openCommandMetadata = defineFieldCommandMetadata( const closeCommandMetadata = defineFieldCommandMetadata( 'close', - 'Close the named app, or close the active session app when app is omitted. Use shutdown only when the selected simulator or emulator should also stop.', + 'Close the named app, or close the active session app when app is omitted. Use shutdown only when the selected simulator or emulator should also stop. Android ADB close fails with android-app-close-timeout if the app stays foreground, or its process stays running, past its 2s check window.', { app: stringField('Optional app to close.'), shutdown: booleanField('Shutdown the session/device where supported.'), diff --git a/website/docs/docs/commands.md b/website/docs/docs/commands.md index bb34ad5114..307d263879 100644 --- a/website/docs/docs/commands.md +++ b/website/docs/docs/commands.md @@ -91,6 +91,7 @@ Open apps and links: - `open --platform macos --surface app|frontmost-app|desktop|menubar` selects the macOS session surface; `app` is the default when you name an app. See [Desktop targets](#desktop-targets). `--save-script` records the surface so `replay` reopens it; with no surface given, replay opens an app session. - Android React Native: `open --relaunch` needs an installed package name. Install or reinstall the APK first; `open --relaunch` is rejected. For Metro-backed JS changes, run `metro reload` before reaching for `open --relaunch`. - Android (adb): an intent target such as `open settings` records the package that opened as `appBundleId`. `open settings --relaunch` stops and restarts that app. If the intent matches more than one activity, `--relaunch` fails. +- Android (ADB): `close` force-stops the app, then checks for 2 seconds that it has left the foreground and for 2 more that its process has exited. If either check window ends first, `close` fails with `details.reason: android-app-close-timeout` and `details.phase` set to `foreground` or `process`; apps Android restarts at once, such as the launcher, fail this way. iOS Simulator launch behavior: From 0c24cfdd7a485a5ab324c2ca5340f7f7332016b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 14 Oct 2026 18:38:58 +0200 Subject: [PATCH 5/5] test(android): refuse a package relaunch whose stop fence expires #3600 routed package relaunch through the same stop helper as close, so the fence refusal now also guards relaunch before any launch. Co-Authored-By: Claude Opus 5.5 --- .../src/__tests__/app-lifecycle-open.test.ts | 34 +++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts index 7aab37f6ce..7272e6014d 100644 --- a/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts +++ b/packages/platform-android/src/__tests__/app-lifecycle-open.test.ts @@ -675,6 +675,40 @@ test('openAndroidApp stops a resolved package before launching it when terminati }); }); +test('openAndroidApp refuses to relaunch a package whose stop fence expires', async () => { + vi.useFakeTimers(); + const calls: (readonly string[])[] = []; + try { + await withAndroidAdbProvider( + { + exec: async (args) => { + calls.push(args); + const stdout = + args[1] === 'dumpsys' + ? 'mCurrentFocus=Window{42 u0 com.example.app/.MainActivity}\n' + : ''; + return { stdout, stderr: '', exitCode: 0 }; + }, + }, + { serial: ANDROID_EMULATOR.id }, + async () => { + const rejection = assert.rejects( + openAndroidApp(ANDROID_EMULATOR, 'com.example.app', { terminateRunningApp: true }), + (error: unknown) => + error instanceof AppError && + error.details?.reason === 'android-app-close-timeout' && + error.details?.phase === 'foreground', + ); + await Promise.all([rejection, vi.runAllTimersAsync()]); + }, + ); + assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']); + assert.equal(calls.filter((args) => args[2] === 'start').length, 0, 'no launch after refusal'); + } finally { + vi.useRealTimers(); + } +}); + test('openAndroidApp appends launchArgs to am start when launching by package', async () => { await withFakeAdb(androidOpenFakeAdb, async ({ calls, device }) => { await openAndroidApp(device, 'com.example.app', {