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
5 changes: 5 additions & 0 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {}),
};
});
Expand Down Expand Up @@ -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';
Expand Down Expand Up @@ -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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 New test mock is never reset between tests, allowing call history to leak across test cases

The newly added mock for the idle-stop scheduler is never cleared between tests (mockScheduleIosRunnerIdleStop.mockReset() missing from beforeEach at src/daemon/handlers/__tests__/session.test.ts:162), so call counts from earlier close-on-iOS-simulator tests leak into later ones.

Impact: A test asserting that the scheduler was called could pass even if the code under test never invoked it, masking a real regression.

Every other mock in the file follows the reset pattern

All 25+ other mocks declared at src/daemon/handlers/__tests__/session.test.ts:134-160 have a corresponding mockXxx.mockReset() call inside beforeEach at lines 162-219. The new mockScheduleIosRunnerIdleStop (line 143) is the only one without a reset. The assertion at line 3530 (expect(mockScheduleIosRunnerIdleStop).toHaveBeenCalledWith('sim-1')) could see stale calls from a prior test that also closes an iOS simulator session with runner retention.

Prompt for agents
In src/daemon/handlers/__tests__/session.test.ts, the beforeEach block (starting around line 162) resets every mock declared in the file except the newly added mockScheduleIosRunnerIdleStop. Add mockScheduleIosRunnerIdleStop.mockReset() inside beforeEach, following the same pattern as the other mocks (e.g., after mockStopIosRunner.mockReset() around line 181). This prevents call-count leakage between tests.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in ac96532 — added mockScheduleIosRunnerIdleStop.mockReset() to beforeEach, matching the file's pattern. Good catch.

const mockDismissMacOsAlert = vi.mocked(runMacOsAlertAction);
const mockSettleSimulator = vi.mocked(settleIosSimulator);
const mockResolveAndroidPackage = vi.mocked(resolveAndroidPackageForOpen);
Expand Down Expand Up @@ -176,6 +179,7 @@ beforeEach(() => {
healthCheckMs: 3,
});
mockStopIosRunner.mockReset();
mockScheduleIosRunnerIdleStop.mockReset();
mockStopIosRunner.mockResolvedValue(undefined);
mockDismissMacOsAlert.mockReset();
mockDismissMacOsAlert.mockResolvedValue({} as any);
Expand Down Expand Up @@ -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();
});

Expand Down
4 changes: 4 additions & 0 deletions src/daemon/handlers/session-close.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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) {
Expand Down
54 changes: 54 additions & 0 deletions src/platforms/apple/core/__tests__/runner-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,10 @@ vi.mock('../runner/runner-xctestrun.ts', async () => {

import {
abortAllIosRunnerSessions,
cancelIosRunnerIdleStop,
detachIosSimulatorRunnerSessionsForShutdown,
ensureRunnerSession,
scheduleIosRunnerIdleStop,
executeRunnerCommandWithSession,
getRunnerSessionSnapshot,
invalidateRunnerSession,
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions src/platforms/apple/core/runner/runner-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ export {
export {
detachIosSimulatorRunnerSessionsForShutdown,
getRunnerSessionSnapshot,
scheduleIosRunnerIdleStop,
stopIosRunnerSession,
abortAllIosRunnerSessions,
stopAllIosRunnerSessions,
Expand Down
62 changes: 62 additions & 0 deletions src/platforms/apple/core/runner/runner-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,8 @@ export type RunnerSessionOptions = AppleRunnerLifecycleOptions;

const runnerSessions = new Map<string, RunnerSession>();
const runnerSessionLocks = new Map<string, Promise<unknown>>();
const runnerIdleStopTimers = new Map<string, NodeJS.Timeout>();
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;
Expand Down Expand Up @@ -101,6 +103,9 @@ export async function ensureRunnerSession(
device: DeviceInfo,
options: RunnerSessionOptions,
): Promise<RunnerSession> {
// 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) {
Expand Down Expand Up @@ -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<void> {
cancelIosRunnerIdleStop(deviceId);
await withRunnerSessionLock(deviceId, async () => {
await withRunnerLeaseLock(deviceId, async () => {
await stopRunnerSessionInternal(deviceId);
Expand Down Expand Up @@ -488,6 +549,7 @@ export async function detachIosSimulatorRunnerSessionsForShutdown(): Promise<num
continue; // Could not mark the handoff; leave it for the kill path.
}
runnerSessions.delete(deviceId);
cancelIosRunnerIdleStop(deviceId);
detached += 1;
emitDiagnostic({
level: 'info',
Expand Down
Loading