Skip to content

Paired clients memorize host:port endpoints and never refresh them: a backend port change permanently breaks remote devices (mobile 10 s timeout) until manual re-pairing #19398

Closed as not planned

Description

@DonutsDelivery

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/mobile, apps/web, apps/desktop — connection catalog / pairing (packages/client-runtime/src/connection/*, mobile environment registry)

Summary

Clients store a raw IP:port endpoint as the environment's identity and never refresh it. When the desktop backend's port legitimately changes (its port is re-scanned at every launch, cf. #13747), every remotely paired device that saved host:port times out forever; the server cannot help because it never learns about the clients, and the clients never learn the server moved. The tailscale-serve URL is the one port-stable route, but it is not what pairing saves for mobile, so a port change silently bricks the phone app (10 000 ms connect timeout) until the user re-pairs by hand.

Ports shifting is #13747's half. This issue is the other half: saved endpoints should be refreshed from the server, and portable/serve routes should be preferred over raw host:port. Repaired by hand today; nothing repairs it automatically.

Reproduction (verified on 0.0.45, 2026-10-13)

  1. Desktop (Linux) runs its backend on the port it scanned at launch (was 4773 since Oct 11). A phone is paired/saved with a raw endpoint pinned to that host:port (mobile app also holds the tailscale name in its environment list, but dials the saved direct address).
  2. The desktop app is relaunched (Oct 13 07:46 UTC). Port scan picks the next free port — 4774. The app's own tailscale-serve integration re-points the serve URL to the new port (this part self-heals, and I verified https://<server>.ts.net → 200 OK instantly afterwards).
  3. Nothing on the phone's side changes: its saved route still says the old port. Every connect attempt reaches a dead port → "Times out after 10000ms" in the mobile app, indefinitely. Server-side trace shows zero attempts from the phone reach the backend.
  4. Recovery requires manually editing the phone's saved endpoint or re-pairing. The old port stays dead (no listener), so there is not even an upgrade path.

Same class on other transports: #12641 / #19247 (T3 Connect stores the link-time origin and keeps forwarding to a dead port after restarts), #15751 (stale endpoint bookkeeping in the Connections UI), #13747 (why the port moves at all). They share one root property: a transport-specific address was persisted as durable identity.

Why the current model can't self-heal

  • ExecutionEnvironmentDescriptor (packages/contracts/src/environment.ts) is fetched only after a route works; it describes capabilities but carries no live endpoint information, so a connected client cannot learn the server's current address.
  • The connection catalog persists routes (apps/…/connection/registry.ts, withRoutes/replaceRoutesLocked) but nothing on the server ever instructs a client to replace a dead route for the same environmentId — and the client that can't connect can't be reached.
  • Mobile saves the direct URL (endpointUrl) the user typed or LAN-scanned at pairing time; the port-stable tailscale URL exists (server side: tailscaleServeEnabled, serve re-pointed at every startup — verified) but is not what mobile ends up dialing first.

Proposed fixes

Ranked by value/risk; 1+2 together make "port changed" a non-event on every client, including already-paired stale ones.

1. Publish live endpoints in the descriptor (server, small).
Extend ExecutionEnvironmentDescriptor with the server's known routes, e.g.

"endpoints": {
  "httpUrl": "http://192.168.1.10:4774",            // current LAN direct endpoint
  "tailscaleUrl": "https://archlinux…ts.net"        // present when tailscale serve is enabled
}

The server already recomputes exposure at startup (serverExposure.configureFromSettings({ port })), so this is pure plumbing at the descriptor site that all clients fetch on every connect.

2. Refresh saved routes from the descriptor (client, medium).
On a successful handshake, if descriptor.endpoints disagrees with the saved routes for that environmentId, upsert the new route / drop the dead one (the registry's route replace machinery already exists in client-runtime/src/connection/registry.ts). This keeps routing data live while at least one route works — no pairing-token changes needed.

3. Prefer the serve URL when creating environments (client, small).
When tailscaleServeEnabled/tailscale-exposed, save https://<server>.ts.net as the primary route (port changes are absorbed by serve's re-point) and keep the direct host:port as a secondary. Pure pairing-flow change; does not affect users without tailscale.

4. Discovery once all routes are dead (optional / resilience).
Stale routes that can't reach the server can't be fixed by the server. Options: advertise the serve URL in the pairing deep link (t3code:// scheme already exists for auth handoffs) so every client has at least one port-stable route from day one, or — the UX pattern that solves mobile cleanly — QR pairing on mobile (scan from desktop settings → pair by environmentId + device credential, endpoints become a cache rather than identity).

Impact

Major degradation or frequent failure. From the maker side this is the most-visible failure in the product for multi-device users ("I updated, now my phone/mac can't connect"), often mistaken for an update regression (which is exactly how it presented for us). Every fix at the server half (#13747) reduces how often it triggers, but until the client half exists, any real port change (install switch, port conflict, orphan reclaim, tunnel origin) leaves paired devices permanently dead.

Version or commit

Verified against upstream main@519d64f6ce (2026-10-14). Mobile app 0.0.46-nightly (Android), desktop 0.0.45 on Arch Linux.

Related

Activity

  1. juliusmarminge commented on Oct 22, 2026

    @juliusmarminge
    Member

    Tested Linux web in Chromium against current main b5a226e49c, with isolated server state:

    1. Started the web dev server and a separate backend on port 14986; paired it through Settings → Connections. The client saved the direct address and automatically learned a Tailscale address on the same port.
    2. Stopped that backend and restarted the same environment on 14987. The new port returned HTTP 200 with the same environment ID; 14986 refused connections. The client kept both old-port routes and showed “T3 Code on fs-37 did not answer”. Capture below.
    3. Tried the host's built-in Serve integration on a dedicated HTTPS port. Tailscale rejected configuration with permission-denied, so this did not test your case where a working HTTPS Serve URL should survive the move. The Android 10-second timeout was not verified. The existing endpoint-advertisement/route-learning tests passed: 3 files, 56 tests.

    The current shared client already learns advertised LAN/tailnet routes after a successful connection. Please retry with current host and mobile builds and provide the saved route list before and after a backend port change, plus the host log around startup/Serve configuration. Does the phone have an https://…ts.net route before the change, or only addresses with the old backend port? Omit pairing tokens and credentials. That distinguishes failure to learn a working Serve route from recovery after every saved route is unreachable.

    Web client retaining both old-port routes after the backend moved

  2. github-actions commented on Oct 29, 2026

    @github-actions
    Contributor

    Closing: no reply in 7 days after we asked for more information. Comment with the missing details and we will reopen.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    needs more infoInitial triage showed no bug. Awaiting more info

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions