From 909621d0a07f676772c849bd588411f7e22d7f9c Mon Sep 17 00:00:00 2001 From: Ryan Stern <206953196+sternryan@users.noreply.github.com> Date: Sat, 23 May 2026 14:28:06 -0700 Subject: [PATCH] fix(ios-qa): resolve CoreDevice tunnel via devicectl + keep tunnel alive The daemon's tunnel bootstrap used `dns.resolve6` to look up `.coredevice.local`, which fails with ESERVFAIL on macOS 26.x (Darwin 25.x) because Node's resolve6 path goes through libresolv and does NOT consult mDNSResponder. `dns.lookup` (getaddrinfo) does. Even when resolution works, CoreDevice in Xcode 26 only holds the USB tunnel up while a devicectl command is in-flight, so the IPv6 ULA becomes unroutable within ~10-15s of idle and subsequent proxy requests time out. Two-part fix: 1. Resolution order is now (a) `xcrun devicectl device info details --json-output` to read `result.connectionProperties.tunnelIPAddress` directly, (b) mDNS via `dns.lookup`, (c) legacy `dns.resolve6` as a last-ditch fallback. 2. After a successful bootstrap the daemon spawns a periodic `devicectl device info details` (~5s) to keep the tunnel session alive. Cleaned up on SIGINT/SIGTERM/exit. Adds tests for `getDeviceTunnelIPv6FromDevicectl`, the `resolveTunnelIPv6` fallback chain, and `startTunnelKeepalive`. Existing bootstrap tests updated to include the new `device info details` spawn step. Tested against: iPhone 12 Pro on iOS 26.x via Mac Mini M-series running macOS Sequoia 15.x / Darwin 25.3.0. --- ios-qa/daemon/src/devicectl.ts | 145 ++++++++++++++++++ ios-qa/daemon/src/index.ts | 16 ++ ios-qa/daemon/src/tunnel-bootstrap.ts | 19 ++- ios-qa/daemon/test/tunnel-bootstrap.test.ts | 157 +++++++++++++++++++- 4 files changed, 333 insertions(+), 4 deletions(-) diff --git a/ios-qa/daemon/src/devicectl.ts b/ios-qa/daemon/src/devicectl.ts index a3cab48adf..ee1696eb99 100644 --- a/ios-qa/daemon/src/devicectl.ts +++ b/ios-qa/daemon/src/devicectl.ts @@ -27,7 +27,32 @@ export interface ResolveImpl { const defaultSpawn: SpawnImpl = (cmd, args) => spawnSync(cmd, args, { stdio: 'pipe', timeout: 60_000 }); +/** + * Default resolver. Uses `dns.lookup` (getaddrinfo, goes through mDNSResponder + * on macOS) instead of `dns.resolve6` (libresolv, does NOT consult mDNS on + * recent macOS — returns ESERVFAIL for `*.coredevice.local`). + * + * Prefer the IPv6 record but fall back to whatever getaddrinfo returns. + */ const defaultResolve: ResolveImpl = async (hostname) => { + const dns = await import('dns'); + return new Promise((resolve, reject) => { + dns.lookup(hostname, { family: 6, all: true }, (err, addrs) => { + if (err) { reject(err); return; } + const ipv6 = (addrs ?? []).filter((a) => a.family === 6).map((a) => a.address); + if (ipv6.length === 0) { reject(new Error(`no IPv6 records for ${hostname}`)); return; } + resolve(ipv6); + }); + }); +}; + +/** + * Last-resort resolver using `dns.resolve6`. Kept for backwards compatibility + * and for environments where mDNSResponder is not in the resolver chain. On + * macOS 26.x (Darwin 25.x) this typically fails with ESERVFAIL — see comment + * on `defaultResolve` above. + */ +const legacyResolve6: ResolveImpl = async (hostname) => { const dns = await import('dns'); return new Promise((resolve, reject) => { dns.resolve6(hostname, (err, addrs) => { @@ -69,6 +94,89 @@ export function listDevices(spawn: SpawnImpl = defaultSpawn): DeviceEntry[] { } } +/** + * Resolve the CoreDevice tunnel's IPv6 address from `devicectl device info + * details --json-output`. This is the most reliable path on macOS 26.x: the + * tunnel IPv6 lives in `result.connectionProperties.tunnelIPAddress` and is + * authoritative (it's what CoreDevice itself uses to route). + * + * A side effect of running `devicectl device info details` is that it forces + * CoreDevice to bring up / refresh the tunnel session, which is why we prefer + * this over mDNS even on machines where mDNS works. + * + * Returns null when the device isn't found, isn't tunneled, or devicectl + * fails — callers should fall through to mDNS resolution. + */ +export function getDeviceTunnelIPv6FromDevicectl( + udid: string, + spawn: SpawnImpl = defaultSpawn, +): string | null { + const tmp = join(tmpdir(), `devicectl-details-${process.pid}-${Date.now()}.json`); + try { + const r = spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]); + if (r.status !== 0) return null; + const raw = readFileSync(tmp, 'utf-8'); + const obj = JSON.parse(raw); + // `result.connectionProperties.tunnelIPAddress` is the canonical location. + // Some Xcode/CoreDevice versions also surface it under `result.tunnel.ipAddress` + // — accept either. + const conn = obj?.result?.connectionProperties as Record | undefined; + const tunnel = obj?.result?.tunnel as Record | undefined; + const addr = (conn?.tunnelIPAddress ?? tunnel?.ipAddress) as string | undefined; + if (typeof addr === 'string' && addr.includes(':')) return addr; + return null; + } catch { + return null; + } finally { + try { rmSync(tmp, { force: true }); } catch { /* ignore */ } + } +} + +/** + * Start a periodic devicectl `info details` poll that keeps the CoreDevice + * tunnel session alive. Xcode 26's CoreDevice only holds the tunnel up while + * a devicectl command is in-flight or Xcode itself is debugging. Without + * something poking it, the tunnel IPv6 becomes unroutable within seconds — + * `curl` to the address times out even though the address looks valid. + * + * Implementation note: we chose `device info details` (cheap, ~10ms of CPU + * per tick, no persistent child process) over `device console` (which would + * keep the tunnel up continuously but spams stdout, can wedge on backpressure, + * and is harder to kill cleanly). The 5-second interval is comfortably under + * the empirically-observed tunnel teardown timeout (~10-15s of idle). + * + * Returns a `stop()` function that cancels the timer. Safe to call multiple + * times. + */ +export function startTunnelKeepalive( + udid: string, + opts: { intervalMs?: number; spawn?: SpawnImpl } = {}, +): { stop: () => void } { + const intervalMs = opts.intervalMs ?? 5_000; + const spawn = opts.spawn ?? defaultSpawn; + let stopped = false; + const tick = () => { + if (stopped) return; + // Fire-and-forget: ignore result, the side-effect of the spawn is what + // keeps the tunnel up. We deliberately do not use the JSON output here. + try { + const tmp = join(tmpdir(), `devicectl-keepalive-${process.pid}-${Date.now()}.json`); + spawn('xcrun', ['devicectl', 'device', 'info', 'details', '--device', udid, '--json-output', tmp]); + try { rmSync(tmp, { force: true }); } catch { /* ignore */ } + } catch { /* ignore — next tick will retry */ } + }; + const handle = setInterval(tick, intervalMs); + // Don't keep the event loop alive just for this — daemon owns the lifecycle. + if (typeof handle.unref === 'function') handle.unref(); + return { + stop: () => { + if (stopped) return; + stopped = true; + clearInterval(handle); + }, + }; +} + /** * Resolve the CoreDevice tunnel's IPv6 address for a device. The hostname is * derived from the device name as printed by `devicectl list devices`. The @@ -95,6 +203,43 @@ export async function getDeviceTunnelIPv6( } } +/** + * Resolve a device's tunnel IPv6 using every strategy we know, in order of + * decreasing reliability: + * + * 1. `devicectl device info details --json-output` (most reliable on + * macOS 26.x; also has the useful side-effect of bumping the tunnel). + * 2. mDNS via `dns.lookup` (getaddrinfo path — does consult mDNSResponder + * on macOS, unlike `dns.resolve6`). + * 3. mDNS via `dns.resolve6` (legacy path — kept for backwards + * compatibility; will ESERVFAIL on recent macOS). + * + * Returns the first address that any strategy yields, or null. + */ +export async function resolveTunnelIPv6(opts: { + udid: string; + deviceName: string; + spawn?: SpawnImpl; + resolve?: ResolveImpl; + legacyResolve?: ResolveImpl; +}): Promise { + const spawn = opts.spawn ?? defaultSpawn; + const resolveLookup = opts.resolve ?? defaultResolve; + const resolveLegacy = opts.legacyResolve ?? legacyResolve6; + + // 1. devicectl-based + const fromDevicectl = getDeviceTunnelIPv6FromDevicectl(opts.udid, spawn); + if (fromDevicectl) return fromDevicectl; + + // 2. mDNS via dns.lookup + const fromLookup = await getDeviceTunnelIPv6(opts.deviceName, resolveLookup); + if (fromLookup) return fromLookup; + + // 3. last-resort: legacy dns.resolve6 + const fromLegacy = await getDeviceTunnelIPv6(opts.deviceName, resolveLegacy); + return fromLegacy; +} + /** * Check whether a specific bundle ID has a running process on the device. */ diff --git a/ios-qa/daemon/src/index.ts b/ios-qa/daemon/src/index.ts index e89507e1c3..abfe38be3e 100644 --- a/ios-qa/daemon/src/index.ts +++ b/ios-qa/daemon/src/index.ts @@ -21,6 +21,7 @@ import { mintForCaller } from './auth-mint'; import { classifyRoute, proxyToDevice, type DeviceTunnel } from './proxy'; import { writeAudit, writeAttempt, sanitizeReplacer } from './audit'; import { bootstrapTunnel } from './tunnel-bootstrap'; +import { startTunnelKeepalive } from './devicectl'; import type { Capability } from './types'; interface DaemonOptions { @@ -402,6 +403,12 @@ if (import.meta.main) { // Default tunnelProvider: when GSTACK_IOS_TARGET_UDID (or a default with // any connected paired device) is set, bootstrap a real CoreDevice tunnel. // Otherwise return null (proxy will return 503 device_not_connected). + // + // After a successful bootstrap we spawn a periodic devicectl `info details` + // call to keep the CoreDevice tunnel session alive — Xcode 26's CoreDevice + // only holds the tunnel up while a devicectl command is in-flight, so + // without a poke every few seconds the IPv6 becomes unroutable. + let keepalive: { stop: () => void } | null = null; const realTunnelProvider = async () => { const result = await bootstrapTunnel({ udid: targetUDID, @@ -411,9 +418,18 @@ if (import.meta.main) { process.stderr.write(`bootstrap error: ${result.error}${result.detail ? ' — ' + result.detail : ''}\n`); return null; } + if (keepalive) keepalive.stop(); + keepalive = startTunnelKeepalive(result.tunnel.udid); return result.tunnel; }; + const shutdown = () => { + if (keepalive) { keepalive.stop(); keepalive = null; } + }; + process.on('SIGINT', shutdown); + process.on('SIGTERM', shutdown); + process.on('exit', shutdown); + startDaemon({ loopbackPort: port, tailnetEnabled: tailnet, diff --git a/ios-qa/daemon/src/tunnel-bootstrap.ts b/ios-qa/daemon/src/tunnel-bootstrap.ts index aeea9532dd..aa6636938a 100644 --- a/ios-qa/daemon/src/tunnel-bootstrap.ts +++ b/ios-qa/daemon/src/tunnel-bootstrap.ts @@ -17,7 +17,7 @@ import { randomBytes } from 'crypto'; import type { DeviceTunnel } from './proxy'; import { listDevices, - getDeviceTunnelIPv6, + resolveTunnelIPv6, isAppRunning, launchApp, copyFileFromAppContainer, @@ -97,8 +97,21 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise { jsonOutput: { result: { runningProcesses: [{ executable: 'file:///private/var/containers/Bundle/Application/.../com.test.app/com.test', processIdentifier: 1234 }] } }, stdout: 'com.test', }, + { + // devicectl device info details (devicectl-based IPv6 resolution). + // Return no tunnelIPAddress so we fall through to the injected resolver. + argsMatch: /devicectl device info details/, + jsonOutput: { result: { connectionProperties: {} } }, + }, ]); const r = await bootstrapTunnel({ bundleId: 'com.test', @@ -173,6 +184,12 @@ describe('bootstrapTunnel', () => { jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test', processIdentifier: 5678 }] } }, stdout: '/com.test.app/', }, + { + // devicectl-based IPv6 resolution succeeds — returns the tunnel + // address directly, so the injected resolveImpl is never called. + argsMatch: /devicectl device info details/, + jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd99::beef' } } }, + }, { argsMatch: /devicectl device copy from/, destOutput: 'BOOT-TOKEN-XYZ-123\n', @@ -233,6 +250,11 @@ describe('bootstrapTunnel', () => { // jsonOutput body contains the bundle id path, so isAppRunning() returns true. jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } }, }, + { + // devicectl device info details returns no tunnel address. + argsMatch: /devicectl device info details/, + jsonOutput: { result: { connectionProperties: {} } }, + }, ]); const r = await bootstrapTunnel({ bundleId: 'com.test', @@ -258,6 +280,10 @@ describe('bootstrapTunnel', () => { argsMatch: /devicectl device info processes -d B/, jsonOutput: { result: { runningProcesses: [{ executable: 'file:///var/containers/Bundle/Application/X/com.test.app/com.test' }] } }, }, + { + argsMatch: /devicectl device info details --device B/, + jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd00::b' } } }, + }, { argsMatch: /devicectl device copy from --device B/, destOutput: 'TOKEN\n', @@ -274,3 +300,132 @@ describe('bootstrapTunnel', () => { if (r.ok) expect(r.tunnel.udid).toBe('B'); }); }); + +describe('getDeviceTunnelIPv6FromDevicectl', () => { + test('extracts tunnelIPAddress from connectionProperties', () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl device info details --device TEST-UDID/, + jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fde4:2827:528e::1' } } }, + }, + ]); + expect(getDeviceTunnelIPv6FromDevicectl('TEST-UDID', spawn)).toBe('fde4:2827:528e::1'); + }); + + test('falls back to result.tunnel.ipAddress when connectionProperties absent', () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl device info details/, + jsonOutput: { result: { tunnel: { ipAddress: 'fd00::dead:beef' } } }, + }, + ]); + expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBe('fd00::dead:beef'); + }); + + test('returns null when devicectl exits non-zero', () => { + const spawn = makeSpawn([ + { argsMatch: /devicectl device info details/, exitCode: 1, stderr: 'no such device' }, + ]); + expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBeNull(); + }); + + test('returns null when tunnelIPAddress missing or non-string', () => { + const spawn = makeSpawn([ + { argsMatch: /devicectl device info details/, jsonOutput: { result: { connectionProperties: {} } } }, + ]); + expect(getDeviceTunnelIPv6FromDevicectl('UDID', spawn)).toBeNull(); + }); +}); + +describe('resolveTunnelIPv6 fallback chain', () => { + test('prefers devicectl-based resolution', async () => { + const spawn = makeSpawn([ + { + argsMatch: /devicectl device info details/, + jsonOutput: { result: { connectionProperties: { tunnelIPAddress: 'fd11::1' } } }, + }, + ]); + let resolveCalled = false; + const addr = await resolveTunnelIPv6({ + udid: 'U', + deviceName: 'Test', + spawn, + resolve: async () => { resolveCalled = true; return ['fd99::99']; }, + legacyResolve: async () => { resolveCalled = true; return ['fdAA::AA']; }, + }); + expect(addr).toBe('fd11::1'); + expect(resolveCalled).toBe(false); + }); + + test('falls through to dns.lookup when devicectl yields no address', async () => { + const spawn = makeSpawn([ + { argsMatch: /devicectl device info details/, jsonOutput: { result: { connectionProperties: {} } } }, + ]); + let legacyCalled = false; + const addr = await resolveTunnelIPv6({ + udid: 'U', + deviceName: 'Test', + spawn, + resolve: async () => ['fd22::2'], + legacyResolve: async () => { legacyCalled = true; return ['fdAA::AA']; }, + }); + expect(addr).toBe('fd22::2'); + expect(legacyCalled).toBe(false); + }); + + test('falls through to legacy resolve6 when both devicectl and dns.lookup fail', async () => { + const spawn = makeSpawn([ + { argsMatch: /devicectl device info details/, exitCode: 1 }, + ]); + const addr = await resolveTunnelIPv6({ + udid: 'U', + deviceName: 'Test', + spawn, + resolve: async () => { throw new Error('ESERVFAIL'); }, + legacyResolve: async () => ['fd33::3'], + }); + expect(addr).toBe('fd33::3'); + }); + + test('returns null when all three strategies fail', async () => { + const spawn = makeSpawn([ + { argsMatch: /devicectl device info details/, exitCode: 1 }, + ]); + const addr = await resolveTunnelIPv6({ + udid: 'U', + deviceName: 'Test', + spawn, + resolve: async () => { throw new Error('ESERVFAIL'); }, + legacyResolve: async () => { throw new Error('ESERVFAIL'); }, + }); + expect(addr).toBeNull(); + }); +}); + +describe('startTunnelKeepalive', () => { + test('invokes devicectl on each interval tick', async () => { + const calls: string[] = []; + const spawn: SpawnImpl = ((cmd: string, args: string[]) => { + calls.push(`${cmd} ${args.slice(0, 4).join(' ')}`); + return makeReturn(0, '{}', ''); + }) as SpawnImpl; + const ka = startTunnelKeepalive('UDID-X', { intervalMs: 20, spawn }); + await new Promise((res) => setTimeout(res, 75)); + ka.stop(); + const before = calls.length; + // After stop, no more calls. + await new Promise((res) => setTimeout(res, 50)); + expect(calls.length).toBe(before); + expect(before).toBeGreaterThanOrEqual(2); + expect(calls[0]).toContain('devicectl'); + expect(calls[0]).toContain('device info details'); + }); + + test('stop() is idempotent', () => { + const spawn: SpawnImpl = (() => makeReturn(0, '', '')) as SpawnImpl; + const ka = startTunnelKeepalive('U', { intervalMs: 1_000, spawn }); + ka.stop(); + ka.stop(); + // no throw + }); +});