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
21 changes: 21 additions & 0 deletions packages/provider-limrun/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,10 +57,14 @@ import {

type LimrunRuntimeSession = LimrunIosSession | LimrunAndroidSession;

const KEEP_ALIVE_INTERVAL_MS = 30_000;

export type LimrunRuntimeOptions = {
/** Organization API key. It creates and deletes instances for platforms without `instances`. */
apiKey?: string;
region?: string;
/** Pings each leased instance every 30 s so Limrun's inactivity timeout does not end idle sessions. */
keepAlive?: boolean;
runtimeInstance?: string;
instances?: LimrunInstanceAccess;
};
Expand Down Expand Up @@ -106,6 +110,7 @@ export function createLimrunRuntime(
class LimrunRuntimeImplementation implements ProviderDeviceRuntime {
private readonly limrun: Limrun | undefined;
private readonly sessions = new Map<string, LimrunRuntimeSession>();
private readonly keepAliveTimers = new Map<string, NodeJS.Timeout>();
private readonly appAliases = new Map<
string,
Readonly<{ assetName: string; installedAppId: string }>
Expand Down Expand Up @@ -258,6 +263,7 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime {
(await this.attachSession(platform, lease)) ??
(await this.createSession(platform, lease, context));
this.sessions.set(lease.leaseId, session);
if (this.options.keepAlive) this.startKeepAlive(lease.leaseId, session);
return { limrunInstanceId: session.instanceId, device: session.device };
}

Expand Down Expand Up @@ -373,7 +379,22 @@ class LimrunRuntimeImplementation implements ProviderDeviceRuntime {
return { limrunInstanceId: instanceIds[0], limrunInstanceCount: instanceIds.length };
}

private startKeepAlive(leaseId: string, session: LimrunRuntimeSession): void {
const timer = setInterval(() => {
try {
session.client.keepAlive();
} catch {
// A dead connection surfaces on the next real command.
}
}, KEEP_ALIVE_INTERVAL_MS);
timer.unref();
this.keepAliveTimers.set(leaseId, timer);
}

private async terminateSession(session: LimrunRuntimeSession): Promise<void> {
const leaseId = session.lease.leaseId;
clearInterval(this.keepAliveTimers.get(leaseId));
this.keepAliveTimers.delete(leaseId);
session.client.disconnect();
if (session.platform === 'android') await cleanupLimrunAndroidAdbTunnel(session);
if (isAttachedLimrunInstanceId(session.instanceId)) return;
Expand Down
48 changes: 48 additions & 0 deletions src/__tests__/limrun-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const limrunMockState = vi.hoisted(() => {
]),
androidOpenUrl: vi.fn(async () => undefined),
androidDisconnect: vi.fn(),
androidKeepAlive: vi.fn(),
androidSendAsset: vi.fn(async () => undefined),
androidTunnelClose,
androidStartAdbTunnel: vi.fn(async () => ({
Expand Down Expand Up @@ -110,6 +111,7 @@ vi.mock('@limrun/api/ios-client', () => ({
vi.mock('@limrun/api/instance-client', () => ({
createInstanceClient: vi.fn(async () => ({
disconnect: limrunMockState.androidDisconnect,
keepAlive: limrunMockState.androidKeepAlive,
openUrl: limrunMockState.androidOpenUrl,
sendAsset: limrunMockState.androidSendAsset,
startAdbTunnel: limrunMockState.androidStartAdbTunnel,
Expand Down Expand Up @@ -306,6 +308,52 @@ test('Limrun Android reverses localhost URL ports through the persistent ADB tun
}
});

test('Limrun keepAlive pings the session client every 30 s until release', async () => {
vi.useFakeTimers();
try {
const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', keepAlive: true });
const lease = androidLease();
await allocateLimrunDevice(runtime, lease);
await vi.advanceTimersByTimeAsync(60_000);
assert.equal(limrunMockState.androidKeepAlive.mock.calls.length, 2);

await runtime.leaseLifecycle.release?.(lease);
await vi.advanceTimersByTimeAsync(60_000);
assert.equal(limrunMockState.androidKeepAlive.mock.calls.length, 2);
} finally {
vi.useRealTimers();
}
});

test('Limrun keepAlive is off by default', async () => {
vi.useFakeTimers();
try {
const runtime = new LimrunRuntime({ apiKey: 'lim_test_key' });
await allocateLimrunDevice(runtime, androidLease());
await vi.advanceTimersByTimeAsync(60_000);
assert.equal(limrunMockState.androidKeepAlive.mock.calls.length, 0);
await runtime.shutdown();
} finally {
vi.useRealTimers();
}
});

test('Limrun keepAlive keeps pinging after the client throws', async () => {
vi.useFakeTimers();
try {
limrunMockState.androidKeepAlive.mockImplementationOnce(() => {
throw new Error('socket closed');
});
const runtime = new LimrunRuntime({ apiKey: 'lim_test_key', keepAlive: true });
await allocateLimrunDevice(runtime, androidLease());
await vi.advanceTimersByTimeAsync(60_000);
assert.equal(limrunMockState.androidKeepAlive.mock.calls.length, 2);
await runtime.shutdown();
} finally {
vi.useRealTimers();
}
});

function androidLease(): SimulatorLease {
return {
leaseId: 'lease-android',
Expand Down
1 change: 1 addition & 0 deletions src/commands/schema/cli-help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -642,6 +642,7 @@ Limrun direct-device flow:

Limrun creates remote iOS simulators and Android emulators only. Do not pass local device selectors such as --udid, --serial, or --device.
To drive an existing instance without the API key, set LIM_IOS_INSTANCE_URL and LIM_IOS_INSTANCE_TOKEN, or LIM_ANDROID_INSTANCE_URL, LIM_ANDROID_INSTANCE_TOKEN, and LIM_ANDROID_INSTANCE_ADB_URL, from the instance status before connect. agent-device then never creates or deletes that instance; install, and apps before the first open, still need LIMRUN_API_KEY.
Set LIMRUN_KEEP_ALIVE=1 to ping the instance every 30 seconds while a session is open, so an idle session does not hit the Limrun inactivity timeout. It is off by default.
agent-device apps
agent-device open Example.apk
agent-device snapshot -i
Expand Down
12 changes: 12 additions & 0 deletions src/provider-limrun-credentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ test('reads instance access with the lim CLI variable names', () => {
expect(readLimrunCredentials({ ...IOS_ENV, ...ANDROID_ENV, LIMRUN_REGION: 'eu' })).toEqual({
apiKey: undefined,
region: 'eu',
keepAlive: false,
instances: {
ios: { apiUrl: 'https://region.limrun.example/v1/ios_x/api', token: 'ios-token' },
android: {
Expand All @@ -30,6 +31,7 @@ test('reads the API key alone and ignores the removed key alias', () => {
expect(readLimrunCredentials({ LIMRUN_API_KEY: ' lim_key ' })).toEqual({
apiKey: 'lim_key',
region: undefined,
keepAlive: false,
instances: undefined,
});
expect(readLimrunCredentials({ LIM_API_KEY: 'lim_key', LIMRUN_REGION: 'eu' })).toBeUndefined();
Expand All @@ -47,3 +49,13 @@ test('rejects partial instance access instead of creating a new instance', () =>
const { LIM_ANDROID_INSTANCE_ADB_URL: _adbUrl, ...withoutAdbUrl } = ANDROID_ENV;
expect(() => readLimrunCredentials(withoutAdbUrl)).toThrow(/LIM_ANDROID_INSTANCE_ADB_URL/);
});

test('reads LIMRUN_KEEP_ALIVE as an opt-in flag that never registers a runtime alone', () => {
const keepAlive = (env: Record<string, string>) =>
readLimrunCredentials({ ...IOS_ENV, ...env })?.keepAlive;
expect(keepAlive({ LIMRUN_KEEP_ALIVE: '1' })).toBe(true);
expect(keepAlive({ LIMRUN_KEEP_ALIVE: ' TRUE ' })).toBe(true);
expect(keepAlive({ LIMRUN_KEEP_ALIVE: '0' })).toBe(false);
expect(keepAlive({})).toBe(false);
expect(readLimrunCredentials({ LIMRUN_KEEP_ALIVE: '1' })).toBeUndefined();
});
4 changes: 3 additions & 1 deletion src/provider-limrun-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { EnvMap } from '@agent-device/kernel/source-value';
export type LimrunCredentials = Readonly<{
apiKey?: string;
region?: string;
keepAlive?: boolean;
instances?: LimrunInstanceAccess;
}>;

Expand All @@ -29,6 +30,7 @@ export function limrunInstanceVariables(platform: 'ios' | 'android'): readonly s
export function readLimrunCredentials(env: EnvMap): LimrunCredentials | undefined {
const apiKey = env.LIMRUN_API_KEY?.trim() || undefined;
const region = env.LIMRUN_REGION?.trim() || undefined;
const keepAlive = ['1', 'true'].includes(env.LIMRUN_KEEP_ALIVE?.trim().toLowerCase() ?? '');
const ios = readInstanceVars(env, INSTANCE_VARS.ios);
const android = readInstanceVars(env, INSTANCE_VARS.android);
if (!apiKey && !ios && !android) return undefined;
Expand All @@ -40,7 +42,7 @@ export function readLimrunCredentials(env: EnvMap): LimrunCredentials | undefine
adbUrl: android.LIM_ANDROID_INSTANCE_ADB_URL,
},
};
return { apiKey, region, instances: ios || android ? instances : undefined };
return { apiKey, region, keepAlive, instances: ios || android ? instances : undefined };
}

function readInstanceVars<Name extends string>(
Expand Down
4 changes: 4 additions & 0 deletions website/docs/docs/limrun.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ agent-device disconnect

`install`, and `apps` before the first `open`, still need `LIMRUN_API_KEY`, because they use Limrun asset storage. After `open`, `apps` lists the apps installed on the instance without the key. Install the app before you hand over the instance. From the Node.js runtime, `getDeviceSession(device).installRemoteApp(url)` installs from a signed asset URL without the API key.

## Keeping idle sessions alive

Limrun ends an instance after its inactivity timeout, which an idle session reaches while a model thinks between steps. Set `LIMRUN_KEEP_ALIVE=1` (or `true`) to ping the instance every 30 seconds while a session is open. It is off by default, so instance lifetime is unchanged unless you ask for it. In the Node.js runtime, pass `keepAlive: true`.

## CLI workflow

A new Limrun instance does not contain your app. Run `install <package-or-bundle-id> <app-path-or-url>` before `open`. The install command allocates the instance when needed, so you do not need to run `devices` first.
Expand Down
Loading