Skip to content

fix(pairing): say what to check when a local-network host never answers - #68

Merged
enisze merged 1 commit into
mainfrom
fix/pairing-local-network-diagnostics
Aug 28, 2026
Merged

fix(pairing): say what to check when a local-network host never answers#68
enisze merged 1 commit into
mainfrom
fix/pairing-local-network-diagnostics

Conversation

@enisze

@enisze enisze commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Follow-up to #67, which fixed the desktop advertising a stale LAN address. That was a real bug — the desktop now advertises the machine's current address — but it left the other half of the same failure untouched: when the phone genuinely can't reach the address, the app says nothing useful.

Problem

Every one of these produced the identical message:

Failed to fetch remote environment endpoint http://192.168.2.37:3773/.well-known/t3/environment (HttpClientError: Transport error (GET ...))

  • phone on mobile data, so no 192.168.x address is reachable at all;
  • phone on a different SSID, or a network with AP isolation;
  • host field still holding an address the desktop had on an earlier network;
  • desktop backend genuinely down.

A raw Transport error can't distinguish "your phone isn't on this network" from "the server is dead", so there's no next step to take.

Second, smaller bug in the same path: buildPairingUrl promoted any scheme-less host to https://. Typing 192.168.1.21:3773 therefore built an HTTPS URL against a plain-HTTP desktop backend — the TLS handshake failed and surfaced as, again, an opaque transport error.

Changes

  • packages/shared/src/privateNetworkHost.ts (new) — isPrivateNetworkHostname / isPrivateNetworkUrl: loopback, 10/8, 172.16/12, 192.168/16, 169.254/16, IPv6 loopback + fc00::/7 + fe80::/10, localhost, and .local mDNS names. It deliberately excludes the 100.64.0.0/10 CGNAT range Tailscale hands out: those are private but route over the tailnet, so local-network advice would be actively misleading.
  • packages/client-runtime/src/rpc/http.ts — a transport failure against such a host now appends: "Nothing answered at 192.168.2.37:3773. That address only works from the same local network, so check this device is on that network (mobile data cannot reach it) and that the address still matches the computer — it changes when the computer joins another network." The raw detail is kept so the message stays diagnosable, and routable hosts are unchanged. Benefits mobile and web, since both render this error.
  • apps/mobile/src/features/connection/pairing.ts — scheme-less local hosts get http://, routable hosts still default to https://, explicit schemes are untouched.

Tests

  • 30 cases pinning the predicate's ranges, including the excluded tailnet range and malformed IPv4 literals.
  • 4 cases on the error path: hint present for private hosts across ranges, absent for routable hosts, absent on timeouts (which carry their own reason), and the raw transport detail retained.
  • 6 cases on buildPairingUrl covering scheme selection, explicit schemes, the code-less case, and a parsePairingUrl round-trip.

Full suites green for all three touched packages: packages/shared 353, packages/client-runtime 489, apps/mobile 559. Lint and format clean.

What this does not fix

If the phone is on cellular or a different network, this only makes the app say so — the connection still can't work. Verified while writing it: the desktop serves http://192.168.1.21:3773/.well-known/t3/environment (200, serverVersion 0.0.30) with the macOS firewall disabled, and the server trace shows zero inbound requests from the phone, so the failing request never reaches the machine.

🤖 Generated with Claude Code

Pairing against an RFC1918 address fails at the transport layer whenever the
phone is on mobile data, on another SSID, or holding an address the desktop only
had on an earlier network. All three surfaced as a bare `HttpClientError:
Transport error`, which reads like a dead backend and gives nobody a next step.

Name the unreachable host and the thing to check. Alongside it, stop promoting a
scheme-less local host to `https://`: typing `192.168.1.21:3773` built an https
URL against a plain-HTTP desktop, so the handshake failed and produced the same
opaque transport error.

The shared `isPrivateNetworkHostname` predicate backs both, and deliberately
excludes the 100.64.0.0/10 range Tailscale uses — those route over the tailnet,
so local-network advice would be wrong there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@enisze

enisze commented Aug 28, 2026

Copy link
Copy Markdown
Owner Author

Rebased on main after #70 landed, and dropped the mobile scheme fix from this PR: upstream pingdotgg#4990 (now in main via #70) already defaults bare-IP pairing to HTTP, so my version was redundant. Its duplicate test block is gone too — upstream's own buildPairingUrl tests cover the scheme rules.

What remains here is the part upstream does not have: the shared isPrivateNetworkHostname predicate plus the actionable transport-error message.

One gap this leaves, noted for later: upstream's isIpLiteral only recognises IP literals, so a scheme-less macbook.local:3773 still gets promoted to https://. Worth sending upstream rather than re-diverging here.

Tests after the rebase: shared 353, client-runtime 489, mobile 556 — all passing.

@enisze
enisze force-pushed the fix/pairing-local-network-diagnostics branch from 1396f31 to e907807 Compare August 28, 2026 18:24
@enisze
enisze merged commit c6659ab into main Aug 28, 2026
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 participant