Repository navigation
[Bug]: Tailscale Serve mapping is hijacked by a secondary instance and left stale on unclean exit, never reconciled #10363
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 502Workaround
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
bermudi commentedon Oct 15, 2026
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 athttps://<host>.<tailnet>.ts.net/. The root Serve mapping was replaced with T3's backend at127.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
e536bfd33bc3c351997141d93790df75eeded7b9still has:The real Tailscale 1.104.1 CLI was exercised against a private Unix-socket LocalAPI fixture, using an explicit
--socketon 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.serve --bg --https=443 http://127.0.0.1:3773/keep/and:8788preserved.serve --https=443 offnoninteractively/keep/deleted; the :443 listener removed.:8788preserved.serve --https=443 --set-path=/ offNo
--yeswas supplied. Tailscale's removal logic selects all handlers when--set-pathis 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
Jacksondr5 commentedon Oct 18, 2026
The dev runner hits the same overwrite. Reproduced on
mainat 16918b5 (Linux, Tailscale serve):tailscale serve --bg --https=7733 http://127.0.0.1:9555. tailscaled binds that listener only on the tailnet addresses (ss -ltnshows100.x:7733and[fd7a:…]:7733ontailscale0, nothing on loopback).T3CODE_PORT_OFFSET=2000 vp run dev --dry-runreportswebPort=7733. The availability probe checks only127.0.0.1and::1(DEV_PORT_PROBE_HOSTS), so the published port looks free. With a listener on127.0.0.1:7733, it moves to 7734 as expected.T3CODE_PORT_OFFSET=2000 vp run dev --share: the 7733 handler changes fromhttp://127.0.0.1:9555tohttp://localhost:7733, and the tailnet URL serves T3 Code instead of the other server.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 unreadableserve statusas 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.
juliusmarminge commentedon Oct 22, 2026
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:
24463 → http://127.0.0.1:24461.t3 serve --port 24462 --base-dir <separate-home> --tailscale-serve --tailscale-serve-port 24463.tailscale serve status --jsonchanged the target tohttp://127.0.0.1:24462while 24461 remained live.ServerAlreadyRunningErrorbefore 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.
Jacksondr5 commentedon Oct 24, 2026
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-servebehaviour for you to decide and offer only the small, dev-tooling half.The two paths share
ensureTailscaleServeanddisableTailscaleServe, 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 (theclear-existingstage), 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:localhost:<webPort>. A stale mapping from an earlier dev run on the same port still points there, so it is still cleared.tailscale serve statusfails, rather than treat it as empty.The change would stay in
scripts/lib/dev-share.ts, with tests, and wouldn't touchpackages/tailscaleor the server. Separately, the port probe checks only loopback (DEV_PORT_PROBE_HOSTS), so a port Tailscale is serving looks free. With the guard,--sharerefuses 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.
Joao-kouznetz commentedon Oct 30, 2026
A fresh instance of this stale Serve mapping occurred on a Linux boot-service host after an in-place T3 server update (2026-10-30):
/->http://127.0.0.1:38591. The old port had no listener./.well-known/t3/environmentthrough the tailnet URL returned HTTP 502, while the same path on127.0.0.1:45183returned HTTP 200 with protocol v2.Remote environment endpoint ... returned undeclared status 502. The managed T3 Connect relay itself was healthy.T3CODE_PORT=45183in the service unit so subsequent restarts use the published port.This host had a manually created Serve mapping and had not enabled
T3CODE_TAILSCALE_SERVE. That makes this a port-change/reconciliation case even with a single current server, separate from a second server hijacking the handler. It may be useful to include stale Serve target detection int3 doctoror connection diagnostics. I saw #26757 includes Serve retry/ownership work, but this manually managed mapping may still need a diagnostic.