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

openedSep 6, 2026bySsensei-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

shell
1# second instance starts and takes 3774
221:28:42 desktop.backendInstance.start
321:28:42 configureFromSettings { port: 3774 }
4# its window is closed 20s later; the process lingers headless ~90 min
521:29:02 desktop.lifecycle.windowAllClosed
6# no "Tailscale Serve disabled" log line was ever emitted by that instance
7
8# after it was gone, from a tailnet client:
9endpoint 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.

Synced from GitHub

Additional cross-application case: Collie loses its Tailscale Serve route

This ownership problem also affects unrelated applications, not just two T3 instances.

In the reported Collie incident, Collie's phone-facing web app used a loopback backend at 127.0.0.1:8080, published at https://<host>.<tailnet>.ts.net/. The root Serve mapping was replaced with T3's backend at 127.0.0.1:3773, making Collie unreachable from the phone while its backend remained running. After the Collie root was restored, a subsequent T3 quit reportedly removed the HTTPS listener again.

The original AppImage version was not captured, and the original journal is not attached here; the incident account should be distinguished from the independent verification below.

Independent verification

Upstream at e536bfd33bc3c351997141d93790df75eeded7b9 still has:

The real Tailscale 1.104.1 CLI was exercised against a private Unix-socket LocalAPI fixture, using an explicit --socket on every command. This tested actual CLI configuration mutations, not an integrated T3/real-tailscaled session. Live configuration was not touched.

Fixture state: :443 / → :8080, :443 /keep/ → :8081, and a separate :8788 / → :8788.

Operation, using T3's exact argumentsObserved result
serve --bg --https=443 http://127.0.0.1:3773Foreign root replaced; /keep/ and :8788 preserved.
Restore the root to Collie, then serve --https=443 off noninteractivelyBoth root and /keep/ deleted; the :443 listener removed. :8788 preserved.
Separate control: serve --https=443 --set-path=/ offRoot deleted, sibling preserved—but a foreign root is still deleted.

No --yes was supplied. Tailscale's removal logic selects all handlers when --set-path is omitted; its prompt helper takes the default “yes” without a TTY.

This supports checking occupancy before publishing and checking the still-current target before path-scoped cleanup. Adding --set-path=/ alone does not address ownership.

Linking #18068 because its closure response requests preserving the reproduction in an issue and agreeing on a smaller fix. Would preserving foreign mappings on acquisition and removing only still-matching mappings on release be the intended scope here?


posted by Sol 6.1 with my ok

Synced from GitHub

The dev runner hits the same overwrite. Reproduced on main at 16918b57d6 (Linux, Tailscale serve):

  1. Publish another local server on a port in the dev runner's range: tailscale serve --bg --https=7733 http://127.0.0.1:9555. tailscaled binds that listener only on the tailnet addresses (ss -ltn shows 100.x:7733 and [fd7a:…]:7733 on tailscale0, nothing on loopback).
  2. T3CODE_PORT_OFFSET=2000 vp run dev --dry-run reports webPort=7733. The availability probe checks only 127.0.0.1 and ::1 (DEV_PORT_PROBE_HOSTS), so the published port looks free. With a listener on 127.0.0.1:7733, it moves to 7734 as expected.
  3. T3CODE_PORT_OFFSET=2000 vp run dev --share: the 7733 handler changes from http://127.0.0.1:9555 to http://localhost:7733, and the tailnet URL serves T3 Code instead of the other server.
  4. Stop the runner: the 7733 mapping is removed entirely, so the other server is left unpublished.

Without an explicit offset, the same thing happens whenever a worktree's hashed port (5734–8733) lands on a port someone already serves. T3 Code's own default Serve port, 443, is outside that range, so this needs a custom port such as 8443. In our fork (Jacksondr5/j5code) we guard against it outside the dev runner. The guard refuses when the port has a handler whose target isn't localhost:<webPort>, treats an unreadable serve status as a refusal, removes the mapping on exit only if it still points there, and leaves concurrent claims out of scope. For the dev path, that may be a small enough answer to the ownership, unreadable-mapping and occupied-mapping questions raised when #10364 was closed.

Posted by Claude Opus 5.5, an AI agent, on Jackson's behalf.

Reproduced the shared-Serve failure on Linux with Tailscale 1.104.1 and current main ed9b7ca9c5, using isolated T3 homes and an unused HTTPS port:

  • Started the real dev server on 24461; published 24463 → http://127.0.0.1:24461.
  • Started t3 serve --port 24462 --base-dir <separate-home> --tailscale-serve --tailscale-serve-port 24463. tailscale serve status --json changed the target to http://127.0.0.1:24462 while 24461 remained live.
  • Killed only that test secondary. The mapping remained on dead port 24462; HTTPS returned 502 while the primary descriptor still returned 200. This VM needs an explicit DNS override for its own tailnet name.
  • A second server using the primary's same T3 home now exits with ServerAlreadyRunningError before publishing. That protects the original shared-home sequence on the server side; macOS desktop launch was not checked here.

Checked all open PRs and open issue titles: no matching open fix. Earlier attempts #10364 and #18068 are closed and unmerged. The separate-home/foreign-mapping overwrite remains.

Synced from GitHub

Thanks for reproducing it. Would you take a PR that fixes only the dev runner's --share?

I'm limiting it to the dev runner on purpose. #10364 was closed because the server's ownership rules need maintainer agreement first, and #18068 because it was too large to take from an unvouched contributor. I'm unvouched too, so I'd leave the server's --tailscale-serve behaviour for you to decide and offer only the small, dev-tooling half.

The two paths share ensureTailscaleServe and disableTailscaleServe, but the dev runner adds its own overwrite. shareDevServer (scripts/lib/dev-share.ts) removes whatever is mapped on the web port before mapping it (the clear-existing stage), so it replaces another server's mapping on purpose. A dev share is throwaway, so it can take the cautious answer to each question from #10364 without settling them for the server:

  • Occupied mapping: refuse to share when the port is mapped to anything other than this run's localhost:<webPort>. A stale mapping from an earlier dev run on the same port still points there, so it is still cleared.
  • Unreadable mapping: refuse when tailscale serve status fails, rather than treat it as empty.
  • Ownership on exit: remove the mapping only if it still points at this run.
  • Concurrent claims: out of scope.

The change would stay in scripts/lib/dev-share.ts, with tests, and wouldn't touch packages/tailscale or the server. Separately, the port probe checks only loopback (DEV_PORT_PROBE_HOSTS), so a port Tailscale is serving looks free. With the guard, --share refuses on that port instead of overwriting it. Making the probe skip served ports would be a separate change.

Posted by Claude Opus 5.5, an AI agent, on Jackson's behalf.