Skip to content
Open
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/agent-device-stop-during-startup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---

Stop an agent-device session that is still starting up when it is stopped.
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForFileAsync: jest.fn(),
}));

// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}

const finishHost = jest.fn();
const closePreview = jest.fn();
const stopTool = jest.fn();
Expand All @@ -54,6 +74,7 @@ const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnTh

beforeEach(() => {
jest.clearAllMocks();
routeStartupWatch();
// The daemon package is external. No installation or daemon process runs in these tests.
jest.spyOn(fs, 'existsSync').mockReturnValue(true);
// Only the emulator logcat folder: the Appium step writes into its own temp folder.
Expand Down Expand Up @@ -99,7 +120,7 @@ beforeEach(() => {
jest.mocked(startAgentDeviceEventCollectionAsync).mockResolvedValue(events);
jest.mocked(startAppiumEventCollectionAsync).mockResolvedValue(events);
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
lifetimeWait.mockResolvedValue(undefined);
});

afterEach(() => {
Expand Down Expand Up @@ -157,7 +178,7 @@ describe.each([
expect(finishHost).toHaveBeenCalledTimes(1);
expect(closePreview).not.toHaveBeenCalled();
expect(finishHost.mock.invocationCallOrder[0]).toBeGreaterThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
lifetimeWait.mock.invocationCallOrder[0]
);
expect(stopTool).toHaveBeenCalledTimes(1);
expect(stopEvents).toHaveBeenCalledTimes(1);
Expand Down Expand Up @@ -302,7 +323,7 @@ describe.each([
} else if (phase === 'config') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValueOnce(error);
} else {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValueOnce(error);
lifetimeWait.mockRejectedValueOnce(error);
}
await expect(runAsync()).rejects.toBe(error);
expect(finishHost).toHaveBeenCalledTimes(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));

// Agent-device also polls for Stop while it starts up. That watch sets neither an idle timeout nor
// a duration; keep it pending until startup retires it, and give each lifetime wait its own mock.
const lifetimeWait = jest.fn<
ReturnType<typeof waitForDeviceRunSessionStoppedAsync>,
Parameters<typeof waitForDeviceRunSessionStoppedAsync>
>();
function routeStartupWatch(): void {
lifetimeWait.mockReset();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async args => {
if ('idleTimeout' in args || 'maxDurationSeconds' in args) {
return await lifetimeWait(args);
}
if (!args.signal?.aborted) {
await new Promise<void>(resolve =>
args.signal?.addEventListener('abort', () => resolve(), { once: true })
);
}
});
}

const logger = {
info: jest.fn(),
warn: jest.fn(),
Expand Down Expand Up @@ -124,6 +144,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
}
beforeEach(() => {
jest.clearAllMocks();
routeStartupWatch();
lifetime = registerLocalEgressResources(stopEgress);
stopEgress.mockResolvedValue(undefined);
stopPreview.mockResolvedValue(undefined);
Expand Down Expand Up @@ -166,7 +187,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
finishAsync: stopPreview,
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => {
lifetimeWait.mockImplementation(async () => {
expect(lifetime.aborted).toBe(false);
expect(monitorLocalEgressAsync).toHaveBeenCalledTimes(1);
});
Expand All @@ -178,16 +199,18 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller

it('reports credentials and starts monitoring before waiting, then releases resources', async () => {
await run();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
});
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({
ctx,
deviceRunSessionId: 'session-id',
logger,
remoteConfig: expect.objectContaining({
...fields,
[controllerField]: expect.any(String),
[controllerField === 'previewUrl' ? 'previewToken' : 'webPreviewToken']: 'preview-secret',
}),
})
);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith({
port: handoff.port,
env,
Expand All @@ -201,7 +224,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
await stopLocalEgressResourcesAsync(logger);
stopEgress.mockClear();
jest.mocked(readLocalEgressHandoffAsync).mockResolvedValue(null);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
lifetimeWait.mockResolvedValue(undefined);
await run();
const { remoteConfig } = jest.mocked(uploadRemoteSessionConfigAsync).mock.calls[0][0];
expect(remoteConfig).toHaveProperty(controllerField);
Expand All @@ -220,7 +243,7 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
} else if (phase === 'report') {
jest.mocked(uploadRemoteSessionConfigAsync).mockRejectedValue(error);
} else if (phase === 'wait') {
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockRejectedValue(error);
lifetimeWait.mockRejectedValue(error);
} else {
stopPreview.mockRejectedValue(error);
}
Expand All @@ -234,11 +257,10 @@ describe.each(controllers)('%s local egress', (_name, createFunction, controller
);
it('passes cancellation to the monitor and releases the lifetime', async () => {
const abort = new AbortController();
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockImplementation(async () => abort.abort());
lifetimeWait.mockImplementation(async () => abort.abort());
await run(abort.signal);
expect(monitorLocalEgressAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: abort.signal })
);
// Agent-device hands the monitor its startup signal, which follows the step signal.
expect(jest.mocked(monitorLocalEgressAsync).mock.calls[0][0].signal?.aborted).toBe(true);
expect(lifetime.aborted).toBe(true);
expect(stopEgress).toHaveBeenCalledTimes(1);
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { pollAgentDeviceArtifactsForUploadAsync } from '../../utils/agentDeviceA
import { startAgentDeviceEventCollectionAsync } from '../../utils/agentDeviceEvents';
import { startDeviceSessionHostAsync } from '../../utils/deviceSessionHost';
import {
DeviceRunSessionStoppedError,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
getNgrokTunnelDomainOrThrow,
Expand Down Expand Up @@ -337,7 +338,8 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
type Device = Parameters<typeof runAgentDeviceRemoteSessionAsync>[1]['device'];
function startSession(
device: Device | ((tasks: StartupTasks) => Device),
signal?: AbortSignal
signal?: AbortSignal,
onStarted?: () => Promise<void>
) {
const logger = {
info: jest.fn(),
Expand Down Expand Up @@ -365,6 +367,7 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
capture: NO_CAPTURE,
tasks,
device: sessionDevice,
onStarted,
});
}

Expand Down Expand Up @@ -640,6 +643,79 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
});

it('drains resources and preserves the explicit startup Stop reason', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});

it('does not hide a teardown failure after an explicit startup Stop', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
const failed = new Error('daemon teardown failed');
let tasks!: StartupTasks;
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: tasks.untilAborted(new Promise(() => {})) };
});
session.catch(() => {});
await flushAsync();
mockDaemonStopAsync.mockRejectedValueOnce(failed);
tasks.abort(stopped);
await expect(session).rejects.toBe(failed);
});

it('retires startup monitoring after reporting and before lifetime waiting', async () => {
const retired = deferred();
const retiring = jest.fn(() => retired.promise);
const session = startSession(
{ booted: Promise.resolve(), ready: Promise.resolve() },
undefined,
retiring
);
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledTimes(1);
expect(retiring).toHaveBeenCalledTimes(1);
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
retired.resolve();
await session;
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledTimes(1);
});

it('aborts a pending ready report when startup is stopped', async () => {
const stopped = new DeviceRunSessionStoppedError('stopped during startup');
let tasks!: StartupTasks;
jest.mocked(uploadRemoteSessionConfigAsync).mockImplementation(
async ({ signal }) =>
new Promise((_resolve, reject) => {
signal!.addEventListener('abort', () => reject(new Error('request aborted')), {
once: true,
});
})
);
const session = startSession(startup => {
tasks = startup;
return { booted: Promise.resolve(), ready: Promise.resolve() };
});
session.catch(() => {});
await flushAsync();
expect(uploadRemoteSessionConfigAsync).toHaveBeenCalledWith(
expect.objectContaining({ signal: tasks.signal })
);
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(startAgentDeviceEventCollectionAsync).not.toHaveBeenCalled();
});

it('starts the Android host before application readiness and waits before reporting ready', async () => {
const ready = deferred();
const session = startSession({ booted: Promise.resolve(), ready: ready.promise });
Expand Down
Loading
Loading