Skip to content

Commit d3b7385

Browse files
committed
fix(android): refuse close when stop fences expire
1 parent cbae29e commit d3b7385

3 files changed

Lines changed: 113 additions & 1 deletion

File tree

‎packages/platform-android/src/__tests__/app-lifecycle-open.test.ts‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { test } from 'vitest';
1+
import { test, vi } from 'vitest';
22
import assert from 'node:assert/strict';
33
import { closeAndroidApp, openAndroidApp } from '../app-lifecycle.ts';
44
import { withAndroidAdbProvider } from '../adb-executor.ts';
@@ -162,6 +162,55 @@ test('closeAndroidApp waits until package process exits after force-stop', async
162162
]);
163163
});
164164

165+
test.each(['foreground', 'process'] as const)(
166+
'closeAndroidApp refuses an exhausted %s fence',
167+
async (phase) => {
168+
vi.useFakeTimers();
169+
const calls: (readonly string[])[] = [];
170+
try {
171+
await withAndroidAdbProvider(
172+
{
173+
exec: async (args) => {
174+
calls.push(args);
175+
const stdout =
176+
args[1] === 'pidof'
177+
? '12345\n'
178+
: args[1] === 'dumpsys'
179+
? `mCurrentFocus=Window{42 u0 ${phase === 'foreground' ? 'com.example.app/.MainActivity' : 'com.android.launcher/.Launcher'}}\n`
180+
: '';
181+
return { stdout, stderr: '', exitCode: 0 };
182+
},
183+
},
184+
{ serial: ANDROID_EMULATOR.id },
185+
async () => {
186+
const rejection = assert.rejects(
187+
closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'),
188+
(error: unknown) => {
189+
assert.ok(error instanceof AppError);
190+
assert.equal(error.code, 'COMMAND_FAILED');
191+
assert.deepEqual(error.details, {
192+
reason: 'android-app-close-timeout',
193+
phase,
194+
packageName: 'com.example.app',
195+
timeoutMs: 2_000,
196+
});
197+
return true;
198+
},
199+
);
200+
await Promise.all([rejection, vi.runAllTimersAsync()]);
201+
},
202+
);
203+
assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']);
204+
assert.equal(
205+
calls.filter((args) => args[1] === 'pidof').length,
206+
phase === 'process' ? 40 : 0,
207+
);
208+
} finally {
209+
vi.useRealTimers();
210+
}
211+
},
212+
);
213+
165214
test('openAndroidApp ensures Android reverse before localhost deep link launch', async () => {
166215
const device: DeviceInfo = {
167216
platform: 'android',

‎packages/platform-android/src/app-lifecycle.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,16 @@ async function waitForAndroidPackageNotForeground(
546546
if (foreground?.package !== packageName) return;
547547
await sleep(ANDROID_CLOSE_FOCUS_POLL_MS);
548548
}
549+
throw new AppError(
550+
'COMMAND_FAILED',
551+
`Android app ${packageName} remained foreground after close`,
552+
{
553+
reason: 'android-app-close-timeout',
554+
phase: 'foreground',
555+
packageName,
556+
timeoutMs: ANDROID_CLOSE_FOCUS_TIMEOUT_MS,
557+
},
558+
);
549559
}
550560

551561
async function readAndroidForegroundApp(device: DeviceInfo): Promise<AppStateRuntimeResult | null> {
@@ -565,6 +575,16 @@ async function waitForAndroidPackageProcessGone(
565575
}
566576
await sleep(ANDROID_CLOSE_PROCESS_POLL_MS);
567577
}
578+
throw new AppError(
579+
'COMMAND_FAILED',
580+
`Android app ${packageName} process remained running after close`,
581+
{
582+
reason: 'android-app-close-timeout',
583+
phase: 'process',
584+
packageName,
585+
timeoutMs: ANDROID_CLOSE_PROCESS_TIMEOUT_MS,
586+
},
587+
);
568588
}
569589

570590
async function isAndroidPackageProcessRunning(

‎packages/platform-android/src/lifecycle.test.ts‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ import type { Interactor, SnapshotOptions } from '@agent-device/contracts/intera
99
import type { DeviceInfo } from '@agent-device/kernel/device';
1010
import { AppError, createRequestCanceledError } from '@agent-device/kernel/errors';
1111
import { bindAndroidApplicationLifecycle } from './lifecycle.ts';
12+
import { closeAndroidApp } from './app-lifecycle.ts';
13+
import { withAndroidAdbProvider } from './adb-executor.ts';
14+
import { bindAndroidAdbHostStub } from './adb-host.fixtures.ts';
1215

1316
const device: DeviceInfo = {
1417
platform: 'android',
@@ -20,6 +23,7 @@ const device: DeviceInfo = {
2023
};
2124

2225
afterEach(() => {
26+
vi.useRealTimers();
2327
vi.restoreAllMocks();
2428
});
2529

@@ -39,6 +43,7 @@ function createLifecycle(
3943
input: Readonly<{ stateDir: string; shutdownTarget: boolean; signal: AbortSignal }>,
4044
) => Promise<void>;
4145
shutdownTarget?: () => Promise<unknown>;
46+
close?: Interactor['close'];
4247
}> = {},
4348
): LifecycleFixture {
4449
const calls: string[] = [];
@@ -53,6 +58,7 @@ function createLifecycle(
5358
openDevice: async () => {},
5459
close: async (app: string) => {
5560
calls.push(`close:${app}`);
61+
await params.close?.(app);
5662
},
5763
setSetting: async () => {},
5864
snapshot: async (options: SnapshotOptions) => {
@@ -164,6 +170,43 @@ test.each([
164170
expect(outcome.appBundleId).toBe(appBundleId);
165171
});
166172

173+
test('relaunch refuses to open or observe after the Android process close fence expires', async () => {
174+
vi.useFakeTimers();
175+
bindAndroidAdbHostStub({});
176+
const { calls, lifecycle } = createLifecycle({
177+
close: async (app) => await closeAndroidApp(device, app),
178+
});
179+
await withAndroidAdbProvider(
180+
{
181+
exec: async (args) => ({
182+
stdout:
183+
args[1] === 'pidof'
184+
? '12345\n'
185+
: args[1] === 'dumpsys'
186+
? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n'
187+
: '',
188+
stderr: '',
189+
exitCode: 0,
190+
}),
191+
},
192+
{ serial: device.id },
193+
async () => {
194+
const rejection = expect(
195+
lifecycle.openApplication(openInput({ relaunch: true })),
196+
).rejects.toMatchObject({
197+
code: 'COMMAND_FAILED',
198+
details: {
199+
reason: 'android-app-close-timeout',
200+
phase: 'process',
201+
packageName: 'com.example.app',
202+
},
203+
});
204+
await Promise.all([rejection, vi.runAllTimersAsync()]);
205+
},
206+
);
207+
expect(calls).toEqual(['close:com.example.app']);
208+
});
209+
167210
test('preserves a runtime launch URL duration after the admitted Android follow-up open', async () => {
168211
const { calls, lifecycle } = createLifecycle();
169212
vi.spyOn(Date, 'now')

0 commit comments

Comments
 (0)