Before submitting
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'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.
- Its backend finds 3773 busy and scans up to 3774 (
apps/desktop/src/app/DesktopApp.ts).
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.
- 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 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.
Before submitting
Area
apps/server
Steps to reproduce
Tailscale Serve is enabled (
tailscaleServeEnabled, serve port10010in my case), and the desktop app is running normally — backend onDEFAULT_DESKTOP_BACKEND_PORT(3773), andhttps://<machine>.<tailnet>.ts.net:10010reaches 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.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.window-all-closed; the instance lingers headless and (in both occurrences here) eventually exited without the release finalizer running.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 onmain@223ff44) writes it unconditionally in theacquireReleaseacquire and removes it unconditionally in release:ensureTailscaleServe({ localPort, servePort, localHost: "127.0.0.1" })with no check of what is already there. Whoever starts last wins the slot.endpoint returned undeclared status 502from Tailscale.disableTailscaleServe({ servePort })would have removed the mapping outright — also breaking the primary, which by then owned nothing.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.tsalready has exactly the guard the server layer is missing: it probes/.well-known/t3/environmentthrough 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/electron0.0.37, tailscale CLI on the hostLogs or stack traces
Workaround
Re-run
t3 pair --tailscalefrom the surviving instance (ortailscale serve --bg --https=<servePort> http://127.0.0.1:3773by hand) to repoint the mapping. Ifserver-runtime.jsonstill 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.jsoneven while the recorded pid is still alive, which is what breakst3 pair's own discovery afterwards. Happy to send that separately if it's wanted.