Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ This is the log of notable changes to EAS CLI and related packages.
- [eas-cli] Make `eas simulator` the canonical command and keep `eas simulator:start` as an alias. ([#4112](https://github.com/expo/eas-cli/pull/4112) by [@szdziedzic](https://github.com/szdziedzic))
- [eas-cli] Validate local composite functions referenced from workflow job hooks during `eas workflow:validate`. ([#4064](https://github.com/expo/eas-cli/pull/4064) by [@sswrk](https://github.com/sswrk))
- [eas-cli] Add `eas sim` as a shortcut for `eas simulator` commands, e.g. `eas sim:list` runs `eas simulator:list`. ([#4150](https://github.com/expo/eas-cli/pull/4150) by [@szdziedzic](https://github.com/szdziedzic))
- [build-tools] Support an optional `max_idle_time_minutes` input on `eas/start_argent_remote_session` and `eas/start_agent_device_remote_session`; the step stops the device run session after that many minutes without observed session events. ([#4157](https://github.com/expo/eas-cli/pull/4157) by [@szdziedzic](https://github.com/szdziedzic))

### 🐛 Bug fixes

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
jest.mocked(pollArgentArtifactsForUploadAsync).mockResolvedValue(undefined);
mockStopAsync.mockResolvedValue(undefined);
mockTunnelStopAsync.mockResolvedValue(undefined);
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({ stopAsync: mockStopAsync });
jest.mocked(startArgentEventCollectionAsync).mockResolvedValue({
stopAsync: mockStopAsync,
getLastEventObservedAt: () => undefined,
});

jest.mocked(getDeviceRunSessionIdOrThrow).mockReturnValue('device-run-session-id');
jest.mocked(getNgrokTunnelDomainOrThrow).mockReturnValue('tunnel.example.com');
Expand Down Expand Up @@ -108,7 +111,10 @@ describe('createStartArgentRemoteSessionBuildFunction orchestration', () => {
global: { runtimePlatform: BuildRuntimePlatform.LINUX },
} as unknown as BuildStepContext,
{
inputs: { package_version: { value: undefined } },
inputs: {
package_version: { value: undefined },
max_idle_time_minutes: { value: undefined },
},
outputs: {},
env: { EXISTING: 'value' },
} as never
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,11 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
Expand All @@ -66,6 +71,8 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);

const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
const { runtimePlatform } = global;
logger.info(
`Starting agent-device remote session (version: ${packageVersion ?? 'latest'}, runtime: ${runtimePlatform}).`
Expand Down Expand Up @@ -140,6 +147,13 @@ export function createStartAgentDeviceRemoteSessionBuildFunction(
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} finally {
if (serveSim) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,11 @@ export function createStartArgentRemoteSessionBuildFunction(
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.STRING,
}),
BuildStepInput.createProvider({
id: 'max_idle_time_minutes',
required: false,
allowedValueTypeName: BuildStepInputValueTypeName.NUMBER,
}),
],
fn: async ({ logger, global }, { inputs, env, signal }) => {
// Fail fast before any expensive setup if the injected env
Expand All @@ -79,6 +84,8 @@ export function createStartArgentRemoteSessionBuildFunction(
const ngrokAuthtoken = getNgrokAuthtokenOrThrow(env);

const packageVersion = inputs.package_version.value as string | undefined;
// A missing or non-positive value disables the idle timeout (opt-in feature).
const maxIdleTimeMinutes = inputs.max_idle_time_minutes.value as number | undefined;
warnIfArgentPackageVersionCannotBeVerified({ packageVersion, logger });
const versionSpec = packageVersion ?? 'latest';
const { runtimePlatform } = global;
Expand Down Expand Up @@ -215,6 +222,13 @@ export function createStartArgentRemoteSessionBuildFunction(
deviceRunSessionId,
logger,
signal,
idleTimeout:
maxIdleTimeMinutes !== undefined && maxIdleTimeMinutes > 0
? {
maxIdleTimeMinutes,
getLastEventObservedAt: eventCollection.getLastEventObservedAt,
}
: undefined,
});
} finally {
if (serveSim) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,37 @@ describe(startAgentDeviceEventCollectionAsync, () => {
);
});

it('tracks the local arrival time of the most recent event', async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-events-'));
const eventsDir = path.join(stateDir, 'sessions', 'default');
await fs.promises.mkdir(eventsDir, { recursive: true });
const collection = await startAgentDeviceEventCollectionAsync({
ctx: createContext(),
deviceRunSessionId: 'session-id',
stateDir,
logger: createLogger(),
pollIntervalMs: 10,
});

try {
expect(collection.getLastEventObservedAt()).toBeUndefined();

const before = new Date();
await fs.promises.writeFile(
path.join(eventsDir, 'events.ndjson'),
`${JSON.stringify(createAgentDeviceEvent({ requestId: 'request-1', command: 'tap' }))}\n`
);
await waitForAsync(() => expect(mockEventLogStream.write).toHaveBeenCalledTimes(1));

const observedAt = collection.getLastEventObservedAt();
expect(observedAt).toBeInstanceOf(Date);
expect(observedAt!.getTime()).toBeGreaterThanOrEqual(before.getTime());
} finally {
await collection.stopAsync();
await fs.promises.rm(stateDir, { recursive: true, force: true });
}
});

it('preserves unknown kinds and tolerates optional upstream field changes', async () => {
const stateDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'agent-device-events-'));
const eventsDir = path.join(stateDir, 'sessions', 'default');
Expand Down Expand Up @@ -547,6 +578,9 @@ describe(startAgentDeviceEventCollectionAsync, () => {
);
expect(mockEventLogStream.init).not.toHaveBeenCalled();
expect(HttpLogStream).not.toHaveBeenCalled();
// Without collection, activity is invisible: the collection reports fresh
// activity so an enabled idle timeout never stops the session.
expect(collection.getLastEventObservedAt()).toBeInstanceOf(Date);
});

it('continues persisting the artifact when real-time log setup fails', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,8 @@ function createStatusCtxMock(
| { status: 'NEW' | 'IN_PROGRESS' | 'STOPPED' | 'ERRORED' }
| { error: Error }
| { data: unknown }
)[]
)[],
{ ensureStoppedError }: { ensureStoppedError?: Error } = {}
): CustomBuildContext {
const query = jest.fn(() => {
const result = results.shift();
Expand Down Expand Up @@ -80,9 +81,25 @@ function createStatusCtxMock(
};
});

const mutation = jest.fn(() => ({
toPromise: async () => {
if (ensureStoppedError) {
return { error: ensureStoppedError };
}
return {
data: {
deviceRunSession: {
ensureDeviceRunSessionStopped: { id: 'drs-id', status: 'STOPPED' },
},
},
};
},
}));

return {
graphqlClient: {
query,
mutation,
},
} as unknown as CustomBuildContext;
}
Expand Down Expand Up @@ -423,6 +440,97 @@ describe(waitForDeviceRunSessionStoppedAsync, () => {
{ level: 'warning' }
);
});

describe('with an idle timeout', () => {
beforeEach(() => {
// Fake timers make Date.now() advance by exactly the poll interval per
// loop iteration, so idle time accumulates deterministically.
jest.useFakeTimers();
jest.mocked(setTimeoutAsync).mockImplementation(async delayMs => {
jest.advanceTimersByTime(delayMs ?? 0);
return undefined;
});
});

afterEach(() => {
jest.useRealTimers();
});

function manyInProgressStatuses(): { status: 'IN_PROGRESS' }[] {
return Array.from({ length: 20 }, () => ({ status: 'IN_PROGRESS' as const }));
}

it('stops the session when no activity is observed within the max idle time', async () => {
const ctx = createStatusCtxMock(manyInProgressStatuses());
const logger = createLoggerMock();

await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: {
maxIdleTimeMinutes: 1,
getLastEventObservedAt: () => undefined,
},
});

// One minute at the 5-second poll interval is 12 status polls.
expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(12);
expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(logger.info).toHaveBeenCalledWith(
'Device run session drs-id had no activity for 1 minute(s) (max idle time). Stopping the session.'
);
});

it('keeps the session alive while events keep arriving', async () => {
const ctx = createStatusCtxMock([...manyInProgressStatuses(), { status: 'STOPPED' }]);
const logger = createLoggerMock();

await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: {
maxIdleTimeMinutes: 1,
// Fresh activity on every check; 20 polls exceed one minute, so the
// session would have been stopped without these events.
getLastEventObservedAt: () => new Date(),
},
});

expect(ctx.graphqlClient.query).toHaveBeenCalledTimes(21);
expect(ctx.graphqlClient.mutation).not.toHaveBeenCalled();
expect(logger.info).toHaveBeenCalledWith('Device run session drs-id was stopped.');
});

it('still returns when the session cannot be marked stopped', async () => {
const ctx = createStatusCtxMock(manyInProgressStatuses(), {
ensureStoppedError: new Error('forbidden'),
});
const logger = createLoggerMock();

await waitForDeviceRunSessionStoppedAsync({
ctx,
deviceRunSessionId: 'drs-id',
logger,
idleTimeout: {
maxIdleTimeMinutes: 1,
getLastEventObservedAt: () => undefined,
},
});

expect(ctx.graphqlClient.mutation).toHaveBeenCalledTimes(1);
expect(logger.warn).toHaveBeenCalledWith(
{ err: expect.any(Error) },
'Could not mark device run session drs-id as stopped. The session job ends anyway.'
);
expect(jest.mocked(Sentry).capture).toHaveBeenCalledWith(
'Could not mark idle device run session as stopped',
expect.any(Error),
{ level: 'warning', extras: { deviceRunSessionId: 'drs-id' } }
);
});
});
});

describe(ensureFfmpegInstalledAsync, () => {
Expand Down
3 changes: 2 additions & 1 deletion packages/build-tools/src/steps/utils/agentDeviceEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import {
type DeviceRunSessionEvent,
type DeviceRunSessionEventCollection,
type DeviceRunSessionEventParseFailure,
startDeviceRunSessionEventCollectionAsync,
} from './deviceRunSessionEvents';
Expand Down Expand Up @@ -46,7 +47,7 @@ export async function startAgentDeviceEventCollectionAsync({
stateDir: string;
logger: bunyan;
pollIntervalMs?: number;
}): Promise<{ stopAsync: () => Promise<void> }> {
}): Promise<DeviceRunSessionEventCollection> {
return startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId,
Expand Down
3 changes: 2 additions & 1 deletion packages/build-tools/src/steps/utils/argentEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { z } from 'zod';
import { type CustomBuildContext } from '../../customBuildContext';
import {
type DeviceRunSessionEvent,
type DeviceRunSessionEventCollection,
type DeviceRunSessionEventParseFailure,
startDeviceRunSessionEventCollectionAsync,
} from './deviceRunSessionEvents';
Expand Down Expand Up @@ -75,7 +76,7 @@ export async function startArgentEventCollectionAsync({
eventLogPath: string;
logger: bunyan;
pollIntervalMs?: number;
}): Promise<{ stopAsync: () => Promise<void> }> {
}): Promise<DeviceRunSessionEventCollection> {
return startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId,
Expand Down
24 changes: 22 additions & 2 deletions packages/build-tools/src/steps/utils/deviceRunSessionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,17 @@ type EventFileState = {
decoder: StringDecoder;
};

export type DeviceRunSessionEventCollection = {
stopAsync: () => Promise<void>;
/**
* Local arrival time of the most recently collected event, or `undefined`
* when no event has been observed yet. Uses the collector's clock rather
* than the event's embedded timestamp so producer clock skew cannot affect
* idle detection.
*/
getLastEventObservedAt: () => Date | undefined;
};

export async function startDeviceRunSessionEventCollectionAsync({
ctx,
deviceRunSessionId,
Expand All @@ -106,7 +117,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
source: DeviceRunSessionEventSource;
logger: bunyan;
pollIntervalMs?: number;
}): Promise<{ stopAsync: () => Promise<void> }> {
}): Promise<DeviceRunSessionEventCollection> {
const { producer } = source;
let didReportEventLogFailure = false;
const reportEventLogFailure = (error: Error, operation: 'setup' | 'cleanup'): void => {
Expand Down Expand Up @@ -136,7 +147,13 @@ export async function startDeviceRunSessionEventCollectionAsync({
const error = err instanceof Error ? err : new Error(String(err));
logger.warn({ err: error }, 'Could not persist device run session events to the artifact.');
reportEventLogFailure(error, 'setup');
return { stopAsync: async () => {} };
return {
stopAsync: async () => {},
// Collection is disabled, so session activity is invisible here. Report
// fresh activity so an enabled idle timeout never stops a session it
// cannot observe.
getLastEventObservedAt: () => new Date(),
};
}

let didReportRealtimeLogFailure = false;
Expand Down Expand Up @@ -166,6 +183,7 @@ export async function startDeviceRunSessionEventCollectionAsync({

const states = new Map<string, EventFileState>();
const controller = new AbortController();
let lastEventObservedAt: Date | undefined;
let parseFailureCount = 0;
const parseFailureCounts: Record<DeviceRunSessionEventParseFailure, number> = {
'invalid-json': 0,
Expand All @@ -190,6 +208,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
source,
deviceRunSessionId,
writeEvent: event => {
lastEventObservedAt = new Date();
eventLogStream.write(event);
realtimeLogStream?.write({ ...event, logId: event.eventId });
},
Expand Down Expand Up @@ -259,6 +278,7 @@ export async function startDeviceRunSessionEventCollectionAsync({
});

return {
getLastEventObservedAt: () => lastEventObservedAt,
stopAsync: async () => {
controller.abort();
await pollingPromise;
Expand Down
Loading
Loading