From e63d9d9eab95da995880b8d761140898f0b22cd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 14:19:46 +0200 Subject: [PATCH 1/2] perf: collapse iOS simulator relaunch into one simctl launch call open --relaunch on a simulator dispatched close (simctl terminate, ~0.7s), settled 300ms, then dispatched open (simctl launch, ~0.8s). simctl launch --terminate-running-process does the whole relaunch in one call (~0.94s), so the fast path skips the close dispatch and the post-close settle: steady relaunch ~2.9s -> ~2.0s. The terminateRunningApp option threads from the open context through the interactor to buildIosSimulatorLaunchArgs; only the simulator bundle-id launch path consumes it. Runtime hints keep working - they are user-defaults writes read at the next launch, which the collapsed call still performs. --clear-app-state keeps the close-first ordering so it never mutates a running app's container; real devices and Android keep the two-step relaunch. --- src/core/dispatch-context.ts | 3 + src/core/dispatch.ts | 1 + src/core/interactor-types.ts | 1 + src/daemon/handlers/__tests__/session.test.ts | 63 +++++++++++++++++-- src/daemon/handlers/session-open.ts | 13 +++- src/platforms/apple/core/apps.ts | 14 ++++- src/platforms/apple/interactor.ts | 1 + 7 files changed, 86 insertions(+), 10 deletions(-) diff --git a/src/core/dispatch-context.ts b/src/core/dispatch-context.ts index 2cfee83e56..4c84eea381 100644 --- a/src/core/dispatch-context.ts +++ b/src/core/dispatch-context.ts @@ -44,6 +44,9 @@ export type DispatchContext = ScreenshotDispatchFlags & { activity?: string; launchConsole?: string; launchArgs?: string[]; + // iOS simulator only: relaunch via a single `simctl launch + // --terminate-running-process` instead of a separate terminate + launch. + terminateRunningApp?: boolean; clearAppState?: boolean; verbose?: boolean; logPath?: string; diff --git a/src/core/dispatch.ts b/src/core/dispatch.ts index 131ba6c809..643725ebae 100644 --- a/src/core/dispatch.ts +++ b/src/core/dispatch.ts @@ -304,6 +304,7 @@ async function handleOpenCommand( appBundleId: context?.appBundleId, launchConsole, launchArgs, + terminateRunningApp: context?.terminateRunningApp, }); return { app, ...(launchConsole ? { launchConsole } : {}), ...successText(`Opened: ${app}`) }; } diff --git a/src/core/interactor-types.ts b/src/core/interactor-types.ts index d003f73578..ecd1299353 100644 --- a/src/core/interactor-types.ts +++ b/src/core/interactor-types.ts @@ -74,6 +74,7 @@ export type Interactor = { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; + terminateRunningApp?: boolean; url?: string; }, ): Promise; diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 42b0f29f1b..289fd9cb30 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -3238,7 +3238,7 @@ test('open --relaunch on iOS stops runner before close/open', async () => { expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); }); -test('open --relaunch on iOS simulator keeps runner hot across close/open', async () => { +test('open --relaunch on iOS simulator collapses into one terminate-running open dispatch', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-session'; sessionStore.set(sessionName, { @@ -3263,8 +3263,10 @@ test('open --relaunch on iOS simulator keeps runner hot across close/open', asyn mockStopIosRunner.mockImplementation(async () => { calls.push('stop-runner'); }); - mockDispatch.mockImplementation(async (_device, command, positionals) => { + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; return {}; }); @@ -3282,9 +3284,59 @@ test('open --relaunch on iOS simulator keeps runner hot across close/open', asyn invoke: noopInvoke, }); + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + expect(calls).toEqual(['open:com.example.app']); + expect(openContext?.terminateRunningApp).toBe(true); +}); + +test('open --relaunch --clear-app-state on iOS simulator keeps close-first ordering', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-clear-state-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: [], + flags: { relaunch: true, clearAppState: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + expect(response).toBeTruthy(); expect(response?.ok).toBe(true); expect(calls).toEqual(['close:com.example.app', 'open:com.example.app']); + expect(openContext?.terminateRunningApp).toBeUndefined(); }); test('open --relaunch includes timing and waits for iOS runner prewarm after opening app', async () => { @@ -3395,7 +3447,7 @@ test('open --relaunch on iOS without existing session closes then opens target a expect(calls).toEqual(['stop-runner', 'close:com.example.app', 'open:com.example.app']); }); -test('open --relaunch on iOS simulator reaches settle path for close and open', async () => { +test('open --relaunch on iOS simulator settles once after the collapsed open', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-sim-session'; sessionStore.set(sessionName, { @@ -3437,9 +3489,8 @@ test('open --relaunch on iOS simulator reaches settle path for close and open', expect(response).toBeTruthy(); expect(response?.ok).toBe(true); - expect(settleCalls.length).toBe(2); - expect(settleCalls[0]).toEqual({ deviceId: 'sim-1', delayMs: 300 }); - expect(settleCalls[1]).toEqual({ deviceId: 'sim-1', delayMs: 300 }); + // Collapsed simulator relaunch skips the post-close settle: one settle after open. + expect(settleCalls).toEqual([{ deviceId: 'sim-1', delayMs: 300 }]); }); test('close on macOS session stops runner and dismisses automation alert before delete', async () => { diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index e1d2790d1d..512640597e 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -208,7 +208,17 @@ async function completeOpenCommand(params: { schedulePrewarm(); } - if (shouldRelaunch && openTarget) { + // iOS simulators relaunch with one `simctl launch --terminate-running-process` + // instead of terminate + settle + launch (~1s per relaunch). Runtime hints + // written below are user-defaults reads at that launch, so ordering holds. + // --clear-app-state keeps the close-first ordering: it must never mutate a + // running app's container. + const collapseSimulatorRelaunch = + shouldRelaunch && + Boolean(openTarget) && + isIosSimulator(device) && + req.flags?.clearAppState !== true; + if (shouldRelaunch && openTarget && !collapseSimulatorRelaunch) { const closeTarget = sessionAppBundleId ?? openTarget; const closeStartedAtMs = Date.now(); await relaunchCloseApp({ @@ -255,6 +265,7 @@ async function completeOpenCommand(params: { const openDispatchSession = provisionalSession.session ?? existingSession; await dispatchCommand(device, 'open', openPositionals, req.flags?.out, { ...contextFromFlags(logPath, req.flags, sessionAppBundleId), + ...(collapseSimulatorRelaunch ? { terminateRunningApp: true } : {}), }); timing.openDispatchDurationMs = Math.max(0, Date.now() - openStartedAtMs); const launchUrlStartedAtMs = Date.now(); diff --git a/src/platforms/apple/core/apps.ts b/src/platforms/apple/core/apps.ts index 065b080eb9..4d3f53e3d8 100644 --- a/src/platforms/apple/core/apps.ts +++ b/src/platforms/apple/core/apps.ts @@ -186,7 +186,13 @@ function parseUrlScheme(url: string): string | undefined { export async function openIosApp( device: DeviceInfo, app: string, - options?: { appBundleId?: string; launchConsole?: string; launchArgs?: string[]; url?: string }, + options?: { + appBundleId?: string; + launchConsole?: string; + launchArgs?: string[]; + terminateRunningApp?: boolean; + url?: string; + }, ): Promise { const launchConsole = options?.launchConsole?.trim(); const launchArgs = options?.launchArgs; @@ -258,6 +264,7 @@ export async function openIosApp( await launchIosSimulatorApp(device, bundleId, { ...(launchConsole ? { launchConsole } : {}), ...(launchArgs ? { launchArgs } : {}), + ...(options?.terminateRunningApp ? { terminateRunningApp: true } : {}), }); return; } @@ -1112,7 +1119,7 @@ function isIosBiometricCapabilityMissing(stdout: string, stderr: string): boolea async function launchIosSimulatorApp( device: DeviceInfo, bundleId: string, - options?: { launchConsole?: string; launchArgs?: string[] }, + options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, ): Promise { await ensureBootedSimulator(device); @@ -1175,10 +1182,11 @@ async function launchIosSimulatorApp( function buildIosSimulatorLaunchArgs( deviceId: string, bundleId: string, - options?: { launchConsole?: string; launchArgs?: string[] }, + options?: { launchConsole?: string; launchArgs?: string[]; terminateRunningApp?: boolean }, ): string[] { const args = ['launch']; if (options?.launchConsole) args.push('--console-pty'); + if (options?.terminateRunningApp) args.push('--terminate-running-process'); args.push(deviceId, bundleId); if (options?.launchArgs && options.launchArgs.length > 0) { args.push(...options.launchArgs); diff --git a/src/platforms/apple/interactor.ts b/src/platforms/apple/interactor.ts index f7931c925a..79d429184d 100644 --- a/src/platforms/apple/interactor.ts +++ b/src/platforms/apple/interactor.ts @@ -42,6 +42,7 @@ export function createAppleInteractor( appBundleId: options?.appBundleId, launchConsole: options?.launchConsole, launchArgs: options?.launchArgs, + terminateRunningApp: options?.terminateRunningApp, url: options?.url, }), openDevice: () => openIosDevice(device), From cf9000949ef14843d59462d42327c465b16cba77 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 2 Jul 2026 15:06:43 +0200 Subject: [PATCH 2/2] fix: keep close-first ordering for URL relaunches on simulators Review finding: open --relaunch reached the collapsed fast path, but the URL dispatch branch never consumes terminateRunningApp - a deep-link open never launches the app, so there is nothing to attach the terminate to. The session layer skipped the close while the platform layer did not terminate either, silently losing relaunch semantics. The collapse now applies only to the single app-launch form (openPositionals.length === 1); URL relaunches keep the explicit close-then-open sequence, with a regression test. --- src/daemon/handlers/__tests__/session.test.ts | 51 +++++++++++++++++++ src/daemon/handlers/session-open.ts | 7 ++- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/daemon/handlers/__tests__/session.test.ts b/src/daemon/handlers/__tests__/session.test.ts index 289fd9cb30..23c3848096 100644 --- a/src/daemon/handlers/__tests__/session.test.ts +++ b/src/daemon/handlers/__tests__/session.test.ts @@ -3290,6 +3290,57 @@ test('open --relaunch on iOS simulator collapses into one terminate-running open expect(openContext?.terminateRunningApp).toBe(true); }); +test('open --relaunch on iOS simulator keeps close-first ordering', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'ios-simulator-url-relaunch-session'; + sessionStore.set(sessionName, { + ...makeSession(sessionName, { + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }), + appName: 'com.example.app', + }); + + const calls: string[] = []; + mockResolveTargetDevice.mockResolvedValue({ + platform: 'apple', + id: 'sim-1', + name: 'iPhone 17 Pro', + kind: 'simulator', + booted: true, + }); + let openContext: Record | undefined; + mockDispatch.mockImplementation(async (_device, command, positionals, _out, context) => { + calls.push(`${command}:${positionals.join(' ')}`); + if (command === 'open') openContext = context as Record; + return {}; + }); + + const response = await handleSessionCommands({ + req: { + token: 't', + session: sessionName, + command: 'open', + positionals: ['com.example.app', 'https://example.com/deal'], + flags: { relaunch: true }, + }, + sessionName, + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + invoke: noopInvoke, + }); + + expect(response).toBeTruthy(); + expect(response?.ok).toBe(true); + // The URL dispatch path cannot carry the terminate, so the relaunch keeps + // the explicit close-then-open sequence. + expect(calls).toEqual(['close:com.example.app', 'open:com.example.app https://example.com/deal']); + expect(openContext?.terminateRunningApp).toBeUndefined(); +}); + test('open --relaunch --clear-app-state on iOS simulator keeps close-first ordering', async () => { const sessionStore = makeSessionStore(); const sessionName = 'ios-simulator-clear-state-session'; diff --git a/src/daemon/handlers/session-open.ts b/src/daemon/handlers/session-open.ts index 512640597e..3b4307d213 100644 --- a/src/daemon/handlers/session-open.ts +++ b/src/daemon/handlers/session-open.ts @@ -211,11 +211,14 @@ async function completeOpenCommand(params: { // iOS simulators relaunch with one `simctl launch --terminate-running-process` // instead of terminate + settle + launch (~1s per relaunch). Runtime hints // written below are user-defaults reads at that launch, so ordering holds. - // --clear-app-state keeps the close-first ordering: it must never mutate a - // running app's container. + // Only the single app-launch form collapses: `open ` dispatches + // through the URL path, where a deep-link open never launches the app and + // so cannot carry the terminate; those keep the close-first ordering, as + // does --clear-app-state, which must never mutate a running app's container. const collapseSimulatorRelaunch = shouldRelaunch && Boolean(openTarget) && + openPositionals.length === 1 && isIosSimulator(device) && req.flags?.clearAppState !== true; if (shouldRelaunch && openTarget && !collapseSimulatorRelaunch) {