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 .nx/version-plans/version-plan-1784029535991.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
__default__: patch
---

Android E2E crash-detection logcat cleanup no longer surfaces an unhandled promise rejection during Harness shutdown, which previously crashed the whole test process.
8 changes: 6 additions & 2 deletions packages/jest/src/harness-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -791,9 +791,13 @@ export const createHarnessSession = async (

let cleanupError: unknown;
try {
// The app session (and its logcat/device log stream) must be fully
// torn down before the platform is disposed: platform disposal can
// shut down the device/emulator, and doing that concurrently with
// the app session's own cleanup races the device's log stream
// against the device disappearing out from under it.
await Promise.all([crashMonitor.dispose(), disposeCurrentAppSession()]);
await Promise.all([
crashMonitor.dispose(),
disposeCurrentAppSession(),
bridge.dispose(),
platformInstance.dispose(),
metroInstance.dispose(),
Expand Down
54 changes: 32 additions & 22 deletions packages/platform-android/src/__tests__/instance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@ import * as emulatorStartup from '../emulator-startup.js';
const { getEmulatorCpuCores } = emulatorStartup;

const createLogcatProcess = (lines: string[] = []): Subprocess => {
const process = {
// Mirrors nano-spawn's real Subprocess, which is both a Promise and an
// async iterable, since app-session.ts relies on it being thenable.
const process = Object.assign(Promise.resolve(undefined), {
nodeChildProcess: Promise.resolve({
kill: vi.fn(),
}),
Expand All @@ -25,7 +27,7 @@ const createLogcatProcess = (lines: string[] = []): Subprocess => {
yield line;
}
},
};
});

return process as unknown as Subprocess;
};
Expand Down Expand Up @@ -545,16 +547,20 @@ describe('Android platform instance', () => {
appSession.addListener(listener);
await vi.advanceTimersByTimeAsync(0);

expect(startLogcat).toHaveBeenCalledWith('emulator-5554', [
'logcat',
'-v',
'threadtime',
'-b',
'crash',
'--uid=10234',
'-T',
'01-01 00:00:00.000',
]);
expect(startLogcat).toHaveBeenCalledWith(
'emulator-5554',
[
'logcat',
'-v',
'threadtime',
'-b',
'crash',
'--uid=10234',
'-T',
'01-01 00:00:00.000',
],
{ signal: expect.any(AbortSignal) }
);
expect(startLogcat.mock.invocationCallOrder[0]).toBeLessThan(
startApp.mock.invocationCallOrder[0]
);
Expand Down Expand Up @@ -679,16 +685,20 @@ describe('Android platform instance', () => {
appSession.addListener(listener);
await vi.advanceTimersByTimeAsync(0);

expect(startLogcat).toHaveBeenCalledWith('012345', [
'logcat',
'-v',
'threadtime',
'-b',
'crash',
'--uid=10234',
'-T',
'01-01 00:00:00.000',
]);
expect(startLogcat).toHaveBeenCalledWith(
'012345',
[
'logcat',
'-v',
'threadtime',
'-b',
'crash',
'--uid=10234',
'-T',
'01-01 00:00:00.000',
],
{ signal: expect.any(AbortSignal) }
);
await expect(appSession.getState()).resolves.toEqual({
status: 'running',
pid: 8765,
Expand Down
4 changes: 3 additions & 1 deletion packages/platform-android/src/adb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -792,11 +792,13 @@ export const getLogcatTimestamp = async (adbId: string): Promise<string> => {

export const startLogcat = (
adbId: string,
args: readonly string[]
args: readonly string[],
options?: { signal?: AbortSignal }
): Subprocess =>
spawn(getAdbBinaryPath(), ['-s', adbId, ...args], {
stdout: 'pipe',
stderr: 'pipe',
signal: options?.signal,
});

export const DROPBOX_CRASH_TAGS = [
Expand Down
29 changes: 17 additions & 12 deletions packages/platform-android/src/app-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,14 +62,6 @@ const isCrashSignal = (line: string, bundleId: string): boolean => {
);
};

const stopSubprocess = async (child: Subprocess) => {
try {
(await child.nodeChildProcess).kill();
} catch {
// Ignore termination failures for already-ended background processes.
}
};

const isExitedState = (
state: AppSessionState
): state is Extract<AppSessionState, { status: 'exited' }> =>
Expand All @@ -82,7 +74,10 @@ type CreateAndroidAppSessionOptions = {
stopApp: () => Promise<void>;
getAppPid: () => Promise<number | null>;
getLogcatTimestamp: () => Promise<string>;
startLogcat: (args: readonly string[]) => Subprocess;
startLogcat: (
args: readonly string[],
options: { signal: AbortSignal }
) => Subprocess;
getDropboxOutput?: () => Promise<string>;
getExitInfo?: () => Promise<string>;
crashArtifactWriter?: CrashArtifactWriter;
Expand Down Expand Up @@ -193,7 +188,17 @@ export const createAndroidAppSession = async ({

const logcatTimestamp = await getLogcatTimestamp();
const sessionStartedAt = Date.now();
const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp));
const logcatAbortController = new AbortController();
const logcatProcess = startLogcat(getLogcatArgs(appUid, logcatTimestamp), {
signal: logcatAbortController.signal,
});
// Aborting is nano-spawn's own cancellation path, so the resulting
// SubprocessError is settled the same way regardless of which of the
// merged stdout/stderr iterators observes it first. Without this, killing
// the underlying process directly can make both iterators reject at once,
// and nano-spawn's internal `Promise.race` only reports one of them,
// leaving the other an unhandled rejection.
logcatProcess.catch(() => undefined);
const crashReporter = createAndroidCrashReporter({
bundleId,
crashArtifactWriter,
Expand Down Expand Up @@ -239,7 +244,7 @@ export const createAndroidAppSession = async ({
disposed = true;
stopPolling = true;
emitter.clear();
await stopSubprocess(logcatProcess);
logcatAbortController.abort();
await Promise.allSettled([logTask]);
throw error;
}
Expand Down Expand Up @@ -287,7 +292,7 @@ export const createAndroidAppSession = async ({
cancelPendingPollDelay();

emitter.clear();
await stopSubprocess(logcatProcess);
logcatAbortController.abort();
await stopApp();
await Promise.allSettled([logTask, pollTask]);
},
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-android/src/instance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -297,7 +297,7 @@ export const getAndroidEmulatorPlatformInstance = async (
stopApp: () => adb.stopApp(adbId, config.bundleId),
getAppPid: () => adb.getAppPid(adbId, config.bundleId),
getLogcatTimestamp: () => adb.getLogcatTimestamp(adbId),
startLogcat: (args) => adb.startLogcat(adbId, args),
startLogcat: (args, options) => adb.startLogcat(adbId, args, options),
getDropboxOutput: () => adb.getDropboxPrint(adbId),
getExitInfo: () => adb.getActivityExitInfo(adbId, config.bundleId),
crashArtifactWriter: init.crashArtifactWriter,
Expand Down Expand Up @@ -370,7 +370,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async (
stopApp: () => adb.stopApp(adbId, config.bundleId),
getAppPid: () => adb.getAppPid(adbId, config.bundleId),
getLogcatTimestamp: () => adb.getLogcatTimestamp(adbId),
startLogcat: (args) => adb.startLogcat(adbId, args),
startLogcat: (args, options) => adb.startLogcat(adbId, args, options),
getDropboxOutput: () => adb.getDropboxPrint(adbId),
getExitInfo: () => adb.getActivityExitInfo(adbId, config.bundleId),
crashArtifactWriter: init?.crashArtifactWriter,
Expand Down
Loading