Skip to content

Commit b7baccd

Browse files
committed
feat(ios): usbmux runner route override + #1403 transport experiment evidence
Adds AGENT_DEVICE_IOS_RUNNER_ROUTE=usbmux, an experimental override that routes coredevice-backend physical devices' runner commands through usbmux, and simplifies the xctest branch that awaited a no-op resolveRunnerTransport. Documents the live #1403 experiment (iPhone 17 Pro, USB + Wi-Fi legs): steady-state is a wash, but the >30s-idle tax drops from ~4.5s (tunnel re-probe + session re-establish) to ~440ms because the usbmux session stays hot; CoreDevice Wi-Fi devices never appear in usbmuxd, so the verdict is usbmux-primary with network fallback rather than tunnel-code deletion. Also records the cable-out failure gap (2x45s retry hang swallowing the usbmux DEVICE_NOT_FOUND hint), which affects today's xctest backend too.
1 parent 352428d commit b7baccd

3 files changed

Lines changed: 134 additions & 12 deletions

File tree

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
# #1403 experiment: usbmux as the primary physical-iOS runner transport
2+
3+
Live results from 2026-07-30, thymikee-iphone (iPhone 17 Pro, iOS 26.5.2, UDID
4+
00008150-001849640CF8401C), macOS host with Xcode 26.2. Method: forced-route
5+
env override `AGENT_DEVICE_IOS_RUNNER_ROUTE=usbmux` in `runner-command-route.ts`
6+
(uncommitted experiment patch), isolated `--state-dir` daemons per leg, timings
7+
from per-request `--debug` ndjson plus wall-clock. Every timing sample was
8+
validated against real command output after an early instrument artifact (see
9+
Pitfalls).
10+
11+
## Numbers (same build, same cable state, verified output)
12+
13+
| Scenario | network (tunnel-IP) route | usbmux route |
14+
|---|---|---|
15+
| Steady-state snapshot, cable in | 350–450 ms | 440 ms |
16+
| Snapshot after 40 s idle | **4.5–4.6 s** (tunnel re-probe + session re-establish) | **440 ms** (unchanged) |
17+
| Runner killed, next snapshot | ~4.0 s self-heal | 4.7 s self-heal |
18+
| Steady-state snapshot, Wi-Fi only (no cable) | 425–495 ms | fails (see below) |
19+
| Full lifecycle open/snapshot/tap/screenshot/close | works | works |
20+
21+
Older-build measurements (first pass) agreed in shape: first open pays one-time
22+
xctestrun prep (~13 s cold) and runner startup; fresh device resolution costs
23+
~6.7 s of devicectl listing either way.
24+
25+
## Findings
26+
27+
1. **The idle tax is the payoff, and it is bigger than expected.** Past the
28+
30 s tunnel-IP cache TTL, the network route re-probes the tunnel and
29+
re-establishes runner readiness: ~4.5 s per idle gap, every time. Over
30+
usbmux the session stays hot indefinitely (40 s gaps measured at steady
31+
440 ms). Agent workflows are exactly the idle-gappy workload that hits this
32+
tax on almost every step.
33+
2. **Steady state is a wash.** The per-command usbmuxd handshake (fresh unix
34+
socket, ListDevices, Connect) roughly cancels the network route's pooled-
35+
connection advantage; ~±90 ms either way.
36+
3. **Wi-Fi deletion is off the table.** A CoreDevice-paired, Wi-Fi-reachable
37+
iPhone (devicectl `available (paired)`, live tunnel) does NOT appear in
38+
usbmuxd at all — modern CoreDevice Wi-Fi runs over `remoted`, invisible to
39+
usbmuxd. usbmuxd listed the device only while cabled, `ConnectionType: USB`
40+
only, and the entry disappears on unplug. The CoreDevice network route must
41+
stay as the Wi-Fi fallback → this is a "usbmux-primary + network-fallback"
42+
reshuffle, not a full tunnel-code deletion.
43+
4. **Failure semantics gap (also affects today's xctest backend).** With the
44+
cable out, forced-usbmux commands hang for 2×45 s of retries and surface a
45+
generic "Runner did not accept connection" — the usbmux client's precise
46+
`DEVICE_NOT_FOUND` ("Connect the device by cable, trust this Mac…") is
47+
swallowed by `shouldRetryRunnerConnectError`. An implementation should make
48+
usbmux DEVICE_NOT_FOUND non-retryable (or fall back to the network route
49+
immediately) and preserve the hint.
50+
5. **Lock behavior is transport-independent.** A locked phone wedges AX
51+
capture/runner relaunch identically on both routes (SBMainWorkspace
52+
"Locked" denial; xcodebuild "Unlock thymikee-iphone to Continue").
53+
6. **usbmux UDID matching is exact.** usbmuxd `SerialNumber` equals the
54+
dashed hardware UDID that `DeviceInfo.id` already uses for devicectl-listed
55+
devices; DeviceID (10) is the mux handle. Multi-device disambiguation is
56+
structural. (Multi-attached-device matrix not exercised: one device only.)
57+
58+
## Implied design (matches the issue's desired outcome, minus full deletion)
59+
60+
- One private route resolver: physical devices try usbmux first; on usbmux
61+
DEVICE_NOT_FOUND (not attached via USB) resolve the CoreDevice tunnel and
62+
use the network route. Tunnel cache/invalidations stay but only run on the
63+
fallback path, so cabled devices never pay the probe/TTL tax.
64+
- Failure semantics: usbmux DEVICE_NOT_FOUND → immediate fallback attempt;
65+
if the fallback also fails, surface the usbmux hint (cable/trust/unlock).
66+
- The xctest backend keeps usbmux-only (CoreDevice unavailable by definition)
67+
but needs the same non-retryable classification for DEVICE_NOT_FOUND.
68+
69+
## Pitfalls for whoever implements/re-measures
70+
71+
- `bin/agent-device.mjs` runs `dist/`, not `src/`. A stale dist silently ran
72+
the first "usbmux leg" on stock network code while the env override sat
73+
inert in the daemon (numbers looked plausible; only the cable-pull test
74+
exposed it). Rebuild and verify the daemon process (`ps eww <pid>`, dist
75+
mtime, and a behavioral probe) before trusting route attribution.
76+
- `/usr/bin/time` on a failing command still prints a small `real` — a fast
77+
error latency is indistinguishable from a fast success unless output is
78+
asserted. Runner-lease contention ("already owned by another daemon")
79+
produced exactly this artifact.
80+
- Sessions are cwd-scoped; run every command of a leg from the same cwd.
81+
- Auto-Lock must be Never during measurement or the runner dies mid-series.

src/platforms/apple/core/__tests__/runner-transport.test.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ beforeEach(() => {
6060

6161
afterEach(() => {
6262
vi.unstubAllGlobals();
63+
vi.unstubAllEnvs();
6364
});
6465

6566
test('waitForRunner propagates request cancellation without fallback', async () => {
@@ -224,6 +225,36 @@ test('sendRunnerCommandOnce routes xctest physical devices through usbmux', asyn
224225
]);
225226
});
226227

228+
test('sendRunnerCommandOnce routes coredevice physical devices through usbmux when overridden', async () => {
229+
vi.stubEnv('AGENT_DEVICE_IOS_RUNNER_ROUTE', 'usbmux');
230+
const fetchMock = vi.fn();
231+
vi.stubGlobal('fetch', fetchMock);
232+
233+
const response = await sendRunnerCommandOnce(iosDevice, 8100, { command: 'uptime' }, 5_000);
234+
235+
assert.equal(response.status, 200);
236+
assert.equal(fetchMock.mock.calls.length, 0);
237+
assert.equal(mockRunCmd.mock.calls.length, 0);
238+
assert.deepEqual(mockUsbmuxPostCommand.mock.calls[0]?.slice(0, 3), [
239+
iosDevice.id,
240+
8100,
241+
{ command: 'uptime' },
242+
]);
243+
});
244+
245+
test('waitForRunner routes coredevice physical devices through usbmux when overridden', async () => {
246+
vi.stubEnv('AGENT_DEVICE_IOS_RUNNER_ROUTE', 'usbmux');
247+
const fetchMock = vi.fn();
248+
vi.stubGlobal('fetch', fetchMock);
249+
250+
const response = await waitForRunner(iosDevice, 8100, { command: 'snapshot' }, undefined, 5_000);
251+
252+
assert.equal(response.status, 200);
253+
assert.equal(fetchMock.mock.calls.length, 0);
254+
assert.equal(mockRunCmd.mock.calls.length, 0);
255+
assert.equal(mockUsbmuxPostCommand.mock.calls.length, 1);
256+
});
257+
227258
function stubSuccessfulFetch(): void {
228259
vi.stubGlobal(
229260
'fetch',

src/platforms/apple/core/runner/runner-command-route.ts

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,13 @@ import { resolveIosPhysicalDeviceControl } from '../physical-device-control.ts';
33

44
const RUNNER_DEVICE_TUNNEL_IP_CACHE_TTL_MS = 30_000;
55

6+
/**
7+
* Experimental override for #1403: forces physical-device runner commands
8+
* through usbmux regardless of backend. Read per-resolve so daemon restarts
9+
* are not required between experiment runs.
10+
*/
11+
const RUNNER_ROUTE_OVERRIDE_ENV = 'AGENT_DEVICE_IOS_RUNNER_ROUTE';
12+
613
type DeviceTunnelIpCacheEntry = {
714
ip: string;
815
expiresAt: number;
@@ -33,13 +40,8 @@ export function createRunnerCommandRouteResolver(device: DeviceInfo, port: numbe
3340
return buildNetworkRoute(device, port, null, false);
3441
}
3542
const control = resolveIosPhysicalDeviceControl(device);
36-
if (control.backend === 'xctest') {
37-
const transport = await control.resolveRunnerTransport(device, timeoutBudgetMs);
38-
return {
39-
kind: transport.kind,
40-
endpoints: [`usbmux://${device.id}:${port}/command`],
41-
cachedTunnelIp: false,
42-
};
43+
if (control.backend === 'xctest' || readRunnerRouteOverride() === 'usbmux') {
44+
return buildUsbmuxRoute(device, port);
4345
}
4446
if (!forceRefresh) {
4547
const cached = readDeviceTunnelIpCache(device.id);
@@ -50,11 +52,7 @@ export function createRunnerCommandRouteResolver(device: DeviceInfo, port: numbe
5052
}
5153
const transport = await control.resolveRunnerTransport(device, timeoutBudgetMs);
5254
if (transport.kind === 'usbmux') {
53-
return {
54-
kind: 'usbmux',
55-
endpoints: [`usbmux://${device.id}:${port}/command`],
56-
cachedTunnelIp: false,
57-
};
55+
return buildUsbmuxRoute(device, port);
5856
}
5957
const tunnelIp = transport.tunnelIp;
6058
requestTunnelIp = tunnelIp;
@@ -75,6 +73,18 @@ export function clearDeviceTunnelIpCache(): void {
7573
deviceTunnelIpCache.clear();
7674
}
7775

76+
function readRunnerRouteOverride(): 'usbmux' | null {
77+
return process.env[RUNNER_ROUTE_OVERRIDE_ENV]?.trim() === 'usbmux' ? 'usbmux' : null;
78+
}
79+
80+
function buildUsbmuxRoute(device: DeviceInfo, port: number): RunnerCommandRoute {
81+
return {
82+
kind: 'usbmux',
83+
endpoints: [`usbmux://${device.id}:${port}/command`],
84+
cachedTunnelIp: false,
85+
};
86+
}
87+
7888
function buildNetworkRoute(
7989
device: DeviceInfo,
8090
port: number,

0 commit comments

Comments
 (0)