Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/android-close-killed-exit.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'agent-device': patch
---

Fix Android `close` and `open --relaunch` failing with `android-app-close-timeout` on a loaded emulator while the force-stopped app was still exiting. Close now waits up to 10 seconds for the processes it killed to exit and reports a survivor with `details.phase: exit`; a new process still fails with `process` after 2 seconds. When `pidof` cannot list the app's processes, close fails with `android-process-probe-unavailable`.
254 changes: 228 additions & 26 deletions packages/platform-android/src/__tests__/app-lifecycle-close.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ test('closeAndroidApp waits until package is no longer foreground', async () =>
);

assert.deepEqual(calls, [
['shell', 'pidof', 'com.example.app'],
['shell', 'am', 'force-stop', 'com.example.app'],
['shell', 'dumpsys', 'window', 'windows'],
['shell', 'dumpsys', 'window', 'windows'],
Expand Down Expand Up @@ -88,6 +89,7 @@ test('closeAndroidApp returns after force-stop when package is already not foreg
);

assert.deepEqual(calls, [
['shell', 'pidof', 'com.example.app'],
['shell', 'am', 'force-stop', 'com.example.app'],
['shell', 'dumpsys', 'window', 'windows'],
['shell', 'pidof', 'com.example.app'],
Expand Down Expand Up @@ -119,11 +121,8 @@ test('closeAndroidApp waits until package process exits after force-stop', async
}
if (args.join(' ') === 'shell pidof com.example.app') {
processPolls += 1;
return {
stdout: processPolls === 1 ? '12345\n' : '',
stderr: '',
exitCode: processPolls === 1 ? 0 : 1,
};
const running = processPolls <= 2;
return { stdout: running ? '12345\n' : '', stderr: '', exitCode: running ? 0 : 1 };
}
return { stdout: '', stderr: '', exitCode: 0 };
},
Expand All @@ -138,27 +137,68 @@ test('closeAndroidApp waits until package process exits after force-stop', async
);

assert.deepEqual(calls, [
['shell', 'pidof', 'com.example.app'],
['shell', 'am', 'force-stop', 'com.example.app'],
['shell', 'dumpsys', 'window', 'windows'],
['shell', 'pidof', 'com.example.app'],
['shell', 'pidof', 'com.example.app'],
['shell', 'pidof', 'com.example.app'],
['shell', 'pidof', 'com.example.app'],
]);
});

test.each(['foreground', 'process'] as const)(
test('closeAndroidApp waits past the process fence for a killed process that exits slowly', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
try {
await withAndroidAdbProvider(
{
exec: async (args) => {
const running = args[1] === 'pidof' && Date.now() < 3_000;
return {
stdout:
args[1] === 'dumpsys'
? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n'
: running
? '12345\n'
: '',
stderr: '',
exitCode: args[1] === 'pidof' && !running ? 1 : 0,
};
},
},
{ serial: ANDROID_EMULATOR.id },
async () => {
await Promise.all([
closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'),
vi.runAllTimersAsync(),
]);
},
);
assert.ok(Date.now() >= 3_000);
} finally {
vi.useRealTimers();
}
});

test.each([
['foreground', 2_000, 1],
['exit', 10_000, 201],
['process', 2_000, 42],
] as const)(
'closeAndroidApp refuses an exhausted %s fence',
async (phase) => {
async (phase, timeoutMs, pidofCalls) => {
vi.useFakeTimers();
const calls: (readonly string[])[] = [];
try {
await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push(args);
const killed = calls.filter((call) => call[1] === 'am').length === 0;
const stdout =
args[1] === 'pidof'
? '12345\n'
? `${killed || phase !== 'process' ? '12345' : '67890'}\n`
: args[1] === 'dumpsys'
? `mCurrentFocus=Window{42 u0 ${phase === 'foreground' ? 'com.example.app/.MainActivity' : 'com.android.launcher/.Launcher'}}\n`
: '';
Expand All @@ -176,36 +216,33 @@ test.each(['foreground', 'process'] as const)(
reason: 'android-app-close-timeout',
phase,
packageName: 'com.example.app',
timeoutMs: 2_000,
timeoutMs,
});
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,
);
assert.deepEqual(calls[1], ['shell', 'am', 'force-stop', 'com.example.app']);
assert.equal(calls.filter((args) => args[1] === 'pidof').length, pidofCalls);
} finally {
vi.useRealTimers();
}
},
);

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],
['foreground', 1, 1_999, true, 3],
['foreground', 1, 2_000, false, 1],
['foreground', 1, 2_001, false, 1],
['process', 1, 1_849, true, 3],
['process', 1, 1_850, false, 2],
['process', 1, 2_000, false, 2],
['process', 1, 2_001, false, 2],
['process', 2, 1_999, true, 3],
['process', 2, 2_000, false, 3],
['process', 2, 2_001, false, 3],
] as const)(
'closeAndroidApp admits %s probe %i completing at %ims only within its fence (success=%s)',
async (phase, probe, completionMs, succeeds, expectedProcessPolls) => {
Expand All @@ -219,7 +256,7 @@ test.each([
if (args[1] === 'pidof') processPolls += 1;
if (
(phase === 'foreground' && args[1] === 'dumpsys') ||
(phase === 'process' && args[1] === 'pidof' && processPolls === probe)
(phase === 'process' && args[1] === 'pidof' && processPolls === probe + 1)
) {
await new Promise((resolve) => setTimeout(resolve, completionMs - Date.now()));
}
Expand Down Expand Up @@ -286,9 +323,174 @@ test('openAndroidApp refuses to relaunch a package whose stop fence expires', as
await Promise.all([rejection, vi.runAllTimersAsync()]);
},
);
assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']);
assert.deepEqual(calls[1], ['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.each([
[9_999, true, 4],
[10_000, false, 2],
[10_001, false, 2],
] as const)(
'closeAndroidApp admits a killed-process exit probe completing at %ims only within its fence (success=%s)',
async (completionMs, succeeds, expectedProcessPolls) => {
vi.useFakeTimers();
vi.setSystemTime(0);
let processPolls = 0;
try {
await withAndroidAdbProvider(
{
exec: async (args) => {
if (args[1] === 'pidof') processPolls += 1;
if (args[1] === 'pidof' && processPolls === 2) {
await new Promise((resolve) => setTimeout(resolve, completionMs - Date.now()));
}
const running = args[1] === 'pidof' && processPolls === 1;
return {
stdout:
args[1] === 'dumpsys'
? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n'
: running
? '12345\n'
: '',
stderr: '',
exitCode: args[1] === 'pidof' && !running ? 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.deepEqual(error.details, {
reason: 'android-app-close-timeout',
phase: 'exit',
packageName: 'com.example.app',
timeoutMs: 10_000,
});
return true;
});
await Promise.all([result, vi.runAllTimersAsync()]);
},
);
assert.equal(processPolls, expectedProcessPolls);
} finally {
vi.useRealTimers();
}
},
);

test('closeAndroidApp refuses a new process that outlives the killed one', async () => {
vi.useFakeTimers();
vi.setSystemTime(0);
const calls: (readonly string[])[] = [];
try {
await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push(args);
const killed = calls.some((call) => call[1] === 'am');
const pids = !killed ? '12345' : Date.now() < 500 ? '12345 67890' : '67890';
return {
stdout:
args[1] === 'dumpsys'
? 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n'
: args[1] === 'pidof'
? `${pids}\n`
: '',
stderr: '',
exitCode: 0,
};
},
},
{ serial: ANDROID_EMULATOR.id },
async () => {
const rejection = assert.rejects(
closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'),
(error: unknown) =>
error instanceof AppError &&
error.details?.reason === 'android-app-close-timeout' &&
error.details?.phase === 'process',
);
await Promise.all([rejection, vi.runAllTimersAsync()]);
},
);
} finally {
vi.useRealTimers();
}
});

test.each([
['an adb error', async () => ({ stdout: '', stderr: 'error: closed', exitCode: 255 })],
[
'non-pid output',
async () => ({ stdout: '/system/bin/sh: pidof: not found\n', stderr: '', exitCode: 0 }),
],
[
'an exec timeout',
async () => {
throw new AppError('COMMAND_FAILED', 'adb timed out after 2000ms', { timeoutMs: 2_000 });
},
],
] as const)(
'closeAndroidApp refuses to force-stop when pidof answers with %s',
async (_, pidof) => {
const calls: (readonly string[])[] = [];
await withAndroidAdbProvider(
{
exec: async (args) => {
calls.push(args);
return await pidof();
},
},
{ serial: ANDROID_EMULATOR.id },
async () => {
await assert.rejects(
closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'),
(error: unknown) =>
error instanceof AppError &&
error.details?.reason === 'android-process-probe-unavailable',
);
},
);
assert.deepEqual(calls, [['shell', 'pidof', 'com.example.app']]);
},
);

test('closeAndroidApp reports an unanswered pidof after force-stop instead of an exit timeout', async () => {
let pidofCalls = 0;
await withAndroidAdbProvider(
{
exec: async (args) => {
if (args[1] === 'dumpsys') {
return {
stdout: 'mCurrentFocus=Window{43 u0 com.android.launcher/.Launcher}\n',
stderr: '',
exitCode: 0,
};
}
if (args[1] !== 'pidof') return { stdout: '', stderr: '', exitCode: 0 };
pidofCalls += 1;
return pidofCalls === 1
? { stdout: '12345\n', stderr: '', exitCode: 0 }
: { stdout: '', stderr: 'error: device offline', exitCode: 1 };
},
},
{ serial: ANDROID_EMULATOR.id },
async () => {
await assert.rejects(
closeAndroidApp(ANDROID_EMULATOR, 'com.example.app'),
(error: unknown) =>
error instanceof AppError &&
error.details?.reason === 'android-process-probe-unavailable',
);
},
);
assert.equal(pidofCalls, 2);
});
Original file line number Diff line number Diff line change
Expand Up @@ -409,15 +409,21 @@ test('openAndroidApp stops the bound package before an app-bound deep link when
url: 'example://bottom-tabs',
terminateRunningApp: true,
});
assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']);
assert.deepEqual(calls.slice(0, 2), [
['shell', 'pidof', 'com.example.app'],
['shell', 'am', 'force-stop', 'com.example.app'],
]);
assert.deepEqual(calls.at(-1)?.slice(0, 4), ['shell', 'am', 'start', '-W']);
});
});

test('openAndroidApp stops a resolved package before launching it when terminating a running app', async () => {
await withFakeAdb(androidOpenFakeAdb, async ({ calls, device }) => {
await openAndroidApp(device, 'com.example.app', { terminateRunningApp: true });
assert.deepEqual(calls[0], ['shell', 'am', 'force-stop', 'com.example.app']);
assert.deepEqual(calls.slice(0, 2), [
['shell', 'pidof', 'com.example.app'],
['shell', 'am', 'force-stop', 'com.example.app'],
]);
assert.deepEqual(calls.at(-1)?.slice(0, 4), ['shell', 'am', 'start', '-W']);
assert.equal(calls.filter((call) => call[2] === 'start').length, 1);
});
Expand Down
Loading
Loading