fix(ios-qa): resolve CoreDevice tunnel via devicectl + keep tunnel alive#1673
Open
sternryan wants to merge 1 commit into
Open
fix(ios-qa): resolve CoreDevice tunnel via devicectl + keep tunnel alive#1673sternryan wants to merge 1 commit into
sternryan wants to merge 1 commit into
Conversation
The daemon's tunnel bootstrap used `dns.resolve6` to look up
`<device>.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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
On macOS 26.x (Darwin 25.x), the
/ios-qadaemon'sbootstrapTunnelfails at the IPv6-resolution step withresolve_failed. The cause:getDeviceTunnelIPv6callsdns.resolve6('<device>.coredevice.local'), and Node'sdns.resolve6uses the libresolv path, which does NOT consult mDNSResponder — so the CoreDevice mDNS name returnsESERVFAILeven when the device is actively tunneled.dns.lookup(getaddrinfo) is the API that routes through mDNS.A deeper issue compounds this: even when resolution succeeds, Xcode 26's CoreDevice only keeps the USB tunnel session alive while a
devicectlcommand is in-flight (or Xcode itself is attached). Within ~10–15 seconds of idle, the tunnel IPv6 ULA becomes unroutable —curl http://[fde4:…]:9999/healthztimes out even thoughxcrun devicectl device info detailsstill reports the same address. Proxy traffic from the daemon to the StateServer fails silently.Repro
Fix
Two-part, both inside
ios-qa/daemon/src/:1. Resolution order (
devicectl.ts) — newresolveTunnelIPv6()tries strategies in decreasing reliability:xcrun devicectl device info details --json-outputand readresult.connectionProperties.tunnelIPAddressdirectly (most reliable; also bumps the tunnel as a side effect)dns.lookup(getaddrinfo → mDNSResponder)dns.resolve6as a last-ditch fallback (kept for backwards compat)The new
defaultResolveusesdns.lookupinstead ofdns.resolve6.bootstrapTunnelnow callsresolveTunnelIPv6instead of justgetDeviceTunnelIPv6.2. Tunnel keepalive (
devicectl.ts+index.ts) — newstartTunnelKeepalive(udid)spawns a periodicxcrun devicectl device info details(default 5s) to keep CoreDevice's tunnel session alive. We choseinfo detailsoverdevice consolebecause it's cheap (~10ms CPU per tick, no persistent child process, no stdout firehose, no backpressure risk) and the 5s interval is comfortably under the empirical teardown timeout. Started after a successful bootstrap, stopped onSIGINT/SIGTERM/exit. Returns a{ stop }handle for clean teardown.Testing
New / updated tests in
ios-qa/daemon/test/tunnel-bootstrap.test.ts:getDeviceTunnelIPv6FromDevicectl(4 tests): extractstunnelIPAddress, falls back toresult.tunnel.ipAddress, handles non-zero exit, handles missing fieldresolveTunnelIPv6 fallback chain(4 tests): each strategy preferred in order, all-fail → nullstartTunnelKeepalive(2 tests): periodic spawn,stop()idempotentdevice info detailsspawn stepBackwards compatibility
The legacy
dns.resolve6path is preserved as the third-tier fallback. The exportedgetDeviceTunnelIPv6(deviceName, resolve)signature is unchanged. New surface:getDeviceTunnelIPv6FromDevicectl(udid, spawn),resolveTunnelIPv6(opts),startTunnelKeepalive(udid, opts). Existing tests pass with the new resolution order (the test scripts now include the new spawn step).Pre-existing test issue (not addressed)
test/daemon-integration.test.tsimportsafterEachbut it's not in the bun:test import list. This is a pre-existing failure onmain— unchanged by this PR. Worth a follow-up.Tested against
iPhone 12 Pro on iOS 26.x via Mac Mini M-series running macOS Sequoia 15.x / Darwin 25.3.0.
Maintainer notes
GSTACK_IOS_TUNNEL_KEEPALIVE_MSwould be one knob — happy to add if you'd like.setInterval(...).unref()so it never blocks daemon shutdown.result.tunnel.ipAddressis accepted as a fallback toresult.connectionProperties.tunnelIPAddressbecause some Xcode/CoreDevice JSON shapes use the former — defensive but cheap.