Blolabel
← Notes
foundational 7 min read

Creating a WebDriverAgent session terminates the last session's app, so our verification step moved the phone it was measuring

Session creation is a write, not a handshake. WebDriverAgent publishes most of its read path as withoutSession routes for exactly this reason, and an observation loop built from them costs 325 milliseconds and disturbs nothing.

In WebDriverAgent, POST /session begins by killing the session that was already active, and killing a session terminates the application that session was bound to. The phone then shows SpringBoard. So a client that creates a session in the middle of a task closes whatever the agent just opened, and a verification step that needs a session destroys the state it was about to measure. Ours did: every task in one benchmark run failed with "foreground is springboard", and the phone had been in the right state until the moment we looked at it.

Abstract technical illustration of an instrument probe touching a surface and the pattern it was measuring collapsing at the point of contact

The three statements that do it

The path is short enough to read. In WebDriverAgentLib/Commands/FBSessionCommands.m, handleCreateSession: opens with [FBSession killActiveSessionAndWaitForTeardown] before it looks at a single capability. In WebDriverAgentLib/Routing/FBSession.m, -kill ends by terminating the tested application when four conditions hold: there is a tested application, FBConfiguration.sharedInstance.shouldTerminateApp is set, the app is running, and it is not the same as the system application. shouldTerminateApp is set to YES in resetSessionSettings, which is to say it is the default.

Nothing here is a bug. A WebDriver session owns an application under test, and tearing it down means putting that application away; the specification's session model assumes one client driving one app. It stops matching reality when the thing being driven is a phone, the state under test is the phone's state rather than one app's, and more than one process wants to look.

The observations below were measured on a physical iPhone 17 Pro Max running iOS 26.6 from macOS 26.5, against WebDriverAgent v16.12.4 (released 6 September 2026), and are recorded in FINDINGS.md section 6 of the phoneshell repository, published 15 September 2026. The code references are to WebDriverAgent's current main branch, which still registers the routes the same way as of v16.12.8 on 11 September 2026.

Which calls need a session, and what they cost

The useful detail, and the one that makes a fix possible, is that WebDriverAgent already publishes most of its read path twice: once under a session and once as .withoutSession. Twenty-two route registrations in FBCustomCommands.m alone carry that flag.

callsessionmeasured costwhat it is forsource
GET /sourcenot required159 to 210 ms at depth 20the accessibility treeFBDebugCommands.m
GET /screenshotnot required72 mspixelsFBScreenshotCommands.m
GET /wda/activeAppInfonot required43 mswhich app is in frontFBCustomCommands.m
GET /wda/lockednot requirednot reportedis the phone awakeFBCustomCommands.m
GET /alert/text, POST /alert/accept, /alert/dismissnot required1,360 ms on a heavy screensystem dialogsFBAlertViewCommands.m
POST /wda/homescreennot requirednot reportedgo homeFBCustomCommands.m
POST /wda/apps/launchUnattachednot requirednot reportedopen an app without owning itFBCustomCommands.m
GET /window/sizenot requirednot reportedscreen geometryFBElementCommands.m
POST /sessioncreates one, kills the lastnot reportedattach to an appFBSessionCommands.m
POST /session/:id/actionsrequired500 ms tap, 19,000 ms dragW3C gesturesFBElementCommands.m
POST /wda/dragfromtofordurationrequired1,400 msnative dragFBElementCommands.m
POST /session/:id/wda/apps/activaterequiredreturns OK with the app still in the backgroundbring an app forwardFBSessionCommands.m
POST /wda/pickerwheel/:uuid/selectrequired550 ms per tappicker wheelsFBElementCommands.m

Reading is cheap and safe. Writing is neither. An observation loop built only from the first group never creates a session, so looking at the phone cannot disturb it, and the three calls that make up a full observation add to 325 ms.

What creating a session does before it reads a single capability POST /session from any client killActiveSessionAndWait first line of handleCreateSession: [session kill] FBSession.m terminate the tested app, if all four hold testedApplication != nil shouldTerminateApp (default YES) testedApplication.running not the system application fb_terminateTestedApplicationWithTimeout: foreground app is gone SpringBoard is in front: the home screen everything the agent had navigated to is unwound, and no call reported an error
Session creation is a write, not a handshake. Read it as "close whatever is open, then attach", because that is what it does.

Three bugs, one cause

Each of these was a real failure on this rig, and all three are the same sentence applied in a different place.

The same task, measured two ways verification that creates a session open Settingsagent acts Auto-Lock: Neverphone is now correct check calls set_settingswhich needs a session Settings terminated, home screen"foreground is springboard": FAIL Every task in the run failed identically, and the failure was in the last 200 milliseconds of each one. verification that reads without one open Settingsagent acts Auto-Lock: Neverphone is now correct GET /source, /screenshotwithoutSession, 325 ms total Settings still in frontthe check reads the real state The rule: the channel that measures must not be a channel that can act. A benchmark that destroys the state it measures still produces numbers. They are consistent, they look like capability, and they are about the harness. Session-scoped calls: actions, dragfromtoforduration, apps/activate, pickerwheel/select. Sessionless: source, screenshot, activeAppInfo, locked, alert, homescreen.
The failing run was not a model result. It was the checker walking in front of the camera, once per task, at exactly the moment of measurement.
What one look at the phone costs, when looking cannot change it 3 sessionless reads tuned full observation the same, before tuning 0.325 s (43 + 72 + 210 ms) 0.99 s 7 to 13 s 02 s4 s6 s8 s wall clock per observation, heavy third-party app
Measured on a heavy third-party screen. The bar at the bottom is the version that also asked for the expensive attributes and the alert text on every read.

What this changes if you build a rig or grade a run

Separate the channel that measures from the channel that acts, and prefer the sessionless routes for everything that only looks. That is not a workaround; it is what the .withoutSession registrations are for, and WebDriverAgent has them for the tree, the screenshot, the foreground app, the lock state, the window size, the alert and the home button.

Two further consequences are worth stating for anyone running a benchmark rather than a test suite. First, a check that mutates is not a check, and the mutation here is invisible because every call returns success. Second, one WebDriverAgent holds one session, so a rig that also runs a companion app on the same phone needs a rule about who may create one, or the two will keep evicting each other and both will report healthy while doing it.

The judgement, marked as one: the WebDriver session model is a poor fit for device-level agents, and the sessionless read path is the part of WebDriverAgent that admits it. A protocol designed for this job would separate "attach to an app" from "look at the device" explicitly, which is close to what the MCP accessibility-tree proposal (arXiv:2608.24898, 13 July 2026) is arguing for on the assistive side.

Check it yourself

Two terminals, one phone, about a minute. Open an app, confirm it is in front, create a session that owns it, then create another one and watch the first app disappear.

git clone https://github.com/Blomega/phoneshell && cd phoneshell
bin/phoneshell up

# no session anywhere in this line
curl -s localhost:8100/wda/activeAppInfo | python3 -m json.tool   # bundleId of what is in front

# session one: attach to Settings, which brings it forward
S1=$(curl -s -X POST localhost:8100/session -H 'content-type: application/json' \
  -d '{"capabilities":{"alwaysMatch":{"bundleId":"com.apple.Preferences"}}}' \
  | python3 -c 'import json,sys;print(json.load(sys.stdin)["sessionId"])')
curl -s localhost:8100/wda/activeAppInfo        # com.apple.Preferences

# session two, from any other client. Session one's app is terminated by the teardown
curl -s -X POST localhost:8100/session -H 'content-type: application/json' \
  -d '{"capabilities":{"alwaysMatch":{}}}' > /dev/null
curl -s localhost:8100/wda/activeAppInfo        # com.apple.springboard

To see the other half, leave the phone on any screen and hammer the read path: /source, /screenshot, /wda/activeAppInfo in a loop for a minute. Nothing moves. The code is handleCreateSession: in FBSessionCommands.m and -kill in FBSession.m, both in appium/WebDriverAgent, and the client side is phoneshell/device.py plus the observation loop in phoneshell/actions.py.

What would prove this wrong

Sources

  1. Appium WebDriverAgent, WebDriverAgentLib/Commands/FBSessionCommands.m, handleCreateSession: and its first statement. https://github.com/appium/WebDriverAgent/blob/master/WebDriverAgentLib/Commands/FBSessionCommands.m
  2. Appium WebDriverAgent, WebDriverAgentLib/Routing/FBSession.m, the kill teardown and its four termination conditions. https://github.com/appium/WebDriverAgent/blob/master/WebDriverAgentLib/Routing/FBSession.m
  3. Appium WebDriverAgent, WebDriverAgentLib/Commands/FBCustomCommands.m, twenty-two withoutSession route registrations. https://github.com/appium/WebDriverAgent/blob/master/WebDriverAgentLib/Commands/FBCustomCommands.m
  4. W3C, WebDriver Level 2, the session model. https://www.w3.org/TR/webdriver2/
  5. phoneshell, FINDINGS.md sections 2, 6 and 18, published 15 September 2026. https://github.com/Blomega/phoneshell/blob/main/FINDINGS.md
  6. Ramineni et al., MCP-Driven Accessibility Tree Standardization for AI-Powered Screen Reader Agents, arXiv:2608.24898, 13 July 2026. https://arxiv.org/abs/2608.24898