11import { dispatchCommand , resolveTargetDevice } from '../../core/dispatch.ts' ;
22import { isCommandSupportedOnDevice } from '../../core/capabilities.ts' ;
3- import { runIosRunnerCommand } from '../../platforms/ios/runner-client.ts' ;
4- import { stopIosRunnerSession } from '../../platforms/ios/runner-client.ts' ;
3+ import { runIosRunnerCommand , stopIosRunnerSession } from '../../platforms/ios/runner-client.ts' ;
54import { snapshotAndroid } from '../../platforms/android/index.ts' ;
65import {
76 attachRefs ,
@@ -79,8 +78,7 @@ export async function handleSnapshotCommands(params: {
7978 }
8079 snapshotScope = resolved ;
8180 }
82- const shouldCleanupSessionlessIosRunner = ! session && device . platform === 'ios' ;
83- try {
81+ return await withSessionlessRunnerCleanup ( session , device , async ( ) => {
8482 const data = ( await dispatchCommand ( device , 'snapshot' , [ ] , req . flags ?. out , {
8583 ...contextFromFlags (
8684 logPath ,
@@ -120,11 +118,7 @@ export async function handleSnapshotCommands(params: {
120118 appBundleId : nextSession . appBundleId ,
121119 } ,
122120 } ;
123- } finally {
124- if ( shouldCleanupSessionlessIosRunner ) {
125- await stopIosRunnerSession ( device . id ) ;
126- }
127- }
121+ } ) ;
128122 }
129123
130124 if ( command === 'wait' ) {
@@ -148,8 +142,7 @@ export async function handleSnapshotCommands(params: {
148142 error : { code : 'UNSUPPORTED_OPERATION' , message : 'wait is not supported on this device' } ,
149143 } ;
150144 }
151- const shouldCleanupSessionlessIosRunner = ! session && device . platform === 'ios' ;
152- try {
145+ return await withSessionlessRunnerCleanup ( session , device , async ( ) => {
153146 let text : string ;
154147 let timeoutMs : number | null ;
155148 if ( parsed . kind === 'selector' ) {
@@ -269,11 +262,7 @@ export async function handleSnapshotCommands(params: {
269262 ok : false ,
270263 error : { code : 'COMMAND_FAILED' , message : `wait timed out for text: ${ text } ` } ,
271264 } ;
272- } finally {
273- if ( shouldCleanupSessionlessIosRunner ) {
274- await stopIosRunnerSession ( device . id ) ;
275- }
276- }
265+ } ) ;
277266 }
278267
279268 if ( command === 'alert' ) {
@@ -288,8 +277,7 @@ export async function handleSnapshotCommands(params: {
288277 } ,
289278 } ;
290279 }
291- const shouldCleanupSessionlessIosRunner = ! session && device . platform === 'ios' ;
292- try {
280+ return await withSessionlessRunnerCleanup ( session , device , async ( ) => {
293281 if ( action === 'wait' ) {
294282 const timeout = parseTimeout ( req . positionals ?. [ 1 ] ) ?? DEFAULT_TIMEOUT_MS ;
295283 const start = Date . now ( ) ;
@@ -321,11 +309,7 @@ export async function handleSnapshotCommands(params: {
321309 ) ;
322310 recordIfSession ( sessionStore , session , req , data as Record < string , unknown > ) ;
323311 return { ok : true , data } ;
324- } finally {
325- if ( shouldCleanupSessionlessIosRunner ) {
326- await stopIosRunnerSession ( device . id ) ;
327- }
328- }
312+ } ) ;
329313 }
330314
331315 if ( command === 'settings' ) {
@@ -350,18 +334,20 @@ export async function handleSnapshotCommands(params: {
350334 } ,
351335 } ;
352336 }
353- const appBundleId = session ?. appBundleId ;
354- const data = await dispatchCommand (
355- device ,
356- 'settings' ,
357- [ setting , state , appBundleId ?? '' ] ,
358- req . flags ?. out ,
359- {
360- ...contextFromFlags ( logPath , req . flags , appBundleId , session ?. trace ?. outPath ) ,
361- } ,
362- ) ;
363- recordIfSession ( sessionStore , session , req , data ?? { setting, state } ) ;
364- return { ok : true , data : data ?? { setting, state } } ;
337+ return await withSessionlessRunnerCleanup ( session , device , async ( ) => {
338+ const appBundleId = session ?. appBundleId ;
339+ const data = await dispatchCommand (
340+ device ,
341+ 'settings' ,
342+ [ setting , state , appBundleId ?? '' ] ,
343+ req . flags ?. out ,
344+ {
345+ ...contextFromFlags ( logPath , req . flags , appBundleId , session ?. trace ?. outPath ) ,
346+ } ,
347+ ) ;
348+ recordIfSession ( sessionStore , session , req , data ?? { setting, state } ) ;
349+ return { ok : true , data : data ?? { setting, state } } ;
350+ } ) ;
365351 }
366352
367353 return null ;
@@ -420,6 +406,23 @@ async function resolveSessionDevice(
420406 return { session, device } ;
421407}
422408
409+ async function withSessionlessRunnerCleanup < T > (
410+ session : SessionState | undefined ,
411+ device : SessionState [ 'device' ] ,
412+ task : ( ) => Promise < T > ,
413+ ) : Promise < T > {
414+ const shouldCleanupSessionlessIosRunner = ! session && device . platform === 'ios' ;
415+ try {
416+ return await task ( ) ;
417+ } finally {
418+ // Sessionless iOS commands intentionally stop the runner to avoid leaked xcodebuild processes.
419+ // For multi-command flows, keep an active session via `open` so the runner can be reused.
420+ if ( shouldCleanupSessionlessIosRunner ) {
421+ await stopIosRunnerSession ( device . id ) ;
422+ }
423+ }
424+ }
425+
423426function recordIfSession (
424427 sessionStore : SessionStore ,
425428 session : SessionState | undefined ,
0 commit comments