Skip to content

[Bug]: Tailscale Serve mapping is hijacked by a secondary instance and left stale on unclean exit, never reconciled #10363

Description

@sensei-woo

Before submitting

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

Area

apps/server

Steps to reproduce

Tailscale Serve is enabled (tailscaleServeEnabled, serve port 10010 in my case), and the desktop app is running normally — backend on DEFAULT_DESKTOP_BACKEND_PORT (3773), and https://<machine>.<tailnet>.ts.net:10010 reaches it.

  1. open -n -a "T3 Code (Alpha)" on macOS. A second instance starts, because @clerk/electron's createClerkBridge skips requestSingleInstanceLock() on darwin, so bridge.isPrimaryInstance is always true there and the second-instance path in apps/desktop/src/app/DesktopClerk.ts never fires.
  2. Its backend finds 3773 busy and scans up to 3774 (apps/desktop/src/app/DesktopApp.ts).
  3. tailscale serve status now shows the shared serve port pointing at http://127.0.0.1:3774 — the healthy primary on 3773 has been silently evicted from the tailnet URL. ~/.t3/userdata/server-runtime.json has also been overwritten with the second instance's pid/port.
  4. Close the second window. On macOS the app does not quit on window-all-closed; the instance lingers headless and (in both occurrences here) eventually exited without the release finalizer running.
  5. From the phone / any tailnet client, hit the tailnet URL.

Both occurrences on this machine came from step 1 happening by accident, not deliberately — a second launch is easy to trigger on macOS.

Expected behavior

A second server that finds the Tailscale Serve slot already fronting a live T3 server should leave it alone and log that it did. The primary should keep the tailnet URL. And whichever server is publishing the mapping should be able to recover it if it goes stale.

Actual behavior

The mapping is tailnet-wide state keyed only by HTTPS port, and apps/server/src/server.ts (L619–671 on main @ 223ff44) writes it unconditionally in the acquireRelease acquire and removes it unconditionally in release:

  • Hijack. Acquire waits for activation, reads the bound port, and calls ensureTailscaleServe({ localPort, servePort, localHost: "127.0.0.1" }) with no check of what is already there. Whoever starts last wins the slot.
  • Stale on unclean exit. The finalizer only runs on a clean scope close. When the secondary died without it, the mapping stayed pointed at the now-dead 3774 and the tailnet URL returned endpoint returned undeclared status 502 from Tailscale.
  • Symmetric hazard even on a clean exit. Had the finalizer run, disableTailscaleServe({ servePort }) would have removed the mapping outright — also breaking the primary, which by then owned nothing.
  • Nothing reconciles. The primary never re-runs ensureTailscaleServe, and the acquire failure path is Effect.catch → Effect.logWarning, so even a hard failure is invisible unless you are reading server logs. The only self-heal in the codebase is resolveTailscalePairingBase in apps/server/src/cli/pair.ts, and that is manual (t3 pair) — and it is itself blocked here, because discoverPairTarget skips the dead pid recorded in the clobbered server-runtime.json.

Note that pair.ts already has exactly the guard the server layer is missing: it probes /.well-known/t3/environment through the existing mapping and refuses the port when it fronts a different live environment (ServesOtherEnvironmentError / ServePortOccupiedError). The server startup path just doesn't do it.

Impact

Major degradation or frequent failure

Version or commit

0.0.38 (behavior still present on main @ 223ff44)

Environment

macOS 15 (Darwin 25.6.0), T3 Code (Alpha) 0.0.38, @clerk/electron 0.0.37, tailscale CLI on the host

Logs or stack traces

# second instance starts and takes 3774
21:28:42  desktop.backendInstance.start
21:28:42  configureFromSettings { port: 3774 }
# its window is closed 20s later; the process lingers headless ~90 min
21:29:02  desktop.lifecycle.windowAllClosed
# no "Tailscale Serve disabled" log line was ever emitted by that instance

# after it was gone, from a tailnet client:
endpoint returned undeclared status 502

Workaround

Re-run t3 pair --tailscale from the surviving instance (or tailscale serve --bg --https=<servePort> http://127.0.0.1:3773 by hand) to repoint the mapping. If server-runtime.json still records the dead pid, pairing has to be pointed at the live server explicitly.


Follow-up, deliberately kept out of the fix I'm about to open so the PR stays to one concern: a secondary instance also overwrites ~/.t3/userdata/server-runtime.json even while the recorded pid is still alive, which is what breaks t3 pair's own discovery afterwards. Happy to send that separately if it's wanted.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions