diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 42b0f29f1..b668ceb42 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -26,6 +26,7 @@ vi.mock('../../../platforms/apple/core/runner/runner-client.ts', async (importOr })), prewarmAppleRunnerCache: vi.fn(), prewarmIosRunnerSession: vi.fn(), + scheduleIosRunnerIdleStop: vi.fn(), stopIosRunnerSession: vi.fn(async () => {}), }; }); @@ -109,6 +110,7 @@ import { prepareIosRunner, prewarmAppleRunnerCache, prewarmIosRunnerSession, + scheduleIosRunnerIdleStop, stopIosRunnerSession, } from '../../../platforms/apple/core/runner/runner-client.ts'; import { runMacOsAlertAction } from '../../../platforms/apple/os/macos/helper.ts'; @@ -138,6 +140,7 @@ const mockPrewarmIosRunnerSession = vi.mocked(prewarmIosRunnerSession); const mockPrewarmAppleRunnerCache = vi.mocked(prewarmAppleRunnerCache); const mockPrepareIosRunner = vi.mocked(prepareIosRunner); const mockStopIosRunner = vi.mocked(stopIosRunnerSession); +const mockScheduleIosRunnerIdleStop = vi.mocked(scheduleIosRunnerIdleStop); const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction); const mockSettleSimulator = vi.mocked(settleIosSimulator); const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen); @@ -176,6 +179,7 @@ beforeEach(() => { healthCheckMs: 3, }); mockStopIosRunner.mockReset(); + mockScheduleIosRunnerIdleStop.mockReset(); mockStopIosRunner.mockResolvedValue(undefined); mockDismissMacOsAlert.mockReset(); mockDismissMacOsAlert.mockResolvedValue({} as any); @@ -3524,6 +3528,7 @@ test('close on iOS simulator session retains runner and deletes the session', as expect(response).toBeTruthy(); expect(response?.ok).toBe(true); expect(mockStopIosRunner).not.toHaveBeenCalled(); + expect(mockScheduleIosRunnerIdleStop).toHaveBeenCalledWith('sim-1'); expect(sessionStore.get(sessionName)).toBeUndefined(); }); diff --git a/src/daemon/handlers/session-close.ts b/src/daemon/handlers/session-close.ts index 213e65b5a..8ff2b571f 100644 --- a/src/daemon/handlers/session-close.ts +++ b/src/daemon/handlers/session-close.ts @@ -1,4 +1,5 @@ import { emitDiagnostic } from '../../utils/diagnostics.ts'; +import { scheduleIosRunnerIdleStop } from '../../platforms/apple/core/runner/runner-client.ts'; import { isApplePlatform, type DeviceInfo } from '../../kernel/device.ts'; import { dispatchCommand } from '../../core/dispatch.ts'; import { contextFromFlags } from '../context.ts'; @@ -101,6 +102,9 @@ export async function handleCloseCommand(params: { deviceId: session.device.id, }, }); + // A retained runner holds the device's runner lease against every other + // daemon; bound that with an idle stop unless something reuses it first. + scheduleIosRunnerIdleStop(session.device.id); } const runtime = sessionStore.getRuntimeHints(sessionName); if (hasRuntimeTransportHints(runtime) && session.appBundleId) { diff --git a/src/platforms/apple/core/__tests__/runner-session.test.ts b/src/platforms/apple/core/__tests__/runner-session.test.ts index a64ef8882..8b6f6e9aa 100644 --- a/src/platforms/apple/core/__tests__/runner-session.test.ts +++ b/src/platforms/apple/core/__tests__/runner-session.test.ts @@ -109,8 +109,10 @@ vi.mock('../runner/runner-xctestrun.ts', async () => { import { abortAllIosRunnerSessions, + cancelIosRunnerIdleStop, detachIosSimulatorRunnerSessionsForShutdown, ensureRunnerSession, + scheduleIosRunnerIdleStop, executeRunnerCommandWithSession, getRunnerSessionSnapshot, invalidateRunnerSession, @@ -726,6 +728,58 @@ test('runner session does not require Apple developer mode for iOS simulators', assert.equal(mockRunAppleToolCommand.mock.calls.some(isDevToolsSecurityStatusCall), false); }); +test('idle stop tears down a retained runner after the idle window', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-idle-stop-sim' }; + const previousIdleMs = process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; + process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS = '40'; + try { + await ensureRunnerSession(device, {}); + scheduleIosRunnerIdleStop(device.id); + + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.equal(getRunnerSessionSnapshot(device.id), null); + } finally { + cancelIosRunnerIdleStop(device.id); + if (previousIdleMs === undefined) delete process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; + else process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS = previousIdleMs; + } +}); + +test('any runner use cancels a pending idle stop', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-idle-cancel-sim' }; + const previousIdleMs = process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; + process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS = '40'; + try { + await ensureRunnerSession(device, {}); + scheduleIosRunnerIdleStop(device.id); + await ensureRunnerSession(device, {}); + + await new Promise((resolve) => setTimeout(resolve, 150)); + assert.ok(getRunnerSessionSnapshot(device.id)); + } finally { + cancelIosRunnerIdleStop(device.id); + if (previousIdleMs === undefined) delete process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; + else process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS = previousIdleMs; + } +}); + +test('idle stop is disabled when the window is zero', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-idle-disabled-sim' }; + const previousIdleMs = process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; + process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS = '0'; + try { + await ensureRunnerSession(device, {}); + scheduleIosRunnerIdleStop(device.id); + + await new Promise((resolve) => setTimeout(resolve, 100)); + assert.ok(getRunnerSessionSnapshot(device.id)); + } finally { + cancelIosRunnerIdleStop(device.id); + if (previousIdleMs === undefined) delete process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS; + else process.env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS = previousIdleMs; + } +}); + test('shutdown detach hands off default-set simulator runner sessions', async () => { const device = { ...IOS_SIMULATOR, id: 'runner-session-detach-default-sim' }; // Default simulator set: no XCTestDevices redirect is held. diff --git a/src/platforms/apple/core/runner/runner-client.ts b/src/platforms/apple/core/runner/runner-client.ts index d53f14568..18d2c6285 100644 --- a/src/platforms/apple/core/runner/runner-client.ts +++ b/src/platforms/apple/core/runner/runner-client.ts @@ -187,6 +187,7 @@ export { export { detachIosSimulatorRunnerSessionsForShutdown, getRunnerSessionSnapshot, + scheduleIosRunnerIdleStop, stopIosRunnerSession, abortAllIosRunnerSessions, stopAllIosRunnerSessions, diff --git a/src/platforms/apple/core/runner/runner-session.ts b/src/platforms/apple/core/runner/runner-session.ts index f641ad1f0..64c306de2 100644 --- a/src/platforms/apple/core/runner/runner-session.ts +++ b/src/platforms/apple/core/runner/runner-session.ts @@ -68,6 +68,8 @@ export type RunnerSessionOptions = AppleRunnerLifecycleOptions; const runnerSessions = new Map(); const runnerSessionLocks = new Map>(); +const runnerIdleStopTimers = new Map(); +const RUNNER_RETAINED_IDLE_STOP_DEFAULT_MS = 5 * 60_000; const RUNNER_READY_PREFLIGHT_TIMEOUT_MS = 1_000; const RUNNER_STALE_BUNDLE_UNINSTALL_TIMEOUT_MS = 10_000; const RUNNER_PREFLIGHT_SKIP_FRESHNESS_MS = 5_000; @@ -101,6 +103,9 @@ export async function ensureRunnerSession( device: DeviceInfo, options: RunnerSessionOptions, ): Promise { + // Any runner use means the device is active again: a pending idle stop + // from a retained-after-close runner no longer applies. + cancelIosRunnerIdleStop(device.id); return await withRunnerSessionLock(device.id, async () => { const existing = runnerSessions.get(device.id); if (existing) { @@ -444,7 +449,63 @@ async function stopRunnerSessionInternal( } } +// Bounds the lifetime of a runner retained after session close: the retained +// runner holds the device's runner lease, which blocks every other daemon on +// the machine from using the device. If nothing touches the runner within the +// idle window, stop it and release the lease. Any ensureRunnerSession call +// cancels the pending stop. AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS overrides +// the window; 0 disables idle stops (retain until daemon exit, the pre-idle +// behavior). +export function scheduleIosRunnerIdleStop(deviceId: string): void { + cancelIosRunnerIdleStop(deviceId); + const idleMs = resolveRunnerIdleStopMs(); + if (idleMs <= 0) return; + if (!runnerSessions.has(deviceId)) return; + const timer = setTimeout(() => { + runnerIdleStopTimers.delete(deviceId); + emitDiagnostic({ + level: 'info', + phase: 'ios_runner_idle_stop', + data: { deviceId, idleMs }, + }); + stopIosRunnerSession(deviceId).catch((error: unknown) => { + emitDiagnostic({ + level: 'warn', + phase: 'ios_runner_idle_stop_failed', + data: { + deviceId, + error: error instanceof Error ? error.message : String(error), + }, + }); + }); + }, idleMs); + timer.unref?.(); + runnerIdleStopTimers.set(deviceId, timer); + emitDiagnostic({ + level: 'debug', + phase: 'ios_runner_idle_stop_scheduled', + data: { deviceId, idleMs }, + }); +} + +export function cancelIosRunnerIdleStop(deviceId: string): void { + const timer = runnerIdleStopTimers.get(deviceId); + if (!timer) return; + clearTimeout(timer); + runnerIdleStopTimers.delete(deviceId); +} + +function resolveRunnerIdleStopMs(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.AGENT_DEVICE_IOS_RUNNER_IDLE_STOP_MS?.trim(); + if (raw) { + const parsed = Number(raw); + if (Number.isFinite(parsed) && parsed >= 0) return Math.floor(parsed); + } + return RUNNER_RETAINED_IDLE_STOP_DEFAULT_MS; +} + export async function stopIosRunnerSession(deviceId: string): Promise { + cancelIosRunnerIdleStop(deviceId); await withRunnerSessionLock(deviceId, async () => { await withRunnerLeaseLock(deviceId, async () => { await stopRunnerSessionInternal(deviceId); @@ -488,6 +549,7 @@ export async function detachIosSimulatorRunnerSessionsForShutdown(): Promise