pingdotgg / pingdotgg/t3code

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

Open
#10,363 0 comments 0 reactions 0 assignees View on GitHub

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.

  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.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.