Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
145 changes: 145 additions & 0 deletions ios-qa/daemon/src/devicectl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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<string, unknown> | undefined;
const tunnel = obj?.result?.tunnel as Record<string, unknown> | 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
Expand All @@ -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<string | null> {
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.
*/
Expand Down
16 changes: 16 additions & 0 deletions ios-qa/daemon/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
19 changes: 16 additions & 3 deletions ios-qa/daemon/src/tunnel-bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { randomBytes } from 'crypto';
import type { DeviceTunnel } from './proxy';
import {
listDevices,
getDeviceTunnelIPv6,
resolveTunnelIPv6,
isAppRunning,
launchApp,
copyFileFromAppContainer,
Expand Down Expand Up @@ -97,8 +97,21 @@ export async function bootstrapTunnel(opts: BootstrapOptions): Promise<Bootstrap
}
}

// Step 3: resolve tunnel IPv6
const ipv6 = await getDeviceTunnelIPv6(target.name, resolve);
// Step 3: resolve tunnel IPv6. Try devicectl `info details` first (most
// reliable on macOS 26.x), fall through to mDNS via dns.lookup, then
// dns.resolve6 as a last-ditch fallback. See devicectl.ts:resolveTunnelIPv6
// for the rationale.
// When tests inject `resolve`, use it for both the mDNS-lookup path AND the
// legacy resolve6 path — otherwise the legacy path would make a real DNS
// call. In production, only `resolve` is set (to the dns.lookup-based
// default) and the legacy path uses the real dns.resolve6.
const ipv6 = await resolveTunnelIPv6({
udid: target.identifier,
deviceName: target.name,
spawn,
resolve,
legacyResolve: resolve,
});
if (!ipv6) {
return { ok: false, error: 'resolve_failed', detail: target.name };
}
Expand Down
Loading