diff --git a/packages/provider-limrun/src/runtime.ts b/packages/provider-limrun/src/runtime.ts index f2b67bab35..d0ab48448b 100644 --- a/packages/provider-limrun/src/runtime.ts +++ b/packages/provider-limrun/src/runtime.ts @@ -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; }; @@ -106,6 +110,7 @@ export function createLimrunRuntime( class LimrunRuntimeImplementation implements ProviderDeviceRuntime { private readonly limrun: Limrun | undefined; private readonly sessions = new Map(); + private readonly keepAliveTimers = new Map(); private readonly appAliases = new Map< string, Readonly<{ assetName: string; installedAppId: string }> @@ -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 }; } @@ -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 { + 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; diff --git a/src/__tests__/limrun-runtime.test.ts b/src/__tests__/limrun-runtime.test.ts index 957f811bae..b4097cd8d9 100644 --- a/src/__tests__/limrun-runtime.test.ts +++ b/src/__tests__/limrun-runtime.test.ts @@ -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 () => ({ @@ -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, @@ -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', diff --git a/src/commands/schema/cli-help.ts b/src/commands/schema/cli-help.ts index 914849ffdf..0ac4b4829f 100644 --- a/src/commands/schema/cli-help.ts +++ b/src/commands/schema/cli-help.ts @@ -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 diff --git a/src/provider-limrun-credentials.test.ts b/src/provider-limrun-credentials.test.ts index 0aad5c4cba..087ae58827 100644 --- a/src/provider-limrun-credentials.test.ts +++ b/src/provider-limrun-credentials.test.ts @@ -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: { @@ -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(); @@ -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) => + 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(); +}); diff --git a/src/provider-limrun-credentials.ts b/src/provider-limrun-credentials.ts index 057637daa3..4fb205de0b 100644 --- a/src/provider-limrun-credentials.ts +++ b/src/provider-limrun-credentials.ts @@ -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; }>; @@ -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; @@ -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( diff --git a/website/docs/docs/limrun.md b/website/docs/docs/limrun.md index 190d319e7d..2db78b130f 100644 --- a/website/docs/docs/limrun.md +++ b/website/docs/docs/limrun.md @@ -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 ` before `open`. The install command allocates the instance when needed, so you do not need to run `devices` first.