Compare commits

..
4 Commits
Author SHA1 Message Date
Gabe Debes e34c0cb3f6 [eas-cli] Mark default devices in simulator:devices (#4895) 2026-11-13 23:52:38 +00:00
Szymon Dziedzic c7f766568c [build-tools] Count Android web preview input toward the session idle timeout (#4890)
* [build-tools] Count Android web preview input toward the session idle timeout

* [build-tools] Count an expo-device-hub read timeout as unobservable, not as a down device
2026-11-14 00:32:58 +01:00
Szymon Dziedzic a328e59ee4 [build-tools] Do not fail a successful session when its tool tunnel cannot stop (#4893)
* [build-tools] Do not fail a successful session when its tool tunnel cannot stop

* Shorten the changeset to one sentence

* Name the options for tool tunnels and test a startup Stop
2026-11-14 00:27:04 +01:00
Expo CI f843a97165 bump STAGING and PRODUCTION to 27.10.0 in cli-versions.json 2026-11-13 23:05:29 +00:00
26 changed files with 1157 additions and 437 deletions

No files matched your search

@@ -0,0 +1,5 @@
---
'@expo/build-tools': minor
---
Count Android web preview input as session activity, so `max_idle_time_minutes` works on Android sessions. `eas/start_serve_sim_remote_session` no longer rejects it on Android; the API does not accept an idle timeout for Android web-preview-only sessions yet.
+5
View File
@@ -0,0 +1,5 @@
---
'eas-cli': patch
---
Mark the default device for each platform in `eas simulator:devices` and include `isDefault` in JSON output.
@@ -0,0 +1,5 @@
---
'@expo/build-tools': patch
---
A successful remote device run session no longer fails when its agent-device, Appium, or Argent tunnel does not stop within its time-out.
+2 -2
View File
@@ -1,4 +1,4 @@
{
"STAGING": "27.9.0",
"PRODUCTION": "27.9.0"
"STAGING": "27.10.0",
"PRODUCTION": "27.10.0"
}
@@ -201,6 +201,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
});
it('does not fail a session when only the agent-device tunnel cannot stop', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
mockTunnelStopAsync.mockRejectedValueOnce(tunnelError);
await expect(runAsync(logger, BuildRuntimePlatform.LINUX)).resolves.toBeUndefined();
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockDaemonStopAsync).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: tunnelError },
'Could not stop the agent-device tunnel during remote session teardown.'
);
});
it('uses the warm daemon after adoption and starts no daemon of its own', async () => {
const logger = { info: jest.fn(), warn: jest.fn(), child: jest.fn().mockReturnThis() };
const warmServer = { port: 3200 } as never;
@@ -918,6 +933,21 @@ describe('runAgentDeviceRemoteSessionAsync orchestration', () => {
await expect(session).rejects.toBe(failed);
});
it('reports a startup Stop when only the agent-device tunnel cannot stop', 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();
mockTunnelStopAsync.mockRejectedValueOnce(new Error('tunnel stop timed out'));
tasks.abort(stopped);
await expect(session).rejects.toBe(stopped);
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
});
it('retires startup monitoring after reporting and before lifetime waiting', async () => {
const retired = deferred();
const retiring = jest.fn(() => retired.promise);
@@ -850,6 +850,26 @@ describe('createStartAppiumRemoteSessionBuildFunction session lifecycle', () =>
);
}
it('does not fail a session when only the Appium tunnel cannot stop', async () => {
const tunnelError = new Error('Ngrok tunnel stop timed out after 4000ms.');
const logger = { info: jest.fn(), warn: jest.fn() };
stopAppium.mockResolvedValueOnce(undefined);
jest.mocked(startNgrokTunnelAsync).mockResolvedValueOnce({
url: 'https://appium-abc.tunnel.example.com',
subdomainId: 'appium-abc',
stopAsync: jest.fn().mockRejectedValue(tunnelError),
});
await expect(runSessionAsync(logger)).resolves.toBeUndefined();
expect(stopAppium).toHaveBeenCalledTimes(1);
expect(finishHost).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: tunnelError },
'Could not stop the Appium tunnel during remote session teardown.'
);
});
it('does not allocate Appium or a host after parent cancellation during Xcode selection', async () => {
const controller = new AbortController();
const failure = new Error('parent step cancelled');
@@ -667,7 +667,7 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
['succeeded', false],
['failed', true],
])(
'fails after stopping the host and tools when the tools tunnel cannot close and the session %s',
'stops the host and tools when the tools tunnel cannot close and the session %s',
async (_description, sessionFails) => {
const closeError = new Error('ngrok close failed');
const sessionError = new Error('session failed');
@@ -677,23 +677,26 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
}
const logger = { info: jest.fn(), warn: jest.fn() };
const buildFunction = createStartArgentRemoteSessionBuildFunction({} as CustomBuildContext);
// The tunnel may still be serving, so its failure fails a session that otherwise succeeded.
await expect(
buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
)
).rejects.toBe(sessionFails ? sessionError : closeError);
// A stopped tunnel's proxy drops every connection, so its failure does not fail a session.
const running = buildFunction.fn!(
{
logger,
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: {},
} as never
);
if (sessionFails) {
await expect(running).rejects.toBe(sessionError);
} else {
await expect(running).resolves.toBeUndefined();
}
expect(mockTunnelStopAsync).toHaveBeenCalledTimes(1);
expect(mockPreviewStopAsync).toHaveBeenCalledTimes(1);
expect(mockStopAsync).toHaveBeenCalledTimes(1);
@@ -702,17 +705,13 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
{ err: closeError },
'Could not stop the Argent tunnel during remote session teardown.'
);
expect(jest.mocked(Sentry.capture).mock.calls).toEqual(
sessionFails
? [
[
'Could not stop the Argent tunnel after the remote session failed',
closeError,
{ level: 'warning' },
],
]
: []
);
expect(jest.mocked(Sentry.capture).mock.calls).toEqual([
[
`Could not stop the Argent tunnel after the remote session ${sessionFails ? 'failed' : 'ended'}`,
closeError,
{ level: 'warning' },
],
]);
}
);
@@ -246,17 +246,20 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('uses preview activity to reset the configured idle timeout', async () => {
await runAsync(BuildRuntimePlatform.DARWIN, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'uses preview activity to reset the configured idle timeout on %s',
async runtimePlatform => {
await runAsync(runtimePlatform, { max_idle_time_minutes: { value: 5 } });
const { idleTimeout } = jest.mocked(waitForDeviceRunSessionStoppedAsync).mock.calls[0][0];
expect(idleTimeout?.maxIdleTimeMinutes).toBe(5);
expect(idleTimeout?.getLastEventObservedAt()).toBeUndefined();
const host = await jest.mocked(startDeviceSessionHostAsync).mock.results[0].value;
const observedAt = new Date();
jest.mocked(host.getLastPreviewActivityAt).mockReturnValue(observedAt);
expect(idleTimeout?.getLastPreviewActivityAt?.()).toBe(observedAt);
expect(stopAsync).toHaveBeenCalledTimes(1);
}
);
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'leaves the idle timer disabled when omitted on %s',
@@ -277,16 +280,6 @@ describe(createStartWebPreviewRemoteSessionBuildFunction, () => {
);
});
it('rejects an Android idle timeout before starting the host', async () => {
await expect(
runAsync(BuildRuntimePlatform.LINUX, {
max_idle_time_minutes: { value: 5 },
})
).rejects.toThrow('Omit max_idle_time_minutes for Android sessions.');
expect(startDeviceSessionHostAsync).not.toHaveBeenCalled();
expect(waitForDeviceRunSessionStoppedAsync).not.toHaveBeenCalled();
});
it.each([BuildRuntimePlatform.DARWIN, BuildRuntimePlatform.LINUX])(
'handles Stop while the %s host starts',
async runtimePlatform => {
@@ -26,6 +26,7 @@ import { type StartupTasks } from '../utils/startupTasks';
import {
type DetachedProcessHandle,
DeviceRunSessionStoppedError,
TOOL_TUNNEL_TEARDOWN_OPTIONS,
finishRemoteSessionAsync,
getDeviceRunSessionIdOrThrow,
getNgrokAuthtokenOrThrow,
@@ -352,7 +353,7 @@ export async function runAgentDeviceRemoteSessionAsync(
? [['startup cleanup', Promise.reject(result.reason)]]
: []
),
['agent-device tunnel', agentDeviceTunnel?.stopAsync()],
['agent-device tunnel', agentDeviceTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['agent-device daemon', toolTeardown],
[
'session host',
@@ -52,6 +52,7 @@ import { preparePreinstalledWdaAsync } from '../utils/preinstalledWda';
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
TOOL_TUNNEL_TEARDOWN_OPTIONS,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
@@ -416,7 +417,7 @@ export function createStartAppiumRemoteSessionBuildFunction(
logger,
sessionFailed,
teardown: [
['Appium tunnel', appiumTunnel?.stopAsync()],
['Appium tunnel', appiumTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Appium server', appiumTeardown],
[
'session host',
@@ -44,6 +44,7 @@ import { ARGENT_EVENT_LOG_FILENAME, startArgentEventCollectionAsync } from '../u
import { watchDeviceRunSessionStartup } from '../utils/deviceRunSessionStartup';
import {
DeviceRunSessionStoppedError,
TOOL_TUNNEL_TEARDOWN_OPTIONS,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
@@ -482,7 +483,7 @@ export function createStartArgentRemoteSessionBuildFunction(
logger,
sessionFailed,
teardown: [
['Argent tunnel', toolsTunnel?.stopAsync()],
['Argent tunnel', toolsTunnel?.stopAsync(), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Argent tool-server', toolTeardown],
[
'session host',
@@ -1,4 +1,3 @@
import { UserError } from '@expo/eas-build-job';
import {
BuildFunction,
BuildRuntimePlatform,
@@ -87,16 +86,6 @@ 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 (
maxIdleTimeMinutes !== undefined &&
maxIdleTimeMinutes > 0 &&
runtimePlatform !== BuildRuntimePlatform.DARWIN
) {
throw new UserError(
'UNSUPPORTED_WEB_PREVIEW_IDLE_TIMEOUT',
'Web preview idle timeouts require iOS preview activity reporting. Omit max_idle_time_minutes for Android sessions.'
);
}
const startupStop = new AbortController();
const watchSignal = signal
? AbortSignal.any([signal, startupStop.signal])
@@ -45,7 +45,7 @@ import {
runServeSimActionWithResultAsync,
stageServeSimAppAsync,
} from '../serveSimActions';
import { startServeSimActivityPolling } from '../serveSimActivity';
import { startPreviewActivityPolling } from '../previewActivity';
import { readServeSimServersAsync } from '../serveSimMetricsRecorder';
import * as previewCapture from '../serveSimPreviewCapture';
@@ -59,7 +59,7 @@ jest.mock('../serveSimActions');
jest.mock('../iosAppArtifact', () => ({
readIosApplicationIdentifierAsync: jest.fn(),
}));
jest.mock('../serveSimActivity');
jest.mock('../previewActivity');
jest.mock('../localEgress', () => ({
...jest.requireActual('../localEgress'),
readLocalEgressHandoffAsync: jest.fn().mockResolvedValue(null),
@@ -644,7 +644,7 @@ it('starts the readiness budget after refusals that exceed the normal startup de
it('reports preview activity from serve-sim on iOS and stops polling when finished', async () => {
const observedAt = new Date('2026-10-19T12:00:00Z');
const stopActivity = jest.fn().mockResolvedValue(undefined);
jest.mocked(startServeSimActivityPolling).mockReturnValue({
jest.mocked(startPreviewActivityPolling).mockReturnValue({
getLastActivityObservedAt: () => observedAt,
stopAsync: stopActivity,
});
@@ -661,7 +661,8 @@ it('reports preview activity from serve-sim on iOS and stops polling when finish
]
);
expect(startServeSimActivityPolling).toHaveBeenCalledWith({
expect(startPreviewActivityPolling).toHaveBeenCalledWith({
server: 'serve-sim',
port,
token: 'preview-token',
logger: expect.anything(),
@@ -688,15 +689,33 @@ it('does not start polling preview activity when startup is cancelled', async ()
signal: controller.signal,
})
).rejects.toThrow();
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
expect(startPreviewActivityPolling).not.toHaveBeenCalled();
});
it('reports no preview activity on Android, where expo-device-hub keeps no event log', async () => {
it('reports preview activity from expo-device-hub on Android and stops polling when finished', async () => {
const observedAt = new Date('2026-11-13T12:00:00Z');
const stopActivity = jest.fn().mockResolvedValue(undefined);
jest.mocked(startPreviewActivityPolling).mockReturnValue({
getLastActivityObservedAt: () => observedAt,
stopAsync: stopActivity,
});
const host = await startHostAsync();
const port = Number(
jest.mocked(spawnDetached).mock.calls[0][0].args[
jest.mocked(spawnDetached).mock.calls[0][0].args.indexOf('--port') + 1
]
);
expect(startPreviewActivityPolling).toHaveBeenCalledWith({
server: 'expo-device-hub',
port,
token: expect.any(String),
logger: expect.anything(),
});
expect(host.getLastPreviewActivityAt()).toBe(observedAt);
expect(startServeSimActivityPolling).not.toHaveBeenCalled();
expect(host.getLastPreviewActivityAt()).toBeUndefined();
await host.finishAsync();
expect(stopActivity).toHaveBeenCalledTimes(1);
});
it('stops a host that became ready on a different Simulator', async () => {
@@ -0,0 +1,527 @@
import { type bunyan } from '@expo/logger';
import { once } from 'node:events';
import http from 'node:http';
import {
readExpoDeviceHubInputLogAsync,
readServeSimInputLogAsync,
startPreviewActivityPolling,
} from '../previewActivity';
const token = 'test-session-token';
let server: http.Server;
let port: number;
let events: { id: number; at?: string; timestamp?: string }[];
let status: number;
let requests: { url?: string; authorization?: string }[];
let rawBody: string | undefined;
let replaying: boolean;
let replayStartedAt: string | null;
function createLoggerMock(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
beforeEach(async () => {
events = [];
status = 200;
requests = [];
rawBody = undefined;
replaying = false;
replayStartedAt = null;
server = http.createServer((req, res) => {
requests.push({ url: req.url, authorization: req.headers.authorization });
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1), replaying, replayStartedAt }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
port = (server.address() as { port: number }).port;
});
afterEach(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
});
describe(readServeSimInputLogAsync, () => {
it('reads the newest event ID with the session token', async () => {
events = [{ id: 3 }, { id: 7 }];
await expect(readServeSimInputLogAsync({ port, token })).resolves.toEqual({
latestEventId: 7,
latestEventAt: undefined,
replaying: false,
replayStartedAt: undefined,
});
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
});
it('returns undefined when serve-sim has logged no event', async () => {
await expect(readServeSimInputLogAsync({ port, token })).resolves.toEqual({
latestEventId: undefined,
latestEventAt: undefined,
replaying: false,
replayStartedAt: undefined,
});
});
it.each([
['unparseable JSON', 'not json'],
['a body without an events array', '{}'],
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
])('rejects %s', async (_, body) => {
rawBody = body;
await expect(readServeSimInputLogAsync({ port, token })).rejects.toThrow('serve-sim event log');
});
it('rejects a refused request', async () => {
status = 401;
await expect(readServeSimInputLogAsync({ port, token })).rejects.toThrow('401');
});
});
describe(readExpoDeviceHubInputLogAsync, () => {
it('reads the newest entry time from either server', async () => {
events = [{ id: 2, timestamp: '2026-11-13T12:00:02.000Z' }];
await expect(readServeSimInputLogAsync({ port, token })).resolves.toMatchObject({
latestEventId: 2,
latestEventAt: '2026-11-13T12:00:02.000Z',
});
events = [{ id: 3, at: '2026-11-13T12:00:03.000Z' }];
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toMatchObject({
latestEventId: 3,
latestEventAt: '2026-11-13T12:00:03.000Z',
});
});
it('reads the newest recorded Android input through the hub with the session token', async () => {
events = [{ id: 3 }, { id: 9 }];
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
latestEventId: 9,
latestEventAt: undefined,
replaying: false,
replayStartedAt: undefined,
});
expect(requests).toEqual([
{ url: '/vendor/serve-emu/api/session?limit=1', authorization: `Bearer ${token}` },
]);
});
it('reports a running replay and when it started', async () => {
replaying = true;
replayStartedAt = '2026-11-13T12:00:00.000Z';
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
latestEventId: undefined,
latestEventAt: undefined,
replaying: true,
replayStartedAt: '2026-11-13T12:00:00.000Z',
});
});
it('reads replay state from the paged response shape', async () => {
rawBody = JSON.stringify({
events: [{ id: 4, at: '2026-11-13T12:00:01.000Z' }],
session: { replaying: true, replayStartedAt: '2026-11-13T12:00:00.000Z' },
});
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
latestEventId: 4,
latestEventAt: '2026-11-13T12:00:01.000Z',
replaying: true,
replayStartedAt: '2026-11-13T12:00:00.000Z',
});
});
it('returns no event ID when the hub has recorded no input', async () => {
await expect(readExpoDeviceHubInputLogAsync({ port, token })).resolves.toEqual({
latestEventId: undefined,
latestEventAt: undefined,
replaying: false,
replayStartedAt: undefined,
});
});
it.each([
['unparseable JSON', 'not json'],
['a body without an events array', '{}'],
['an entry without a numeric ID', '{"events":[{"id":"9"}]}'],
])('rejects %s', async (_, body) => {
rawBody = body;
await expect(readExpoDeviceHubInputLogAsync({ port, token })).rejects.toThrow(
'expo-device-hub input log'
);
});
});
describe(startPreviewActivityPolling, () => {
async function waitForRequestsAsync(count: number): Promise<void> {
while (requests.length < count) {
await new Promise(resolve => setTimeout(resolve, 5));
}
}
it('reports activity only when a new event appears', async () => {
const polling = startPreviewActivityPolling({
server: 'serve-sim',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
events = [{ id: 1 }];
const before = Date.now();
await waitForRequestsAsync(requests.length + 2);
const firstSeen = polling.getLastActivityObservedAt();
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
events = [{ id: 1 }, { id: 2 }];
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
} finally {
await polling.stopAsync();
}
});
it('reports current activity while the event log fails, and warns once', async () => {
status = 500;
const logger = createLoggerMock();
const polling = startPreviewActivityPolling({
server: 'serve-sim',
port,
token,
logger,
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(3);
expect(logger.warn).toHaveBeenCalledTimes(1);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
status = 200;
events = [{ id: 4 }];
await waitForRequestsAsync(requests.length + 2);
const observedAt = polling.getLastActivityObservedAt();
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
} finally {
await polling.stopAsync();
}
});
it('keeps the last observation while serve-sim refuses connections', async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
server = http.createServer();
const polling = startPreviewActivityPolling({
server: 'serve-sim',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await new Promise(resolve => setTimeout(resolve, 50));
expect(polling.getLastActivityObservedAt()).toBeUndefined();
} finally {
await polling.stopAsync();
}
});
it('reports current activity when serve-sim drops the connection mid-response', async () => {
server.removeAllListeners('request');
server.on('request', (req, res) => {
requests.push({ url: req.url });
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
res.write('{"events":');
setImmediate(() => res.socket?.destroy());
});
const polling = startPreviewActivityPolling({
server: 'serve-sim',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(3);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
} finally {
await polling.stopAsync();
}
});
it('reports current activity while serve-sim answers without an event log', async () => {
status = 404;
const polling = startPreviewActivityPolling({
server: 'serve-sim',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
status = 200;
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
} finally {
await polling.stopAsync();
}
});
it('reports Android preview input from expo-device-hub', async () => {
const polling = startPreviewActivityPolling({
server: 'expo-device-hub',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
expect(requests[0].url).toBe('/vendor/serve-emu/api/session?limit=1');
events = [{ id: 1 }];
const before = Date.now();
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
} finally {
await polling.stopAsync();
}
});
it('reports activity while expo-device-hub replays recorded input', async () => {
replaying = true;
const polling = startPreviewActivityPolling({
server: 'expo-device-hub',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const before = Date.now();
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
} finally {
await polling.stopAsync();
}
});
it('reports a replay that started and finished between two reads', async () => {
events = [{ id: 1 }];
const polling = startPreviewActivityPolling({
server: 'expo-device-hub',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const observedAt = polling.getLastActivityObservedAt();
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
replayStartedAt = '2026-11-13T12:00:00.000Z';
await waitForRequestsAsync(requests.length + 2);
const replayObservedAt = polling.getLastActivityObservedAt();
expect(replayObservedAt).not.toBe(observedAt);
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(replayObservedAt);
} finally {
await polling.stopAsync();
}
});
it('counts input in a new recording that repeats the last ID between two reads', async () => {
events = [{ id: 1, at: '2026-11-13T12:00:00.000Z' }];
const polling = startPreviewActivityPolling({
server: 'expo-device-hub',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const firstSeen = polling.getLastActivityObservedAt();
expect(firstSeen).toBeDefined();
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
events = [{ id: 1, at: '2026-11-13T12:05:00.000Z' }];
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
} finally {
await polling.stopAsync();
}
});
it('counts input in a new recording whose IDs start again', async () => {
events = [{ id: 1 }];
const polling = startPreviewActivityPolling({
server: 'expo-device-hub',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const firstSeen = polling.getLastActivityObservedAt();
expect(firstSeen).toBeDefined();
events = [];
await waitForRequestsAsync(requests.length + 2);
events = [{ id: 1 }];
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
} finally {
await polling.stopAsync();
}
});
it.each([
['before its headers', 'headers'],
['after its headers', 'body'],
] as const)(
'reports current activity while an expo-device-hub read times out %s',
async (_, stall) => {
events = [{ id: 1 }];
let stalled = false;
server.removeAllListeners('request');
server.on('request', (req, res) => {
requests.push({ url: req.url });
if (stalled && stall === 'headers') {
return;
}
res.writeHead(200, { 'Content-Type': 'application/json' });
if (stalled) {
res.write('{"events":[');
return;
}
res.end(JSON.stringify({ events, replaying: false, replayStartedAt: null }));
});
const logger = createLoggerMock();
const polling = startPreviewActivityPolling({
server: 'expo-device-hub',
port,
token,
logger,
pollIntervalMs: 10,
requestTimeoutMs: 100,
});
try {
while (polling.getLastActivityObservedAt() === undefined) {
await new Promise(resolve => setTimeout(resolve, 5));
}
const observedAt = polling.getLastActivityObservedAt();
stalled = true;
// The next request starts only after the stalled read times out.
await waitForRequestsAsync(requests.length + 2);
const reportedAt = polling.getLastActivityObservedAt();
expect(reportedAt).not.toBe(observedAt);
expect(jest.mocked(logger.warn).mock.calls[0][1]).toContain(
'idle timeout does not stop this session'
);
} finally {
await polling.stopAsync();
server.closeAllConnections();
}
}
);
it('warns about an unreadable log after an earlier device outage', async () => {
const logger = createLoggerMock();
status = 503;
const polling = startPreviewActivityPolling({
server: 'expo-device-hub',
port,
token,
logger,
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
expect(logger.warn).toHaveBeenCalledTimes(1);
status = 500;
await waitForRequestsAsync(requests.length + 2);
expect(logger.warn).toHaveBeenCalledTimes(2);
expect(jest.mocked(logger.warn).mock.calls[1][1]).toContain(
'idle timeout does not stop this session'
);
} finally {
await polling.stopAsync();
}
});
it('keeps the last observation while expo-device-hub has no device', async () => {
events = [{ id: 1 }];
const polling = startPreviewActivityPolling({
server: 'expo-device-hub',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const observedAt = polling.getLastActivityObservedAt();
expect(observedAt).toBeDefined();
status = 503;
await waitForRequestsAsync(requests.length + 3);
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
} finally {
await polling.stopAsync();
}
});
it('reports current activity when serve-sim answers 503', async () => {
status = 503;
const polling = startPreviewActivityPolling({
server: 'serve-sim',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
} finally {
await polling.stopAsync();
}
});
it('stops polling after stopAsync', async () => {
const polling = startPreviewActivityPolling({
server: 'serve-sim',
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
await waitForRequestsAsync(1);
await polling.stopAsync();
const count = requests.length;
await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.length).toBe(count);
});
});
@@ -29,6 +29,7 @@ import { sleepAsync } from '../../../utils/retry';
import { uploadDeviceRunSessionScreenRecordingsAsync } from '../deviceRunSessionScreenRecordings';
import { DEVICE_RUN_SESSION_TOKEN_HEADER } from '../deviceRunSessionWorkerApi';
import {
TOOL_TUNNEL_TEARDOWN_OPTIONS,
createCorsOriginsInputProvider,
createFrameAncestorsInputProvider,
createIosSessionStartupInputProviders,
@@ -38,6 +39,7 @@ import {
describeServeSimLaunch,
ensureFfmpegInstalledOnceAsync,
fetchWebPreviewTurnArgsAsync,
finishRemoteSessionAsync,
parseCorsOriginsInput,
parseFrameAncestorsInput,
parseServeSimLaunchInputs,
@@ -226,6 +228,89 @@ function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
describe(finishRemoteSessionAsync, () => {
const tunnelError = new Error('tunnel stop timed out');
const serverError = new Error('server stop failed');
beforeEach(() => {
jest.mocked(Sentry).capture.mockReset();
});
it('does not fail a successful session when only a tunnel cannot stop', async () => {
const logger = { warn: jest.fn() } as unknown as bunyan;
await expect(
finishRemoteSessionAsync({
logger,
sessionFailed: false,
teardown: [
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Appium server', Promise.resolve()],
],
})
).resolves.toBeUndefined();
expect(logger.warn).toHaveBeenCalledWith(
{ err: tunnelError },
'Could not stop the Appium tunnel during remote session teardown.'
);
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
[
'Could not stop the Appium tunnel after the remote session ended',
tunnelError,
{ level: 'warning' },
],
]);
});
it('fails a successful session with the first failure that fails the session', async () => {
const logger = { warn: jest.fn() } as unknown as bunyan;
await expect(
finishRemoteSessionAsync({
logger,
sessionFailed: false,
teardown: [
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Appium server', Promise.reject(serverError)],
],
})
).rejects.toBe(serverError);
expect(logger.warn).toHaveBeenCalledTimes(2);
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
[
'Could not stop the Appium tunnel after the remote session ended',
tunnelError,
{ level: 'warning' },
],
]);
});
it('reports every teardown failure to Sentry when the session failed', async () => {
const logger = { warn: jest.fn() } as unknown as bunyan;
await expect(
finishRemoteSessionAsync({
logger,
sessionFailed: true,
teardown: [
['Appium tunnel', Promise.reject(tunnelError), TOOL_TUNNEL_TEARDOWN_OPTIONS],
['Appium server', Promise.reject(serverError)],
['session host', undefined],
],
})
).resolves.toBeUndefined();
expect(jest.mocked(Sentry).capture.mock.calls).toEqual([
[
'Could not stop the Appium tunnel after the remote session failed',
tunnelError,
{ level: 'warning' },
],
[
'Could not stop the Appium server after the remote session failed',
serverError,
{ level: 'warning' },
],
]);
});
});
describe(createIosSessionStartupInputProviders, () => {
it('declares optional inputs so existing workflows do not opt into owned startup', () => {
const inputs = createIosSessionStartupInputProviders().map(provider =>
@@ -1,196 +0,0 @@
import { type bunyan } from '@expo/logger';
import { once } from 'node:events';
import http from 'node:http';
import { fetchLatestServeSimEventIdAsync, startServeSimActivityPolling } from '../serveSimActivity';
const token = 'test-session-token';
let server: http.Server;
let port: number;
let events: { id: number }[];
let status: number;
let requests: { url?: string; authorization?: string }[];
let rawBody: string | undefined;
function createLoggerMock(): bunyan {
return { info: jest.fn(), warn: jest.fn() } as unknown as bunyan;
}
beforeEach(async () => {
events = [];
status = 200;
requests = [];
rawBody = undefined;
server = http.createServer((req, res) => {
requests.push({ url: req.url, authorization: req.headers.authorization });
res.writeHead(status, { 'Content-Type': 'application/json' });
res.end(rawBody ?? JSON.stringify({ events: events.slice(-1) }));
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
port = (server.address() as { port: number }).port;
});
afterEach(async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
});
describe(fetchLatestServeSimEventIdAsync, () => {
it('reads the newest event ID with the session token', async () => {
events = [{ id: 3 }, { id: 7 }];
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBe(7);
expect(requests).toEqual([{ url: '/api/event-log?limit=1', authorization: `Bearer ${token}` }]);
});
it('returns undefined when serve-sim has logged no event', async () => {
await expect(fetchLatestServeSimEventIdAsync({ port, token })).resolves.toBeUndefined();
});
it.each([
['unparseable JSON', 'not json'],
['a body without an events array', '{}'],
['an entry without a numeric ID', '{"events":[{"id":"7"}]}'],
])('rejects %s', async (_, body) => {
rawBody = body;
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow(
'serve-sim event log'
);
});
it('rejects a refused request', async () => {
status = 401;
await expect(fetchLatestServeSimEventIdAsync({ port, token })).rejects.toThrow('401');
});
});
describe(startServeSimActivityPolling, () => {
async function waitForRequestsAsync(count: number): Promise<void> {
while (requests.length < count) {
await new Promise(resolve => setTimeout(resolve, 5));
}
}
it('reports activity only when a new event appears', async () => {
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
events = [{ id: 1 }];
const before = Date.now();
await waitForRequestsAsync(requests.length + 2);
const firstSeen = polling.getLastActivityObservedAt();
expect(firstSeen?.getTime()).toBeGreaterThanOrEqual(before);
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(firstSeen);
events = [{ id: 1 }, { id: 2 }];
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).not.toBe(firstSeen);
} finally {
await polling.stopAsync();
}
});
it('reports current activity while the event log fails, and warns once', async () => {
status = 500;
const logger = createLoggerMock();
const polling = startServeSimActivityPolling({ port, token, logger, pollIntervalMs: 10 });
try {
await waitForRequestsAsync(3);
expect(logger.warn).toHaveBeenCalledTimes(1);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
status = 200;
events = [{ id: 4 }];
await waitForRequestsAsync(requests.length + 2);
const observedAt = polling.getLastActivityObservedAt();
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBe(observedAt);
} finally {
await polling.stopAsync();
}
});
it('keeps the last observation while serve-sim refuses connections', async () => {
await new Promise<void>(resolve => server.close(() => resolve()));
server = http.createServer();
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await new Promise(resolve => setTimeout(resolve, 50));
expect(polling.getLastActivityObservedAt()).toBeUndefined();
} finally {
await polling.stopAsync();
}
});
it('reports current activity when serve-sim drops the connection mid-response', async () => {
server.removeAllListeners('request');
server.on('request', (req, res) => {
requests.push({ url: req.url });
res.writeHead(200, { 'Content-Type': 'application/json', 'Content-Length': '100' });
res.write('{"events":');
setImmediate(() => res.socket?.destroy());
});
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(3);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
} finally {
await polling.stopAsync();
}
});
it('reports current activity while serve-sim answers without an event log', async () => {
status = 404;
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
try {
await waitForRequestsAsync(2);
const before = Date.now();
expect(polling.getLastActivityObservedAt()?.getTime()).toBeGreaterThanOrEqual(before);
status = 200;
await waitForRequestsAsync(requests.length + 2);
expect(polling.getLastActivityObservedAt()).toBeUndefined();
} finally {
await polling.stopAsync();
}
});
it('stops polling after stopAsync', async () => {
const polling = startServeSimActivityPolling({
port,
token,
logger: createLoggerMock(),
pollIntervalMs: 10,
});
await waitForRequestsAsync(1);
await polling.stopAsync();
const count = requests.length;
await new Promise(resolve => setTimeout(resolve, 50));
expect(requests.length).toBe(count);
});
});
@@ -56,7 +56,7 @@ import {
readIosApplicationIdentifierAsync,
} from './iosAppArtifact';
import { createServeSimPreviewCapture } from './serveSimPreviewCapture';
import { type ServeSimActivityPolling, startServeSimActivityPolling } from './serveSimActivity';
import { type PreviewActivityPolling, startPreviewActivityPolling } from './previewActivity';
import { verifyLocalEgressGuardAsync } from './localEgressGuard';
import { type IosSimulatorUuid } from '../../utils/IosSimulatorUtils';
import { createColdIosSessionAsync } from './coldIosSession';
@@ -376,7 +376,7 @@ export type DeviceWebPreview = {
export type DeviceSessionHost = {
/** The preview tunnel opened during startup, or a new one after closeAsync. */
openPreviewAsync(): Promise<DeviceWebPreview>;
/** Last observed preview input. Always `undefined` on Android, which keeps no event log. */
/** Last observed preview input from the serve-sim (iOS) or expo-device-hub (Android) input log. */
getLastPreviewActivityAt(): Date | undefined;
/**
* Terminal and idempotent. Finalization, host-stop and upload failures are logged and reported.
@@ -634,7 +634,7 @@ async function startDeviceSessionHostInternalAsync(
const previewSetupController = new AbortController();
let previewSetupTask: Promise<void> | undefined;
let previewFailed = false;
let previewActivity: ServeSimActivityPolling | null = null;
let previewActivity: PreviewActivityPolling | null = null;
function openPreviewAsync(): Promise<DeviceWebPreview> {
if (finishTask) {
@@ -1025,9 +1025,14 @@ async function startDeviceSessionHostInternalAsync(
secrets.push(previewToken);
if (!isAndroid) {
IosSimulatorRecordingUtils.useServeSimPackage(packageSpec);
if (!finishTask) {
previewActivity = startServeSimActivityPolling({ port, token: previewToken, logger });
}
}
if (!finishTask) {
previewActivity = startPreviewActivityPolling({
server: isAndroid ? 'expo-device-hub' : 'serve-sim',
port,
token: previewToken,
logger,
});
}
// Android installed FFmpeg before launching the host. The optional thumbnail must not delay
// readiness on macOS, so it starts after its encoder is installed there.
@@ -25,6 +25,10 @@ const RETRYABLE_NGROK_ERROR_CODES = new Set(['ERR_NGROK_802']);
export type NgrokTunnelHandle = {
url: string;
subdomainId: string;
/**
* Stops the tunnel. The local proxy drops every connection at once; then the listeners close.
* Rejects when the stop does not complete within the stop time-out.
*/
stopAsync: () => Promise<void>;
};
@@ -61,6 +65,8 @@ export async function startNgrokTunnelAsync({
const controller = new AbortController();
const { signal } = controller;
const sockets = new Set<Socket>();
// After an abort, this proxy drops every connection, even while an ngrok listener is still open.
// Session teardown relies on this (TOOL_TUNNEL_TEARDOWN_OPTIONS), so keep it when changing this.
const target = createServer({ allowHalfOpen: true }, client => {
if (signal.aborted) {
client.destroy();
@@ -0,0 +1,261 @@
import { type bunyan } from '@expo/logger';
const POLL_INTERVAL_MS = 5_000;
const REQUEST_TIMEOUT_MS = 5_000;
// expo-device-hub serves serve-emu, which records Android preview input, under this prefix.
const EXPO_DEVICE_HUB_SERVE_EMU_PREFIX = '/vendor/serve-emu';
/** The preview server whose input log counts as session activity. */
export type PreviewActivityServer = 'serve-sim' | 'expo-device-hub';
export type PreviewActivityPolling = {
/**
* When a new input log entry (input or host action, not stream traffic) was first seen. Reports
* now while a read fails for a reason other than a refused connection or a device-unavailable
* answer, so an unobservable session stays active.
*/
getLastActivityObservedAt: () => Date | undefined;
stopAsync: () => Promise<void>;
};
/** What one read of the server's input log shows. */
export type PreviewInputLogState = {
/** The newest entry's ID, or undefined when the log is empty. */
latestEventId: number | undefined;
/**
* When the server logged the newest entry. A new recording starts its IDs again at 1, so the ID
* alone can repeat for new input.
*/
latestEventAt: string | undefined;
/** The server is replaying recorded input, which it does not log again. */
replaying: boolean;
/**
* When the latest replay started, kept after it ends, so a replay that runs entirely between two
* reads still shows up as a change.
*/
replayStartedAt: string | undefined;
};
type ReadInputLogAsync = (options: {
port: number;
token: string;
signal?: AbortSignal;
}) => Promise<PreviewInputLogState>;
const READ_INPUT_LOG: Record<PreviewActivityServer, ReadInputLogAsync> = {
'serve-sim': readServeSimInputLogAsync,
'expo-device-hub': readExpoDeviceHubInputLogAsync,
};
/**
* The server is up but has no device to take input, such as an offline emulator. Like a down
* server, it receives no input, so the last observation stands.
*/
class PreviewDeviceUnavailableError extends Error {}
/** Nothing listens on the port. A reset or dropped response can come from a running server. */
function isServerDownError(err: unknown): boolean {
if (err instanceof PreviewDeviceUnavailableError) {
return true;
}
// Not `instanceof`: undici errors can come from another realm.
let error: unknown = err;
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
const { code, cause } = error as { code?: unknown; cause?: unknown };
if (code === 'ECONNREFUSED') {
return true;
}
error = cause;
}
return false;
}
/**
* A changed newest event ID, a new replay, or a running replay counts as activity, timed by the
* local clock.
*/
export function startPreviewActivityPolling({
server,
port,
token,
logger,
pollIntervalMs = POLL_INTERVAL_MS,
requestTimeoutMs = REQUEST_TIMEOUT_MS,
}: {
server: PreviewActivityServer;
port: number;
token: string;
logger: bunyan;
pollIntervalMs?: number;
requestTimeoutMs?: number;
}): PreviewActivityPolling {
const readInputLogAsync = READ_INPUT_LOG[server];
const controller = new AbortController();
let lastEventKey: string | undefined;
let lastReplayStartedAt: string | undefined;
let lastActivityObservedAt: Date | undefined;
let eventLogUnavailable = false;
let didWarnUnavailable = false;
let didWarnDown = false;
let timer: NodeJS.Timeout | undefined;
let pending: Promise<void> = Promise.resolve();
const pollAsync = async (): Promise<void> => {
try {
const { latestEventId, latestEventAt, replaying, replayStartedAt } = await readInputLogAsync({
port,
token,
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(requestTimeoutMs)]),
});
eventLogUnavailable = false;
const eventKey =
latestEventId === undefined ? undefined : `${latestEventId}@${latestEventAt ?? ''}`;
if (eventKey !== undefined && eventKey !== lastEventKey) {
lastActivityObservedAt = new Date();
}
lastEventKey = eventKey;
if (replayStartedAt !== undefined && replayStartedAt !== lastReplayStartedAt) {
lastReplayStartedAt = replayStartedAt;
lastActivityObservedAt = new Date();
}
if (replaying) {
lastActivityObservedAt = new Date();
}
} catch (err) {
if (controller.signal.aborted) {
return;
}
// Only a down server or device, which receives no input either, keeps the last observation.
// A timeout proves neither: expo-device-hub holds the read while it changes the capture
// source, and the open preview keeps taking input meanwhile.
eventLogUnavailable = !isServerDownError(err);
if (eventLogUnavailable && !didWarnUnavailable) {
didWarnUnavailable = true;
logger.warn(
{ err },
`Could not read the ${server} input log. Preview input cannot be observed, so the ` +
'idle timeout does not stop this session while the log cannot be read.'
);
} else if (!eventLogUnavailable && !didWarnDown) {
didWarnDown = true;
logger.warn({ err }, `Could not read ${server} preview activity.`);
}
}
};
const tick = (): void => {
pending = pollAsync().finally(() => {
if (!controller.signal.aborted) {
timer = setTimeout(tick, pollIntervalMs);
timer.unref();
}
});
};
tick();
return {
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
stopAsync: async () => {
controller.abort();
clearTimeout(timer);
await pending;
},
};
}
export async function readServeSimInputLogAsync({
port,
token,
signal,
}: {
port: number;
token: string;
signal?: AbortSignal;
}): Promise<PreviewInputLogState> {
return await readInputLogAsync({
url: `http://127.0.0.1:${port}/api/event-log?limit=1`,
token,
signal,
logName: 'serve-sim event log',
});
}
/**
* Reads serve-emu's session recording through expo-device-hub. It records browser preview and REST
* input: touches, keys, text, navigation buttons, and location. It does not record input sent with
* `record: false` (the preview's Reload button) or actions such as rotation and screenshots, so
* those alone do not keep a session active. Controller commands count through the session's own
* event stream. The hub scopes the request to its single device.
*/
export async function readExpoDeviceHubInputLogAsync({
port,
token,
signal,
}: {
port: number;
token: string;
signal?: AbortSignal;
}): Promise<PreviewInputLogState> {
return await readInputLogAsync({
// The hub returns the whole recording and ignores `limit`; serve-emu's paged route honors it.
url: `http://127.0.0.1:${port}${EXPO_DEVICE_HUB_SERVE_EMU_PREFIX}/api/session?limit=1`,
token,
signal,
logName: 'expo-device-hub input log',
// The hub answers 503 while it cannot reach the emulator.
deviceUnavailableStatus: 503,
});
}
async function readInputLogAsync({
url,
token,
signal,
logName,
deviceUnavailableStatus,
}: {
url: string;
token: string;
signal?: AbortSignal;
logName: string;
deviceUnavailableStatus?: number;
}): Promise<PreviewInputLogState> {
const response = await fetch(url, {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!response.ok) {
await response.body?.cancel();
const message = `${logName} request failed: ${response.status}`;
throw response.status === deviceUnavailableStatus
? new PreviewDeviceUnavailableError(message)
: new Error(message);
}
const text = await response.text();
type ReplayState = { replaying?: unknown; replayStartedAt?: unknown };
// The hub route returns the recorder snapshot; serve-emu's paged route nests it under `session`.
let body: ({ events?: unknown; session?: ReplayState } & ReplayState) | undefined;
try {
body = JSON.parse(text);
} catch {}
if (!Array.isArray(body?.events)) {
throw new Error(`${logName} response has no events array.`);
}
const replayState: ReplayState = body.session ?? body;
const replaying = replayState.replaying === true;
const replayStartedAt =
typeof replayState.replayStartedAt === 'string' ? replayState.replayStartedAt : undefined;
if (body.events.length === 0) {
return { latestEventId: undefined, latestEventAt: undefined, replaying, replayStartedAt };
}
const latest = body.events.at(-1) as { id?: unknown; at?: unknown; timestamp?: unknown } | null;
if (typeof latest?.id !== 'number') {
throw new Error(`${logName} entry has no numeric ID.`);
}
// serve-emu names the time `at`; serve-sim names it `timestamp`.
const at = latest.at ?? latest.timestamp;
return {
latestEventId: latest.id,
latestEventAt: typeof at === 'string' ? at : undefined,
replaying,
replayStartedAt,
};
}
@@ -649,34 +649,66 @@ export async function stopDetachedProcessAsync(
}
/**
* Runs every named teardown to completion and logs each failure. The first failure is rethrown
* only when the session body succeeded, so a teardown error cannot replace the error that ended it.
* Teardown options for the stop of a tool tunnel (agent-device, Appium, or Argent) at the end of a
* session. The stop first makes the tunnel's local proxy drop every connection (see
* `startNgrokTunnelAsync`), so a listener that does not close in time cannot reach the session. The
* worker process exits after the job, and this closes the process's ngrok sessions. The warm-host
* preview tunnel does not use these options: its retirement still fails the session.
*/
export const TOOL_TUNNEL_TEARDOWN_OPTIONS = { failsSession: false } as const;
/** A named task that `finishRemoteSessionAsync` waits for when a remote session ends. */
export type RemoteSessionTeardown = [
name: string,
task: Promise<unknown> | undefined,
options?: {
/**
* False when a failure of this task cannot affect the user or a later session, so it is
* logged and reported to Sentry but does not fail a session that succeeded. Defaults to true.
*/
failsSession?: boolean;
},
];
/**
* Runs every named teardown to completion and logs each failure. The first failure of a task that
* fails the session is rethrown only when the session body succeeded, so a teardown error cannot
* replace the error that ended it.
*/
export async function finishRemoteSessionAsync({
teardown,
sessionFailed,
logger,
}: {
teardown: [name: string, task: Promise<unknown> | undefined][];
teardown: RemoteSessionTeardown[];
sessionFailed: boolean;
logger: bunyan;
}): Promise<void> {
const results = await Promise.allSettled(teardown.map(([, task]) => task));
const failures = results.flatMap((result, index) =>
result.status === 'rejected' ? [{ name: teardown[index][0], err: result.reason }] : []
);
for (const { name, err } of failures) {
const failures = results.flatMap((result, index) => {
if (result.status !== 'rejected') {
return [];
}
const [name, , options] = teardown[index];
return [{ name, err: result.reason, failsSession: options?.failsSession ?? true }];
});
for (const { name, err, failsSession } of failures) {
logger.warn({ err }, `Could not stop the ${name} during remote session teardown.`);
if (sessionFailed) {
// The session error is what the step reports, so a swallowed teardown failure goes to Sentry.
if (sessionFailed || !failsSession) {
// The step does not report this failure, so it goes to Sentry.
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture(`Could not stop the ${name} after the remote session failed`, error, {
level: 'warning',
});
Sentry.capture(
sessionFailed
? `Could not stop the ${name} after the remote session failed`
: `Could not stop the ${name} after the remote session ended`,
error,
{ level: 'warning' }
);
}
}
if (!sessionFailed && failures.length > 0) {
throw failures[0].err;
const sessionFailure = failures.find(({ failsSession }) => failsSession);
if (!sessionFailed && sessionFailure) {
throw sessionFailure.err;
}
}
@@ -1,131 +0,0 @@
import { type bunyan } from '@expo/logger';
const POLL_INTERVAL_MS = 5_000;
const REQUEST_TIMEOUT_MS = 5_000;
export type ServeSimActivityPolling = {
/**
* When a new event log entry (input or host action, not stream traffic) was first seen. Reports
* now while the log is unreadable but serve-sim is up, so an unobservable session stays active.
*/
getLastActivityObservedAt: () => Date | undefined;
stopAsync: () => Promise<void>;
};
/** Nothing listens on the port. A reset or dropped response can come from a running serve-sim. */
function isServeSimDownError(err: unknown): boolean {
// Not `instanceof`: undici errors can come from another realm.
let error: unknown = err;
for (let depth = 0; depth < 5 && typeof error === 'object' && error !== null; depth++) {
const { code, cause } = error as { code?: unknown; cause?: unknown };
if (code === 'ECONNREFUSED') {
return true;
}
error = cause;
}
return false;
}
/** A changed newest event ID counts as activity, timed by the local clock. */
export function startServeSimActivityPolling({
port,
token,
logger,
pollIntervalMs = POLL_INTERVAL_MS,
}: {
port: number;
token: string;
logger: bunyan;
pollIntervalMs?: number;
}): ServeSimActivityPolling {
const controller = new AbortController();
let lastEventId: number | undefined;
let lastActivityObservedAt: Date | undefined;
let eventLogUnavailable = false;
let didWarn = false;
let timer: NodeJS.Timeout | undefined;
let pending: Promise<void> = Promise.resolve();
const pollAsync = async (): Promise<void> => {
try {
const eventId = await fetchLatestServeSimEventIdAsync({
port,
token,
signal: AbortSignal.any([controller.signal, AbortSignal.timeout(REQUEST_TIMEOUT_MS)]),
});
eventLogUnavailable = false;
if (eventId !== undefined && eventId !== lastEventId) {
lastEventId = eventId;
lastActivityObservedAt = new Date();
}
} catch (err) {
if (controller.signal.aborted) {
return;
}
// Only a down serve-sim, which receives no input either, keeps the last observation.
eventLogUnavailable = !isServeSimDownError(err);
if (!didWarn) {
didWarn = true;
logger.warn(
{ err },
eventLogUnavailable
? 'Could not read the serve-sim event log. Preview input cannot be observed, so the ' +
'idle timeout does not stop this session.'
: 'Could not read serve-sim preview activity.'
);
}
}
};
const tick = (): void => {
pending = pollAsync().finally(() => {
if (!controller.signal.aborted) {
timer = setTimeout(tick, pollIntervalMs);
timer.unref();
}
});
};
tick();
return {
getLastActivityObservedAt: () => (eventLogUnavailable ? new Date() : lastActivityObservedAt),
stopAsync: async () => {
controller.abort();
clearTimeout(timer);
await pending;
},
};
}
export async function fetchLatestServeSimEventIdAsync({
port,
token,
signal,
}: {
port: number;
token: string;
signal?: AbortSignal;
}): Promise<number | undefined> {
const response = await fetch(`http://127.0.0.1:${port}/api/event-log?limit=1`, {
headers: { Authorization: `Bearer ${token}` },
signal,
});
if (!response.ok) {
await response.body?.cancel();
throw new Error(`serve-sim event log request failed: ${response.status}`);
}
const text = await response.text();
let body: { events?: unknown } | undefined;
try {
body = JSON.parse(text);
} catch {}
if (!Array.isArray(body?.events)) {
throw new Error('serve-sim event log response has no events array.');
}
if (body.events.length === 0) {
return undefined;
}
const id = (body.events.at(-1) as { id?: unknown } | null)?.id;
if (typeof id !== 'number') {
throw new Error('serve-sim event log entry has no numeric ID.');
}
return id;
}
+32
View File
@@ -9145,6 +9145,22 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isDefault",
"description": "Whether a session starts this device when it requests no android.deviceIdentifier.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -63742,6 +63758,22 @@
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "isDefault",
"description": "Whether a session starts this device when it requests no ios.deviceIdentifier.",
"args": [],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
@@ -40,11 +40,13 @@ const profiles: DeviceRunSessionProfilesQuery['deviceRunSessions']['profiles'] =
__typename: 'IosDeviceRunSessionDevice',
identifier: 'iPhone 17',
displayName: 'iPhone 17',
isDefault: true,
},
{
__typename: 'IosDeviceRunSessionDevice',
identifier: 'iPhone 17 Pro',
displayName: 'iPhone 17 Pro',
isDefault: false,
},
],
},
@@ -55,23 +57,25 @@ const profiles: DeviceRunSessionProfilesQuery['deviceRunSessions']['profiles'] =
__typename: 'AndroidDeviceRunSessionDevice',
identifier: 'medium_phone',
displayName: 'Medium Phone',
isDefault: true,
},
{
__typename: 'AndroidDeviceRunSessionDevice',
identifier: 'pixel_9',
displayName: 'Pixel 9',
isDefault: false,
},
],
},
};
const iosDevices = [
{ identifier: 'iPhone 17', displayName: 'iPhone 17' },
{ identifier: 'iPhone 17 Pro', displayName: 'iPhone 17 Pro' },
{ identifier: 'iPhone 17', displayName: 'iPhone 17', isDefault: true },
{ identifier: 'iPhone 17 Pro', displayName: 'iPhone 17 Pro', isDefault: false },
];
const androidDevices = [
{ identifier: 'medium_phone', displayName: 'Medium Phone' },
{ identifier: 'pixel_9', displayName: 'Pixel 9' },
{ identifier: 'medium_phone', displayName: 'Medium Phone', isDefault: true },
{ identifier: 'pixel_9', displayName: 'Pixel 9', isDefault: false },
];
function getMockOclifConfig(): Config {
@@ -111,11 +115,11 @@ describe(SimulatorDevices, () => {
expect(mockProfilesAsync).toHaveBeenCalledWith(graphqlClient);
expect(mockLog).toHaveBeenCalledWith(
[
'Platform Device --device value',
'iOS iPhone 17 "iPhone 17"',
'iOS iPhone 17 Pro "iPhone 17 Pro"',
'Android Medium Phone medium_phone',
'Android Pixel 9 pixel_9',
'Platform Device --device value',
'iOS iPhone 17 (default) "iPhone 17"',
'iOS iPhone 17 Pro "iPhone 17 Pro"',
'Android Medium Phone (default) medium_phone',
'Android Pixel 9 pixel_9',
].join('\n')
);
expect(mockLog).toHaveBeenCalledWith(
@@ -129,9 +133,9 @@ describe(SimulatorDevices, () => {
expect(mockLog).toHaveBeenCalledWith(
[
'Platform Device --device value',
'Android Medium Phone medium_phone',
'Android Pixel 9 pixel_9',
'Platform Device --device value',
'Android Medium Phone (default) medium_phone',
'Android Pixel 9 pixel_9',
].join('\n')
);
expect(mockLog).toHaveBeenCalledWith(
@@ -139,6 +143,26 @@ describe(SimulatorDevices, () => {
);
});
it('uses the default flag from the API instead of the device name or list order', async () => {
mockProfilesAsync.mockResolvedValue({
...profiles,
ios: {
...profiles.ios,
devices: profiles.ios.devices.map(device => ({
...device,
isDefault: device.identifier === 'iPhone 17 Pro',
})),
},
});
await createCommand(['--platform', 'ios']).runAsync();
const table = mockLog.mock.calls[0][0];
expect(table).toContain('iPhone 17 Pro (default)');
expect(table).not.toContain('iPhone 17 (default)');
expect(table).toContain('"iPhone 17 Pro"');
});
it('says so when the service lists no devices', async () => {
mockProfilesAsync.mockResolvedValue({
...profiles,
@@ -78,9 +78,10 @@ export default class SimulatorDevices extends EasCommand {
Object.fromEntries(
platforms.map(platform => [
platform,
profiles[platform].devices.map(({ identifier, displayName }) => ({
profiles[platform].devices.map(({ identifier, displayName, isDefault }) => ({
identifier,
displayName,
isDefault,
})),
])
)
@@ -89,9 +90,9 @@ export default class SimulatorDevices extends EasCommand {
}
const devices = platforms.flatMap(platform =>
profiles[platform].devices.map(({ identifier, displayName }) => ({
profiles[platform].devices.map(({ identifier, displayName, isDefault }) => ({
platform,
displayName,
displayName: isDefault ? `${displayName} (default)` : displayName,
flagValue: formatDeviceFlagValue(identifier),
}))
);
+5 -1
View File
@@ -1503,6 +1503,8 @@ export type AndroidDeviceRunSessionDevice = {
displayName: Scalars['String']['output'];
/** AVD hardware profile id accepted by android.deviceIdentifier (e.g. "pixel_9"). */
identifier: Scalars['String']['output'];
/** Whether a session starts this device when it requests no android.deviceIdentifier. */
isDefault: Scalars['Boolean']['output'];
};
export type AndroidDeviceRunSessionProfiles = {
@@ -9142,6 +9144,8 @@ export type IosDeviceRunSessionDevice = {
displayName: Scalars['String']['output'];
/** Simulator device name accepted by ios.deviceIdentifier (e.g. "iPhone 17 Pro"). */
identifier: Scalars['String']['output'];
/** Whether a session starts this device when it requests no ios.deviceIdentifier. */
isDefault: Scalars['Boolean']['output'];
};
export type IosDeviceRunSessionProfiles = {
@@ -16435,7 +16439,7 @@ export type DeviceRunSessionByIdQuery = { __typename?: 'RootQuery', deviceRunSes
export type DeviceRunSessionProfilesQueryVariables = Exact<{ [key: string]: never; }>;
export type DeviceRunSessionProfilesQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', profiles: { __typename?: 'DeviceRunSessionProfiles', ios: { __typename?: 'IosDeviceRunSessionProfiles', devices: Array<{ __typename?: 'IosDeviceRunSessionDevice', identifier: string, displayName: string }> }, android: { __typename?: 'AndroidDeviceRunSessionProfiles', devices: Array<{ __typename?: 'AndroidDeviceRunSessionDevice', identifier: string, displayName: string }> } } } };
export type DeviceRunSessionProfilesQuery = { __typename?: 'RootQuery', deviceRunSessions: { __typename?: 'DeviceRunSessionQuery', profiles: { __typename?: 'DeviceRunSessionProfiles', ios: { __typename?: 'IosDeviceRunSessionProfiles', devices: Array<{ __typename?: 'IosDeviceRunSessionDevice', identifier: string, displayName: string, isDefault: boolean }> }, android: { __typename?: 'AndroidDeviceRunSessionProfiles', devices: Array<{ __typename?: 'AndroidDeviceRunSessionDevice', identifier: string, displayName: string, isDefault: boolean }> } } } };
export type DeviceRunSessionsByAppIdQueryVariables = Exact<{
appId: Scalars['String']['input'];
@@ -216,12 +216,14 @@ export const DeviceRunSessionQuery = {
devices {
identifier
displayName
isDefault
}
}
android {
devices {
identifier
displayName
isDefault
}
}
}