[Bug]: Tailscale Serve mapping is hijacked by a secondary instance and left stale on unclean exit, never reconciled
Nobody has claimed this yet.
- Dominant language
- TypeScript
- Stars
- 23k
- Forks
- 5.9k
- Avg merge
- 11h 14m
- Merged PRs (30d)
- 357
Description
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.
open -n -a "T3 Code (Alpha)"on macOS. A second instance starts, because@clerk/electron'screateClerkBridgeskipsrequestSingleInstanceLock()on darwin, sobridge.isPrimaryInstanceis alwaystruethere and thesecond-instancepath inapps/desktop/src/app/DesktopClerk.tsnever fires.- Its backend finds 3773 busy and scans up to 3774 (
apps/desktop/src/app/DesktopApp.ts). tailscale serve statusnow shows the shared serve port pointing athttp://127.0.0.1:3774— the healthy primary on 3773 has been silently evicted from the tailnet URL.~/.t3/userdata/server-runtime.jsonhas also been overwritten with the second instance's pid/port.- 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. - 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 502from 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 isEffect.catch → Effect.logWarning, so even a hard failure is invisible unless you are reading server logs. The only self-heal in the codebase isresolveTailscalePairingBaseinapps/server/src/cli/pair.ts, and that is manual (t3 pair) — and it is itself blocked here, becausediscoverPairTargetskips the dead pid recorded in the clobberedserver-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.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Read apps/server/src/server.ts around acquireRelease (L619–671), then compare its Tailscale Serve handling with the guard in apps/server/src/cli/pair.ts. Reproduce the two-instance flow and inspect tailscale serve status; done means a live existing mapping is preserved and stale mappings can be recovered without removing another server’s mapping.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- electron, typescript
- Domain
- backend, infrastructure
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100