Compare commits

...
6 changed files with 149 additions and 51 deletions

No files matched your search

@@ -125,6 +125,7 @@ export function createStartArgentRemoteSessionBuildFunction(
deviceRunSessionId,
toolsUrl: `http://127.0.0.1:${toolServerPort}`,
toolsAuthToken: toolServerToken,
logger,
});
const publicToolsUrl = await startNgrokTunnelAsync({
@@ -30,19 +30,18 @@ export function createStartServeSimRemoteSessionBuildFunction(
logger.info(`Selecting Xcode developer directory: ${XCODE_DEVELOPER_DIR}.`);
await spawn('sudo', ['xcode-select', '-s', XCODE_DEVELOPER_DIR], { env, logger });
const { previewUrl, streamUrl } = await startServeSimWithTunnelAsync(ctx, {
const { previewUrl } = await startServeSimWithTunnelAsync(ctx, {
baseDomain: ngrokTunnelDomain,
env,
logger,
timeoutMs: STARTUP_TIMEOUT_MS,
});
logger.info(`Preview URL: ${previewUrl}`);
logger.info(`Stream URL: ${streamUrl}`);
await uploadRemoteSessionConfigAsync({
ctx,
deviceRunSessionId,
remoteConfig: { previewUrl, streamUrl },
remoteConfig: { previewUrl },
logger,
});
@@ -1,5 +1,6 @@
import { bunyan } from '@expo/logger';
import fetch from 'node-fetch';
import { Readable } from 'node:stream';
import { CustomBuildContext } from '../../../customBuildContext';
import { uploadDeviceRunSessionArtifactAsync } from '../deviceRunSessionArtifacts';
@@ -10,6 +11,12 @@ jest.mock('node-fetch');
const { Response } = jest.requireActual('node-fetch') as typeof import('node-fetch');
async function readStreamAsync(stream: NodeJS.ReadableStream): Promise<void> {
for await (const chunk of stream as Readable) {
void chunk;
}
}
function createLoggerMock(): bunyan {
return {
info: jest.fn(),
@@ -70,16 +77,21 @@ describe(uploadArgentArtifactAsync, () => {
it('downloads an Argent artifact and uploads it as a device run session artifact', async () => {
const data = Buffer.from('artifact-data');
const reportedSize = 1024;
const ctx = {
logger: createLoggerMock(),
} as unknown as CustomBuildContext;
const logger = createLoggerMock();
const ctx = {} as unknown as CustomBuildContext;
jest.mocked(fetch).mockResolvedValueOnce(new Response(data));
jest.mocked(fetch).mockResolvedValueOnce(new Response(Readable.from([data])));
jest
.mocked(uploadDeviceRunSessionArtifactAsync)
.mockImplementationOnce(async (_ctx, { stream }) => {
await readStreamAsync(stream);
});
await uploadArgentArtifactAsync(ctx, {
deviceRunSessionId: 'drs-id',
toolsUrl: 'http://127.0.0.1:1234',
toolsAuthToken: 'tools-token',
logger,
artifact: {
id: 'artifact-id',
filename: 'report.json',
@@ -96,7 +108,7 @@ describe(uploadArgentArtifactAsync, () => {
artifactId: 'artifact-id',
name: 'report.json (artifact-id)',
filename: 'report.json',
size: reportedSize,
size: data.length,
stream: expect.anything(),
});
});
@@ -5,6 +5,7 @@ import { CustomBuildContext } from '../../../customBuildContext';
import { Sentry } from '../../../sentry';
import { turtleFetch } from '../../../utils/turtleFetch';
import {
createServeSimTunnelArgs,
fetchServeSimTurnArgsAsync,
turnIceServersToServeSimArgs,
} from '../remoteDeviceRunSession';
@@ -36,6 +37,51 @@ function createEnvMock(): BuildStepEnv {
return { DEVICE_RUN_SESSION_ID: 'drs-id' } as unknown as BuildStepEnv;
}
describe(createServeSimTunnelArgs, () => {
it('builds ngrok/H264 serve-sim args and passes through TURN flags', () => {
expect(
createServeSimTunnelArgs({
baseDomain: 'expo-simulator.ngrok.dev',
turnArgs: [
'--stun-url',
'stun:stun.cloudflare.com:3478',
'--turn-url',
'turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'u',
'--turn-credential',
'c',
],
})
).toEqual([
'serve-sim-sjchmiela@latest',
'--tunnel',
'--tunnel-provider',
'ngrok',
'--tunnel-domain',
'expo-simulator.ngrok.dev',
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
'--stream-fps',
'10',
'--h264-bitrate',
'3000000',
'--h264-max-fps',
'30',
'--stun-url',
'stun:stun.cloudflare.com:3478',
'--turn-url',
'turns:turn.cloudflare.com:443?transport=tcp',
'--turn-username',
'u',
'--turn-credential',
'c',
]);
});
});
describe(turnIceServersToServeSimArgs, () => {
it('returns no args for an empty ICE server list', () => {
expect(turnIceServersToServeSimArgs([])).toEqual([]);
@@ -1,5 +1,11 @@
import { SystemError } from '@expo/eas-build-job';
import { type bunyan } from '@expo/logger';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdtemp, rm, stat } from 'node:fs/promises';
import fetch from 'node-fetch';
import os from 'node:os';
import path from 'node:path';
import { pipeline } from 'node:stream/promises';
import { z } from 'zod';
import { CustomBuildContext } from '../../customBuildContext';
@@ -29,13 +35,14 @@ export async function pollArgentArtifactsForUploadAsync(
deviceRunSessionId,
toolsUrl,
toolsAuthToken,
logger,
}: {
deviceRunSessionId: string;
toolsUrl: string;
toolsAuthToken?: string;
logger: bunyan;
}
): Promise<never> {
const { logger } = ctx;
logger.info('Started polling Argent tool-server for artifacts.');
const seenArtifactIds = new Set<string>();
let listArtifactsErrorCount = 0;
@@ -54,6 +61,7 @@ export async function pollArgentArtifactsForUploadAsync(
toolsUrl,
toolsAuthToken,
artifact,
logger,
}).catch(err => {
const error = err instanceof Error ? err : new Error(String(err));
Sentry.capture('Could not upload Argent remote session artifact', error);
@@ -104,39 +112,52 @@ export async function uploadArgentArtifactAsync(
toolsUrl,
toolsAuthToken,
artifact,
logger,
}: {
deviceRunSessionId: string;
toolsUrl: string;
toolsAuthToken?: string;
artifact: ArgentArtifact;
logger: bunyan;
}
): Promise<void> {
const filename = artifact.isDirectory ? `${artifact.filename}.tar.gz` : artifact.filename;
ctx.logger.info(`Uploading artifact ${filename} (${formatBytes(artifact.size)}).`);
const stream = await createArgentArtifactDownloadStreamAsync({
artifact,
toolsUrl,
toolsAuthToken,
});
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: artifact.id,
name: `${filename} (${artifact.id})`,
filename,
size: artifact.size,
stream,
});
logger.info(`Downloading artifact ${filename}.`);
const temporaryDirectory = await mkdtemp(path.join(os.tmpdir(), 'argent-artifact-'));
try {
const temporaryArtifactPath = path.join(temporaryDirectory, path.basename(filename));
await downloadArgentArtifactToFileAsync({
artifact,
toolsUrl,
toolsAuthToken,
destinationPath: temporaryArtifactPath,
});
const { size } = await stat(temporaryArtifactPath);
logger.info(`Uploading artifact ${filename} (${formatBytes(size)}).`);
await uploadDeviceRunSessionArtifactAsync(ctx, {
deviceRunSessionId,
artifactId: artifact.id,
name: `${filename} (${artifact.id})`,
filename,
size,
stream: createReadStream(temporaryArtifactPath),
});
} finally {
await rm(temporaryDirectory, { recursive: true, force: true });
}
}
async function createArgentArtifactDownloadStreamAsync({
async function downloadArgentArtifactToFileAsync({
artifact,
toolsUrl,
toolsAuthToken,
destinationPath,
}: {
artifact: ArgentArtifact;
toolsUrl: string;
toolsAuthToken?: string;
}): Promise<NodeJS.ReadableStream> {
destinationPath: string;
}): Promise<void> {
const response = await fetch(new URL(`/artifacts/${artifact.id}`, toolsUrl).toString(), {
headers: toolsAuthToken ? { Authorization: `Bearer ${toolsAuthToken}` } : {},
});
@@ -150,5 +171,5 @@ async function createArgentArtifactDownloadStreamAsync({
`Argent artifact ${artifact.id} response did not include a readable body.`
);
}
return response.body;
await pipeline(response.body, createWriteStream(destinationPath));
}
@@ -14,13 +14,17 @@ import { Sentry } from '../../sentry';
import { sleepAsync } from '../../utils/retry';
import { turtleFetch } from '../../utils/turtleFetch';
const SERVE_SIM_PACKAGE_SPEC = 'serve-sim-sjchmiela@latest';
const SERVE_SIM_MJPEG_FALLBACK_MAX_DIMENSION = '1280';
const SERVE_SIM_MJPEG_FALLBACK_QUALITY = '0.55';
const SERVE_SIM_MJPEG_FALLBACK_FPS = '10';
const SERVE_SIM_H264_BITRATE = '3000000';
const SERVE_SIM_H264_MAX_FPS = '30';
const START_DEVICE_RUN_SESSION_MUTATION = graphql(`
mutation StartDeviceRunSession($deviceRunSessionId: ID!, $remoteConfig: JSONObject!) {
deviceRunSession {
startDeviceRunSession(
deviceRunSessionId: $deviceRunSessionId
remoteConfig: $remoteConfig
) {
startDeviceRunSession(deviceRunSessionId: $deviceRunSessionId, remoteConfig: $remoteConfig) {
id
status
}
@@ -236,45 +240,60 @@ export async function startServeSimWithTunnelAsync(
logger: bunyan;
timeoutMs: number;
}
): Promise<{ previewUrl: string; streamUrl: string }> {
): Promise<{ previewUrl: string }> {
logger.info('Launching serve-sim with tunnel.');
const turnArgs = await fetchServeSimTurnArgsAsync(ctx, { env, logger });
const serveSim = spawnDetached({
command: 'npx',
args: [
'serve-sim-szdziedzic@latest',
'--tunnel',
'--tunnel-provider',
'ngrok',
'--tunnel-domain',
baseDomain,
'--stream-max-dimension',
'1280',
'--stream-quality',
'0.55',
'--codec',
'webrtc',
...turnArgs,
],
args: createServeSimTunnelArgs({ baseDomain, turnArgs }),
env,
});
logger.info('Waiting for serve-sim to report tunnel and stream URLs.');
logger.info('Waiting for serve-sim to report tunnel URL.');
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const output = serveSim.getOutput();
const previewUrl = matchLabeledUrl({ output, label: 'Tunnel', baseDomain });
const streamUrl = matchLabeledUrl({ output, label: 'Stream', baseDomain });
if (previewUrl && streamUrl) {
return { previewUrl, streamUrl };
if (previewUrl) {
return { previewUrl };
}
await sleepAsync(1_000);
}
throw new SystemError(
`Timed out waiting for serve-sim to report Tunnel and Stream URLs. Last output:\n${serveSim.getOutput() || '<empty>'}`
`Timed out waiting for serve-sim to report Tunnel URL. Last output:\n${serveSim.getOutput() || '<empty>'}`
);
}
export function createServeSimTunnelArgs({
baseDomain,
turnArgs = [],
}: {
baseDomain: string;
turnArgs?: string[];
}): string[] {
return [
SERVE_SIM_PACKAGE_SPEC,
'--tunnel',
'--tunnel-provider',
'ngrok',
'--tunnel-domain',
baseDomain,
// MJPEG remains the browser fallback when AVCC/H.264 is unavailable.
'--stream-max-dimension',
SERVE_SIM_MJPEG_FALLBACK_MAX_DIMENSION,
'--stream-quality',
SERVE_SIM_MJPEG_FALLBACK_QUALITY,
'--stream-fps',
SERVE_SIM_MJPEG_FALLBACK_FPS,
// The tunneled helper advertises /stream.avcc, so keep H.264 network usage bounded.
'--h264-bitrate',
SERVE_SIM_H264_BITRATE,
'--h264-max-fps',
SERVE_SIM_H264_MAX_FPS,
...turnArgs,
];
}
function matchLabeledUrl({
output,
label,