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

Stop a web-preview session that is still starting up when it is stopped.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import {
uploadRemoteSessionConfigAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../../utils/remoteDeviceRunSession';
import { watchDeviceRunSessionStartup } from '../../utils/deviceRunSessionStartup';
import { createStartupWatchMock } from '../../../__tests__/utils/startupWatch';
import { createStartWebPreviewRemoteSessionBuildFunction } from '../startWebPreviewRemoteSession';

jest.mock('../../utils/deviceSessionHost');
Expand Down Expand Up @@ -39,6 +41,9 @@ jest.mock('../../utils/remoteDeviceRunSession', () => ({
uploadRemoteSessionConfigAsync: jest.fn(),
waitForDeviceRunSessionStoppedAsync: jest.fn(),
}));
jest.mock('../../utils/deviceRunSessionStartup', () => ({
watchDeviceRunSessionStartup: jest.fn(),
}));

const ctx = {} as CustomBuildContext;
const env = { NGROK_AUTHTOKEN: 'ngrok-token', PATH: '/usr/bin' } as BuildStepEnv;
Expand All @@ -48,7 +53,8 @@ const stopAsync = jest.fn();

async function runAsync(
runtimePlatform: BuildRuntimePlatform,
launchInputs: Record<string, { value: unknown }> = {}
launchInputs: Record<string, { value: unknown }> = {},
signal?: AbortSignal
): Promise<void> {
const buildFunction = createStartWebPreviewRemoteSessionBuildFunction(ctx);
await buildFunction.fn!(
Expand All @@ -63,6 +69,7 @@ async function runAsync(
...launchInputs,
},
outputs: {},
signal,
env,
} as never
);
Expand All @@ -86,9 +93,80 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
});
jest.mocked(uploadRemoteSessionConfigAsync).mockResolvedValue(undefined);
jest.mocked(waitForDeviceRunSessionStoppedAsync).mockResolvedValue(undefined);
jest.mocked(watchDeviceRunSessionStartup).mockImplementation(createStartupWatchMock);
stopAsync.mockResolvedValue(undefined);
});

it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'handles Stop while the %s host starts',
async runtimePlatform => {
let hostEntered!: () => void;
const entered = new Promise<void>(resolve => {
hostEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockImplementationOnce((_ctx, { signal }) => {
hostEntered();
return new Promise((_resolve, reject) =>
signal!.addEventListener('abort', () => reject(new Error('host aborted')), { once: true })
);
});
const running = runAsync(runtimePlatform);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
await expect(running).resolves.toBeUndefined();
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
expect(watch.finishAsync).toHaveBeenCalled();
}
);

it('keeps a teardown failure visible after Stop during preview setup', async () => {
let previewEntered!: () => void;
let finishPreview!: (value: {
previewPageUrl: string;
apiUrl: string;
closeAsync: () => Promise<void>;
}) => void;
const entered = new Promise<void>(resolve => {
previewEntered = resolve;
});
jest.mocked(startDeviceSessionHostAsync).mockResolvedValueOnce({
openPreviewAsync: () => {
previewEntered();
return new Promise(resolve => {
finishPreview = resolve;
});
},
finishAsync: stopAsync,
getLastPreviewActivityAt: jest.fn(),
});
const failed = new Error('host teardown failed');
stopAsync.mockRejectedValueOnce(failed);
const running = runAsync(BuildRuntimePlatform.LINUX);
running.catch(() => {});
await entered;
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
watch.stop();
finishPreview({
previewPageUrl: 'https://preview.test',
apiUrl: 'https://server.test',
closeAsync: async () => {},
});
await expect(running).rejects.toBe(failed);
expect(uploadRemoteSessionConfigAsync).not.toHaveBeenCalled();
expect(stopAsync).toHaveBeenCalledTimes(1);
});

it('retires the startup watcher before lifetime waiting', async () => {
await runAsync(BuildRuntimePlatform.LINUX);
const watch = jest.mocked(watchDeviceRunSessionStartup).mock.results[0].value;
expect(watch.completeAsync.mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.invocationCallOrder[0]
);
});

it('reports the session token when serve-sim minted one', async () => {
jest.mocked(startDeviceSessionHostAsync).mockResolvedValue({
openPreviewAsync: jest.fn().mockResolvedValue({
Expand Down Expand Up @@ -205,7 +283,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
env: childEnv,
logger,
timeoutMs: 60_000,
signal: undefined,
signal: expect.any(AbortSignal),
...(selectsXcode
? {
iosSimulatorUdid: 'SELECTED-IOS-UDID',
Expand Down Expand Up @@ -237,6 +315,7 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
previewApiUrl: 'https://web-preview.example.test',
},
logger,
signal: expect.any(AbortSignal),
});
expect(waitForDeviceRunSessionStoppedAsync).toHaveBeenCalledWith({
ctx,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
} from '@expo/steps';

import { CustomBuildContext } from '../../customBuildContext';
import { startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { type DeviceSessionHost, startDeviceSessionHostAsync } from '../utils/deviceSessionHost';
import { resolveIosSessionStartupAsync } from '../utils/iosSimulatorSession';
import {
createNetworkCaptureInputProviders,
Expand All @@ -33,6 +33,8 @@ import {
selectXcodeDeveloperDirectoryAsync,
waitForDeviceRunSessionStoppedAsync,
} from '../utils/remoteDeviceRunSession';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import { createStartupTasks } from '../utils/startupTasks';

const STARTUP_TIMEOUT_MS = 60_000;

Expand Down Expand Up @@ -71,55 +73,66 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
const maxDurationSeconds = inputs.max_duration_seconds?.value as number | undefined;
const packageVersion = inputs.package_version?.value as string | undefined;
const { runtimePlatform } = global;
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
const startup = createStartupTasks(logger, signal);
const startupWatch = watchDeviceRunSessionStartup({
ctx,
deviceRunSessionId,
logger,
signal,
startup,
});
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);
let sessionHost: DeviceSessionHost | undefined;
try {
startup.signal.throwIfAborted();
if (runtimePlatform === BuildRuntimePlatform.DARWIN) {
await selectXcodeDeveloperDirectoryAsync({ env, logger });
}
const { launch, iosStartup } = await resolveIosSessionStartupAsync({
runtimePlatform,
bootSimulator: inputs.boot_simulator?.value as boolean | undefined,
deviceIdentifier: inputs.device_identifier?.value as string | undefined,
installAppPath: inputs.install_app_path?.value as string | undefined,
launchAppIdentifier: inputs.launch_app_identifier?.value,
launchArgs: inputs.launch_args?.value,
openUrl: inputs.open_url?.value,
env,
logger,
signal: startup.signal,
});
startup.signal.throwIfAborted();
const { networkCapture, networkCaptureFields } = parseNetworkCaptureInputs(
{
networkCapture: inputs.network_capture?.value,
networkCaptureFields: inputs.network_capture_fields?.value,
},
{ runtimePlatform }
);

logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}
logger.info(`Starting web preview remote session (runtime: ${runtimePlatform}).`);
const launchDescription = describeServeSimLaunch(launch);
if (launchDescription) {
logger.info(launchDescription);
}

const sessionHost = await startDeviceSessionHostAsync(ctx, {
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
signal,
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
sessionHost = await startDeviceSessionHostAsync(ctx, {
...iosStartup,
runtimePlatform,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
signal: startup.signal,
packageVersion,
disableShare: inputs.public_preview?.value === false,
privatePreviewUrl: inputs.private_preview_url?.value as string | undefined,
frameAncestors: parseFrameAncestorsInput(inputs.frame_ancestors?.value),
corsOrigins: parseCorsOriginsInput(inputs.cors_origins?.value),
launchAppIdentifier: launch.launchAppIdentifier,
launchArgs: launch.launchArgs,
openUrl: launch.openUrl,
networkCapture,
networkCaptureFields,
});
startup.signal.throwIfAborted();

try {
const webPreview = await sessionHost.openPreviewAsync({
baseDomain: ngrokTunnelDomain,
authtoken: ngrokAuthtoken,
Expand All @@ -131,7 +144,7 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
);
await uploadRemoteSessionConfigWithLocalEgressAsync({
env,
signal,
signal: startup.signal,
ctx,
deviceRunSessionId,
// WEB_PREVIEW_ONLY sessions read webPreviewUrl and webPreviewToken. Legacy SERVE_SIM
Expand All @@ -148,15 +161,23 @@ export function createStartWebPreviewRemoteSessionBuildFunction(
logger,
});

await startupWatch.completeAsync();
await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId,
logger,
maxDurationSeconds,
signal,
});
} catch (error) {
if (startupWatch.isStopped()) {
return;
}
throw startup.signal.aborted ? startup.signal.reason : error;
} finally {
await sessionHost.finishAsync();
startup.abort(new Error('Web preview session ended.'));
await startupWatch.finishAsync();
await sessionHost?.finishAsync();
}
}),
});
Expand Down
Loading