Skip to content

fix: use localhost (not 127.0.0.1) for XCTest server connection (#1299)#3166

Open
qwertey6 wants to merge 6 commits intomobile-dev-inc:mainfrom
ReverentPeer:pr/6-ipv6-localhost-fix
Open

fix: use localhost (not 127.0.0.1) for XCTest server connection (#1299)#3166
qwertey6 wants to merge 6 commits intomobile-dev-inc:mainfrom
ReverentPeer:pr/6-ipv6-localhost-fix

Conversation

@qwertey6
Copy link
Copy Markdown

Proposed changes

One-line fix for a 2.5-year-old bug. Maestro CLI was hardcoded to connect to the XCTest runner via 127.0.0.1 (an IPv4 literal). When the runner happened to bind to ::1 (IPv6) only — which can happen with newer Xcode/macOS combinations — every HTTP call failed with Connection refused, even though the runner was alive and curl localhost:<port> worked.

Impact

Root cause

iOS XCTest runner sometimes binds its HTTP server to ::1 (IPv6) only:

$ lsof -iTCP -sTCP:LISTEN | grep IPv6
java   ...  IPv6  TCP localhost:17776 (LISTEN)   # XCTest runner

But Maestro CLI connects via 127.0.0.1 (IPv4 literal):

java.net.ConnectException: Failed to connect to /127.0.0.1:7075

127.0.0.1 is IPv4-only — it cannot reach an IPv6-only socket. Connection refused.

Why localhost works

/etc/hosts on macOS already has both:

127.0.0.1 localhost
::1       localhost

InetAddress.getAllByName("localhost") returns both addresses. okhttp's default Dns.SYSTEM resolver tries each address in order on connection failure, so a localhost URL works regardless of which address family the server binds to.

Changes

Three places hardcoded 127.0.0.1:

  1. MaestroSessionManager.defaultXctestHost — was "127.0.0.1", now "localhost". (Note: defaultHost for the dadb path was already "localhost" — this fix just makes the XCTest constant consistent.)
  2. LocalXCTestInstaller constructor default — was "127.0.0.1", now "localhost"
  3. LocalXCTestInstaller.xcTestDriverStatusCheck — had hardcoded "127.0.0.1" even when host parameter was passed; now uses host

Verification

  • Cold start, warm reuse, and parallel runs on two simulators all pass after the fix
  • Reproduced the original failure on a system where the runner was binding to IPv6 only (error message changed from 127.0.0.1:7075 to localhost/[0:0:0:0:0:0:0:1]:7117, confirming Maestro now uses IPv6 successfully)
  • Existing unit tests still pass (XCTestDriverClientTest already used "localhost" for its fake)

Depends on: #3165#3141#3140#3139#3138 (stacked PR chain)

Issues fixed

Closes #1299
Likely closes #2932

qwertey6 and others added 6 commits April 4, 2026 15:37
iOS simulators share the host's localhost, causing port collisions when
multiple Maestro processes target different sims simultaneously. Session
tracking was per-platform, so two processes on different devices would
interfere with each other's sessions.

Changes:
- Per-device session tracking: SessionStore keys are now
  "{platform}_{deviceId}_{sessionId}" instead of "{platform}_{sessionId}"
- Add --driver-host-port CLI flag for explicit XCTest server port
- Auto-select available ports with isPortAvailable() check
- Refactor SessionStore from singleton to injectable class (DI)
- Add shouldCloseSession(platform, deviceId) for per-device shutdown
  instead of global activeSessions().isEmpty()
- Add cross-process file locking to KeyValueStore (~/.maestro/sessions)
- Append PID to debug log directory to prevent parallel race
- Enable useJUnitPlatform() in maestro-cli (was missing)
- Add SessionStoreTest with 8 tests covering isolation and lifecycle

Verified: 3 iOS simulators + Android emulator running simultaneously,
all passing. Both --driver-host-port (explicit) and auto-port-selection
work correctly.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Default --reinstall-driver to false: reuse a healthy running driver
  instead of killing and reinstalling on every run (~40s saved on iOS)
- XCTestDriverClient checks isChannelAlive() before reinstalling —
  if the user explicitly passes --reinstall-driver, honor it
- Cache extracted iOS build products per-device in
  ~/.maestro/build-products/<deviceId>/ with SHA-256 hash validation:
  skips extraction when source matches cache, re-extracts on upgrade
- Reduce XCTest status check HTTP read timeout from 100s to 3s
- Remove Thread.sleep(1000) heartbeat delay hack (no longer needed
  with per-device session tracking)

Single device: ~52s → ~10-12s. Three devices parallel: ~54s → ~18s.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- iOS XCTest runner: add isVersionMatch() to XCTestInstaller interface.
  LocalXCTestInstaller compares SHA-256 hash of build products against
  a .running-hash marker written at startup. restartXCTestRunner now
  checks both isChannelAlive() AND isVersionMatch() — stale runners
  from a previous Maestro version are replaced automatically.

- Android driver: add isDriverVersionCurrent() that hashes the bundled
  maestro-app.apk and maestro-server.apk, compares against stored hash
  in ~/.maestro/android-driver-hash. On mismatch, APKs are reinstalled
  even when reinstallDriver=false.

- App binary cache (clearAppState): getCachedAppBinary now compares
  Info.plist of cached vs installed app. Stale cache from app updates
  is detected and refreshed before reinstall. Per-device cache dirs
  (~/.maestro/app-cache/<deviceId>/) prevent parallel races.

- Add XCTestDriverClientTest (4 tests) and LocalSimulatorUtilsTest
  (3 tests) covering version mismatch, reuse, and cache behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On iOS, waitForAppToSettle has two tiers: a server-side screenshot
hash check (Tier 1, hardcoded 3000ms) and a client-side hierarchy
comparison fallback (Tier 2). The per-command waitToSettleTimeoutMs
config only controlled Tier 2, so even waitToSettleTimeoutMs: 100
would still burn up to 3 seconds in Tier 1.

Fix: use waitToSettleTimeoutMs as the total settle budget. Tier 1
runs with this timeout, and any remaining time goes to Tier 2:

  - swipe with waitToSettleTimeoutMs: 500 → capped at 500ms total
  - default (no config) → unchanged 3000ms behavior

Wikipedia e2e flow with tuned timeouts: 25s vs 53s baseline.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
On iOS, when a React Native Pressable has accessibilityLabel set, the
child Text content is collapsed into the parent's accessibility label.
The element's title and value are empty, so Maestro's `text` attribute
was always empty for these elements — making `tapOn: "<text>"` fail to
find buttons that are clearly visible to users.

The element IS reachable via `tapOn { label: ... }` or by regex against
accessibilityText, but those are unintuitive workarounds. Users see text
on screen and expect `tapOn: "that text"` to work — that's the entire
point of the selector.

Fix: in mapViewHierarchy, fall back to element.label (the iOS
accessibility label) when both title and value are empty. accessibilityText
still uses element.label as its canonical source, so the existing
Filters.textMatches accessibilityText fallback continues to work.

This also indirectly fixes the "tap doesn't fire onPress" symptom: when
matching by accessibilityText regex, Maestro might select a parent View
wrapping the Pressable, leading to coordinate taps in the wrong area.
With text populated on the Pressable itself, normal element ranking
picks the correct deepest match.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The XCTest runner sometimes binds its HTTP server to ::1 (IPv6) only.
Maestro CLI was hardcoded to connect via 127.0.0.1 (an IPv4 literal),
which cannot reach an IPv6-only socket. Result: every HTTP call fails
with "Connection refused" even though the runner is alive and curl can
reach it via localhost.

Fix: replace 127.0.0.1 with localhost in three places:
- MaestroSessionManager.defaultXctestHost
- LocalXCTestInstaller constructor default
- LocalXCTestInstaller.xcTestDriverStatusCheck (was hardcoded)

okhttp's default Dns resolver returns all addresses for localhost
(both 127.0.0.1 and ::1) and tries them in order on connection
failure, so this works regardless of which address family the
runner binds to.

This is the same root cause as mobile-dev-inc#1299 (open since July 2023).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@Fishbowler
Copy link
Copy Markdown
Contributor

This dependency chain of PRs is a little unusual... It means they can't be merged out of order. If the first PR is the hardest to get to the finish line, then it blocks everything else. Worse, what if something in the chain is outright rejected?

@qwertey6
Copy link
Copy Markdown
Author

Fair point — the chain is large. A few clarifications:

PRs 1-3 have a real dependency (parallel sessions → driver reuse → version safety for that reuse). PRs 4-7 are independent bug fixes that can be rebased onto main individually — happy to do that if it's easier to review.

That said, the chain exists because each fix exposed the next bug during testing. For example, #3167 (port persistence) only became necessary because #3139 (driver reuse) made it possible for the runner to outlive the CLI process, which then collided with #3166 (this PR — IPv6 binding mismatch).

If there's a specific PR you'd like to review first, or if you'd prefer I split the independent ones off the chain, let me know. I can restructure however works best for your review process.

For what it's worth, the individual changes are small — this PR is 3 lines changed in 2 files.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[1.31.0, 1.30.4] xcuitest.XCTestDriverClient$XCTestDriverUnreachable iOS driver not ready - Xcode 26.2 / macOS 26.2 compatibility issue

2 participants