Skip to content

Physical iOS commands can time out in activate/wait-for-idle before executing, leaving hidden stale device ownership #1246

Description

@billsbooth

Summary

On a physical iPhone, agent-device commands can fail with main thread execution timed out before the requested command body runs. The runner logs show the command first calls XCUIApplication.activate() for the target app even though the app is already foreground (state=4), then XCTest waits for the app to idle and times out. Screenshot commands keep working, so the device and app are visually alive, but snapshot, home, and coordinate-backed press/tap all fail through the same activation/idleness path.

After this happens, recovery diagnostics are also confusing: a new open reports DEVICE_IN_USE by the previous session, but agent-device session list returns no sessions, while the daemon and old xcodebuild runner process remain alive and keep logging AGENT_DEVICE_RUNNER_IDLE_KEEPALIVE for hours.

This is on the latest npm package I can resolve today: agent-device 0.19.3 (npm dist-tag latest = 0.19.3). GitHub Releases currently show v0.19.1, so npm appears newer than the Releases page.

Environment

  • agent-device: 0.19.3 via npx -y agent-device
  • npm dist-tag latest: 0.19.3
  • Physical iPhone 14 Pro, connected over CoreDevice
  • Xcode 26.6 (17F113)
  • macOS 26.5.2 arm64
  • Node 26.5.0, npm 11.17.0
  • Private foreground React Native/Expo dev-client app, production-like bundle already loaded

What happened

The app was already open and visibly usable. Screenshots continued to work and showed the app rendering. Network activity from the app also continued. But command types that go through target activation/idleness started failing consistently.

1. snapshot fails before capture

Sanitized runner excerpt:

AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=snapshot commandId=runner-...
AGENT_DEVICE_RUNNER_ACTIVATE bundle=<private-app-bundle> state=4 reason=bundle_changed
    Open <private-app-bundle>
        Activate <private-app-bundle>
            Wait for <private-app-bundle> to idle
AGENT_DEVICE_RUNNER_COMMAND_COALESCED command=snapshot commandId=runner-...
AGENT_DEVICE_RUNNER_COMMAND_FAILED command=snapshot commandId=runner-... error=Error Domain=AgentDeviceRunner Code=3 "main thread execution timed out" UserInfo={NSLocalizedDescription=main thread execution timed out}

The CLI-level JSON returned:

{
  "success": false,
  "error": {
    "code": "COMMAND_FAILED",
    "message": "main thread execution timed out",
    "details": {
      "runnerFailureReason": "runner_main_thread_execution_timeout"
    }
  }
}

2. home fails the same way, but executes later after the CLI has already failed

AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=home commandId=runner-...
AGENT_DEVICE_RUNNER_ACTIVATE bundle=<private-app-bundle> state=4 reason=bundle_changed
    Open <private-app-bundle>
        Activate <private-app-bundle>
            Wait for <private-app-bundle> to idle
AGENT_DEVICE_RUNNER_COMMAND_FAILED command=home commandId=runner-... error=Error Domain=AgentDeviceRunner Code=3 "main thread execution timed out"
...
AGENT_DEVICE_RUNNER_IDLE_KEEPALIVE
    App animations complete notification not received, will attempt to continue.
AGENT_DEVICE_RUNNER_FAST_APP_GUARD command=home bundle=<private-app-bundle> state=4
    Pressing Home button
AGENT_DEVICE_RUNNER_ABANDONED_WORK_DRAINED

So from the caller's perspective home failed after ~30s, but the runner still continued and eventually pressed Home around ~60s. That makes the actual device state diverge from the CLI result.

3. Coordinate-backed press/tap also fails before tapping

Request log shows a 1s readiness preflight and then the command send lasts ~30s:

{"phase":"ios_runner_readiness_preflight","command":"press","durationMs":8,"data":{"command":"tap","reason":"no_recent_healthy_mutation","sessionReady":true,"timeoutMs":1000}}
{"phase":"ios_runner_command_send","command":"press","durationMs":30015,"data":{"command":"tap"}}
{"phase":"platform_command","command":"press","durationMs":30025,"data":{"error":"main thread execution timed out"}}

Runner log for the same command:

AGENT_DEVICE_RUNNER_COMMAND_ACCEPTED command=tap commandId=runner-...
AGENT_DEVICE_RUNNER_ACTIVATE bundle=<private-app-bundle> state=4 reason=bundle_changed
    Open <private-app-bundle>
        Activate <private-app-bundle>
            Wait for <private-app-bundle> to idle
AGENT_DEVICE_RUNNER_COMMAND_FAILED command=tap commandId=runner-... error=Error Domain=AgentDeviceRunner Code=3 "main thread execution timed out"

This was a coordinate-backed tap path, not selector resolution. The target app was already foreground (state=4), and a screenshot immediately before/after worked.

4. Stale ownership after the timeout is hidden from session list

After the failed run:

agent-device open <app> --platform ios --device <device> --session fresh-session --json

returned:

{
  "success": false,
  "error": {
    "code": "DEVICE_IN_USE",
    "message": "Device is already in use by session \"previous-session\".",
    "hint": "Run agent-device session list to inspect active sessions..."
  }
}

But:

agent-device session list --platform ios --device <device> --json

returned:

{
  "success": true,
  "data": {
    "sessions": []
  }
}

At the same time, local processes still showed the old daemon and old physical-iOS runner:

node .../agent-device/dist/src/internal/daemon.js
xcodebuild test-without-building ... AgentDeviceRunnerUITests/RunnerTests/testCommand ...

The runner log continued emitting:

AGENT_DEVICE_RUNNER_IDLE_KEEPALIVE

for hours after session list showed nothing.

Why this seems like an agent-device issue

I do not think this is just a missed selector or app-specific target issue:

  • snapshot, home, and coordinate tap all fail at the same activation/idleness boundary.
  • The target app is already foreground (state=4) when activate() is called.
  • Screenshots still work, which proves the device connection and visual capture path are alive.
  • Coordinate tap still waits for app idle before it can synthesize the event, even though the coordinate path should be useful precisely when AX/idleness is unhealthy.
  • The CLI can return failure while the runner command continues and mutates device state later (home case).
  • The stale device owner is not visible through session list, even though open blocks on it and the daemon/runner process remains alive.

This appears distinct from #1244, which is about system-alert snapshot probes stalling. There was no system alert in this case; the stall happened during Activate <bundle> / Wait for <bundle> to idle before command execution.

Expected behavior

A few possible contracts would make this recoverable:

  1. If the target app is already .runningForeground, avoid calling activate() for coordinate taps/read-only commands unless there is a concrete reason to reactivate.
  2. For coordinate-backed taps and screenshots/snapshot fallbacks, provide an option or internal path that skips XCTest pre-event app-idle waiting when the app is foreground but never quiesces.
  3. If a command times out and is abandoned, prevent it from mutating device state later after the CLI has reported failure, or report that it is still in flight and keep recovery status explicit.
  4. Make stale device ownership visible and clearable: if open says a device is in use by session X, session list should show X or there should be a documented command to clear the stale lease/runner.
  5. Diagnostics should identify that the timeout occurred during target activation/idleness, not during selector lookup or event synthesis.

Reproduction shape

This was observed with a private foreground iOS app that does enough ongoing UI/main-thread work after login that XCTest never observes idle, while screenshots and the app itself remain usable.

A generic reproducer should be any physical-iOS app that remains foreground and visually responsive but does not report XCTest quiescence after activation, for example an app with continuous animation/polling or other main-thread/UI activity. Then run:

agent-device open <app> --platform ios --device <physical-device> --session repro --json
agent-device screenshot /tmp/control.png --platform ios --device <physical-device> --session repro --json
agent-device snapshot -i --platform ios --device <physical-device> --session repro --json
agent-device press <coordinate-or-ref> --platform ios --device <physical-device> --session repro --json
agent-device home --platform ios --device <physical-device> --session repro --json

Expected failure signature is main thread execution timed out in commands that log AGENT_DEVICE_RUNNER_ACTIVATE ... state=4 followed by XCTest Wait for <bundle> to idle, while screenshot still succeeds.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions