@@ -241,6 +241,81 @@ extension RunnerTests {
241241 XCTAssertNil ( box . error )
242242 XCTAssertEqual ( box . observedMainThread , true )
243243 }
244+
245+ func testRunMainThreadWorkTimeoutMarksAbandonedUntilDrained ( ) {
246+ final class ResultBox {
247+ var error : Error ?
248+ var abandonedCount : Int ?
249+ var abandonedSinceSet : Bool ?
250+ var drainedCount : Int ?
251+ var drainedSinceCleared : Bool ?
252+ }
253+ let box = ResultBox ( )
254+ let releaseWork = DispatchSemaphore ( value : 0 )
255+ let observedAbandoned = DispatchSemaphore ( value : 0 )
256+ let finished = expectation ( description : " off-main caller timed out " )
257+ let drained = expectation ( description : " abandoned main work drained " )
258+
259+ DispatchQueue ( label : " agent-device.runner.tests.timeout " ) . async {
260+ do {
261+ _ = try self . runMainThreadWork (
262+ command : nil ,
263+ timeout : 0 ,
264+ timeoutError : self . mainThreadExecutionTimeoutError ,
265+ onAbandoned : {
266+ box . abandonedCount = self . abandonedMainThreadWorkCount
267+ box . abandonedSinceSet = self . abandonedMainThreadWorkSince != nil
268+ observedAbandoned . signal ( )
269+ } ,
270+ onDrained : {
271+ self . mainThreadWorkLock . lock ( )
272+ box . drainedCount = self . abandonedMainThreadWorkCount
273+ box . drainedSinceCleared = self . abandonedMainThreadWorkSince == nil
274+ self . mainThreadWorkLock . unlock ( )
275+ drained . fulfill ( )
276+ }
277+ ) {
278+ _ = releaseWork . wait ( timeout : . now ( ) + 1 )
279+ return true
280+ }
281+ } catch {
282+ box . error = error
283+ }
284+ finished . fulfill ( )
285+ }
286+
287+ DispatchQueue ( label : " agent-device.runner.tests.release-timeout " ) . async {
288+ _ = observedAbandoned . wait ( timeout : . now ( ) + 1 )
289+ releaseWork . signal ( )
290+ }
291+
292+ wait ( for : [ finished , drained ] , timeout : 2 )
293+ XCTAssertEqual ( ( box . error as NSError ? ) ? . code , RunnerErrorCode . mainThreadExecutionTimedOut )
294+ XCTAssertEqual ( box . abandonedCount , 1 )
295+ XCTAssertEqual ( box . abandonedSinceSet , true )
296+ XCTAssertEqual ( box . drainedCount , 0 )
297+ XCTAssertEqual ( box . drainedSinceCleared , true )
298+ }
299+
300+ func testPostSnapshotDelayMarkDoesNotQueueBehindAbandonedTreeCapture ( ) {
301+ abandonedTreeCaptureCount = 1
302+ defer {
303+ abandonedTreeCaptureCount = 0
304+ needsPostSnapshotInteractionDelay = false
305+ }
306+
307+ let finished = expectation ( description : " off-main caller finished " )
308+ DispatchQueue ( label : " agent-device.runner.tests.post-snapshot-delay " ) . async {
309+ self . setNeedsPostSnapshotInteractionDelay ( )
310+ finished . fulfill ( )
311+ }
312+
313+ wait ( for : [ finished ] , timeout : 1 )
314+ mainThreadWorkLock . lock ( )
315+ let abandonedWorkCount = abandonedMainThreadWorkCount
316+ mainThreadWorkLock . unlock ( )
317+ XCTAssertEqual ( abandonedWorkCount , 0 )
318+ }
244319#endif
245320
246321 func execute ( command : Command ) throws -> Response {
@@ -583,7 +658,7 @@ extension RunnerTests {
583658 timeout : mainThreadExecutionTimeout ,
584659 timeoutError : mainThreadExecutionTimeoutError
585660 ) {
586- self . prepareActiveCommandContext ( command : command )
661+ try self . prepareActiveCommandContextSafely ( command : command )
587662 }
588663 switch preparation {
589664 case . response ( let response ) :
@@ -627,6 +702,10 @@ extension RunnerTests {
627702 needsPostSnapshotInteractionDelay = true
628703 return
629704 }
705+ guard ! hasAbandonedTreeCapture ( ) else {
706+ NSLog ( " AGENT_DEVICE_RUNNER_POST_SNAPSHOT_DELAY_MARK_SKIPPED_XCTEST_OCCUPIED " )
707+ return
708+ }
630709 do {
631710 try runMainThreadWork (
632711 command : nil ,
@@ -658,6 +737,28 @@ extension RunnerTests {
658737 }
659738 }
660739
740+ private func prepareActiveCommandContextSafely ( command : Command ) throws -> ActiveCommandPreparation {
741+ var preparation : ActiveCommandPreparation ?
742+ let exceptionMessage = RunnerObjCExceptionCatcher . catchException ( {
743+ preparation = self . prepareActiveCommandContext ( command : command )
744+ } )
745+ if let exceptionMessage {
746+ throw NSError (
747+ domain : RunnerErrorDomain . exception ,
748+ code : RunnerErrorCode . objcException ,
749+ userInfo : [ NSLocalizedDescriptionKey : exceptionMessage ]
750+ )
751+ }
752+ guard let preparation else {
753+ throw NSError (
754+ domain : RunnerErrorDomain . general ,
755+ code : RunnerErrorCode . commandReturnedNoResponse ,
756+ userInfo : [ NSLocalizedDescriptionKey : " snapshot preflight returned no response " ]
757+ )
758+ }
759+ return preparation
760+ }
761+
661762 private func executeOnMain ( command : Command ) throws -> Response {
662763 let preparation = prepareActiveCommandContext ( command : command )
663764 let activeApp : XCUIApplication
0 commit comments