Skip to content

fix(desktop): surface Tailscale Serve failures instead of failing silently - #4919

Open
GenKerensky wants to merge 1 commit into
pingdotgg:mainfrom
GenKerensky:fix/tailscale-serve-error-surfacing
Open

fix(desktop): surface Tailscale Serve failures instead of failing silently#4919
GenKerensky wants to merge 1 commit into
pingdotgg:mainfrom
GenKerensky:fix/tailscale-serve-error-surfacing

Conversation

@GenKerensky

@GenKerensky GenKerensky commented Jul 30, 2026

Copy link
Copy Markdown

Closes #2830.

What Changed

Enabling Tailscale HTTPS from desktop settings now reports failure instead of silently doing nothing.

tailscale serve was configured by the child backend on boot. When the tailnet refused it, the failure landed in a log line the user never sees — the app restarted and the toggle simply stayed off. Enabling now preflights tailscale serve in the main process before persisting, so it can fail with the CLI's own guidance and the login.tailscale.com/f/serve admin link. The failure surfaces three ways: a toast, an inline alert inside the setup dialog (which stays open so you can retry), and a status line on the Tailscale HTTPS row. The setup URL is shown in full and is selectable, with an Open setup button as a shortcut.

Related fixes in the same path:

  • Disable tears Serve down from the main process. It previously relied on the child server's acquireRelease finalizer, which only exists if that child booted with Serve enabled. After a failed relaunch it doesn't, so toggling off persisted enabled: false while tailscale serve --https=<port> stayed live on the tailnet. A teardown that fails now refuses to record itself as done, and says the backend may still be reachable.
  • serve off against an already-unbound port counts as success. The real CLI exits non-zero with "handler does not exist"; the tailnet is already in the state the user asked for, and treating it as failure would strand the toggle on and warn about an exposure that does not exist — biting hardest in exactly the case the eager teardown exists for.
  • The switch reflects the persisted setting, not endpoint reachability. status came from an HTTP probe of the MagicDNS URL, so a provisioning cert or a brief tailnet blip rendered the switch off while Serve was live — and since disabling sits behind the on state, there was then no way to turn it off. The same reasoning applies to the Authorized clients section, which was gated on the same probe.
  • The toggle renders without LAN network access. Serve only proxies to loopback, so it never depended on network-accessible mode; it was hidden anyway. MagicDNS is still resolved only on explicit opt-in, preserving the fix(desktop): stop looping macOS TCC permission prompts #2745 macOS TCC gate.
  • Failed CLI spawns report "Could not run the tailscale CLI. Is Tailscale installed and on PATH?" rather than a generic port message — the most common failure, previously the least explained.
  • A persistence failure no longer tears down a pre-existing working binding; rollback covers interrupt and defect rather than only typed failure; and the MagicDNS cache is invalidated on user-initiated resolve, so "start Tailscale and try again" isn't defeated by a stale 60s miss.

Why

The bug this fixes is silence, not the underlying tailnet refusal. A user with Serve disabled on their tailnet flips the toggle, the app restarts, and the toggle is off again with no message — nothing distinguishes "your tailnet blocks this" from "the app is broken." #2830 describes exactly that: the screen hangs or the app restarts "with seemingly no effect," and "no visible errors were seen. (This is the key issue)."

Two design notes for review:

  • Preflight before persisting. Writing settings first and letting the child fail on boot is what produced the silent failure; the CLI call has to happen where a user-visible error can still be raised. Rollback exists because the ordering can now leave Serve live if the settings write fails afterwards.
  • Only a label, never CLI text, crosses the error boundary. tailscale prints auth keys and node names to stderr, and these errors are logged, so failures are classified into a closed set of labels and the prose lives at the UI edge. The one exception is the admin URL, and it is rebuilt from a literal origin and path keeping only an allowlisted node parameter — validating the parsed host and path alone would let ?authkey=tskey-… ride through into a logged message.

UI Changes

Affects the Tailscale HTTPS row in Settings → Connections:

  • The switch is always rendered (previously hidden without a resolved endpoint) and is held disabled until exposure state loads, with a spinner beside it while the CLI call is in flight.
  • On failure, an inline alert appears inside the setup dialog — which no longer closes — containing the CLI's guidance, the full selectable admin URL, and an Open setup action; plus a destructive-colored status line under the row title, and a toast.

Note

Screenshots are being attached separately — I can't upload images through the API. The behaviour above was verified against a live tailnet; see Testing.

Testing

Unit tests cover the new behaviour: eager teardown asserts the exact serve --https=<port> off invocation, a failed teardown keeps enabled: true and carries the exposure warning, the missing-binary case produces the PATH hint, no-existing-handler is tolerated, and the configure URL drops non-allowlisted query parameters.

Verified end-to-end by driving the built desktop app against a live tailnet:

  • Tailscale absent from PATH (a PATH farm without the binary) → toast, toggle stays off.
  • Tailnet refuses Serve (a shim refusing serve) → dialog stays open with the inline alert, full URL, and Open setup; error anchored to the Tailscale HTTPS row rather than the LAN row.
  • Happy path → enable put https://<magicdns> → proxy http://127.0.0.1:<port> in tailscale serve status, the endpoint returned HTTP 200, and the app came back with the toggle on; disable returned No serve config with the endpoint unreachable and the toggle off.
  • openExternal confirmed to resolve true/false rather than reject, which is why the previous .catch() fallback was dead code.

Checklist

  • This PR is small and focused
  • I explained what changed and why
  • I included before/after screenshots for any UI changes — attaching separately, see UI Changes
  • I included a video for animation/interaction changes — attaching separately

Note

High Risk
Changes ordering of Tailscale Serve vs persisted settings and main-process network exposure teardown/rollback; mistakes could leave the tailnet exposed or block legitimate disable. Touches auth-adjacent exposure and sanitized CLI error boundaries.

Overview
Fixes silent Tailscale HTTPS toggles (#2830) by preflighting tailscale serve in the desktop main process before persisting, so enable/disable can fail with actionable errors (CLI diagnostics, sanitized login.tailscale.com/f/serve link) instead of restarting with the switch still off.

Exposure lifecycle: disable now runs serve off in the main process (not only the child finalizer); failed teardown does not persist enabled: false; no-existing-handler on serve off is treated as success; rollback on failed settings writes (including interrupt/defect via onExit) reverses preflight enable or re-binds after a failed disable.

UI / IPC: new resolveTailscaleHttpsEndpoint (on-demand tailscale status, cache invalidated on user action); Connections Tailscale HTTPS switch follows persisted settings, works without LAN network access, shows spinner/errors inline and in dialogs; isShareableOrigin stops offering pairing URLs for custom schemes like t3code-dev://.

@t3tools/tailscale: classified stderr labels (serve-not-enabled, no-https-certs), safe configure URL extraction, and shared user-facing CLI messages.

Reviewed by Cursor Bugbot for commit 3317caa. Bugbot is set up for automated code reviews on this repo. Configure here.

Note

Surface Tailscale Serve failures with actionable errors and rollback in desktop server exposure

  • setTailscaleServeEnabled now preflights tailscale serve before persisting settings, maps CLI failures (exit errors, spawn errors) to a structured DesktopTailscaleServeConfigureError with a user-safe message and optional admin configureUrl.
  • On persistence failure, best-effort rollback reverts the CLI state (disables if enable preflighted, re-enables if disable succeeded) to keep exposure in sync with settings.
  • A new resolveTailscaleHttpsEndpoint method and IPC channel (desktop:resolve-tailscale-https-endpoint) allows the renderer to request a fresh MagicDNS resolution on demand.
  • The Connections Settings UI surfaces inline error alerts with an optional 'Open setup' button linking to the Tailscale admin page, and the Tailscale switch now reflects the persisted tailscaleServeEnabled flag.
  • Two new stderr diagnostics (serve-not-enabled, no-https-certs) are classified in @t3tools/tailscale, and TailscaleCommandExitError now carries a sanitized configureUrl when present in CLI output.
  • Behavioral Change: enabling Tailscale Serve always sets requiresRelaunch=true, even when settings are unchanged, so the child process re-binds Serve.
📊 Macroscope summarized 3317caa. 11 files reviewed, 0 issues evaluated, 0 issues filtered, 0 comments posted

🗂️ Filtered Issues

No issues evaluated.

…ently

`tailscale serve` was configured by the child backend on boot. When the tailnet
refused — Serve not enabled, no HTTPS certs, CLI missing — the failure landed in
a log line the user never sees: the app restarted and the toggle simply stayed
off, with nothing explaining why.

Enabling now preflights `tailscale serve` in the main process *before*
persisting, so it can fail loudly with the CLI's own guidance and the
`login.tailscale.com/f/serve` admin link. The failure surfaces three ways: a
toast, an inline alert inside the setup dialog (which stays open so the user can
retry), and a status line on the row. The URL is shown in full and selectable,
with an "Open setup" button as a shortcut.

Related fixes in the same path:

- Disable tears Serve down from the main process. It previously relied on the
  child server's `acquireRelease` finalizer, which only exists if *that* child
  booted with Serve enabled — so after a failed relaunch, toggling off persisted
  `enabled: false` while `tailscale serve --https=<port>` stayed live on the
  tailnet. A teardown that fails now refuses to record itself as done and says
  the backend may still be reachable. `serve off` against an already-unbound
  port is treated as success, verified against the real CLI.
- The switch reflects the persisted setting, not endpoint reachability. `status`
  came from an HTTP probe of the MagicDNS URL, so a provisioning cert or a brief
  tailnet blip rendered the switch off while Serve was live — and since
  disabling sits behind the on state, there was then no way to turn it off. The
  same reasoning applies to the Authorized clients section.
- The toggle renders without LAN network access. Serve only proxies to
  loopback, so it never depended on network-accessible mode. MagicDNS is still
  resolved only on explicit opt-in, preserving the pingdotgg#2745 macOS TCC gate.
- Failed CLI spawns report "Could not run the tailscale CLI. Is Tailscale
  installed and on PATH?" instead of a generic port message.
- A persistence failure no longer tears down a pre-existing working binding,
  rollback covers interrupt and defect rather than only typed failure, and the
  MagicDNS cache is invalidated on user-initiated resolve so "start Tailscale
  and try again" is not defeated by a stale 60s miss.

The configure URL is rebuilt from a literal origin and path with only an
allowlisted `node` parameter, so no text lifted from stderr can reach the logged
error message.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014UZ1f7q6ZVbn6uqWgWRzh9
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: CHILL

Plan: Pro Plus

Run ID: ccfe9432-c938-451c-9bd7-e35e75473fbe

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions github-actions Bot added vouch:unvouched PR author is not yet trusted in the VOUCHED list. size:XL 500-999 changed lines (additions + deletions). labels Jul 30, 2026
// skipping the rollback on those exits is exactly the silent-exposure
// case this whole path exists to prevent. Best-effort, since the
// original persistence failure is what the caller needs to see.
Effect.onExit((exit) => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 High backend/DesktopServerExposure.ts:717

Both rollback paths in setTailscaleServeEnabled swallow their error with Effect.ignore, so after a successful tailnet change and a failed settings write, the method returns only DesktopTailscaleServePersistenceError while the tailnet is left in the wrong state. On the enable side, if disableTailscaleServe rollback fails, the new HTTPS binding stays live on the tailnet but settings/UI show Serve disabled — the exact silent exposure this code exists to prevent. On the disable side, if ensureTailscaleServe rollback fails, the HTTPS endpoint is gone but settings and stateRef still say enabled, leaving runtime state inconsistent with persisted/UI state. In both cases the caller cannot detect the rollback failure. Consider logging or attaching a warning so the exposure/inconsistency is surfaced rather than silently dropped.

🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/desktop/src/backend/DesktopServerExposure.ts around line 717:

Both rollback paths in `setTailscaleServeEnabled` swallow their error with `Effect.ignore`, so after a successful tailnet change and a failed settings write, the method returns only `DesktopTailscaleServePersistenceError` while the tailnet is left in the wrong state. On the enable side, if `disableTailscaleServe` rollback fails, the new HTTPS binding stays live on the tailnet but settings/UI show Serve disabled — the exact silent exposure this code exists to prevent. On the disable side, if `ensureTailscaleServe` rollback fails, the HTTPS endpoint is gone but settings and `stateRef` still say enabled, leaving runtime state inconsistent with persisted/UI state. In both cases the caller cannot detect the rollback failure. Consider logging or attaching a warning so the exposure/inconsistency is surfaced rather than silently dropped.

@macroscopeapp

macroscopeapp Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Approvability

Verdict: Needs human review

1 blocking correctness issue found. This PR introduces significant new error handling, rollback logic, and IPC infrastructure for Tailscale Serve configuration. An unresolved review comment identifies that rollback failures are silently ignored, potentially leaving HTTPS exposure in an inconsistent state with settings - warranting human review.

You can customize Macroscope's approvability policy. Learn more.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size:XL 500-999 changed lines (additions + deletions). vouch:unvouched PR author is not yet trusted in the VOUCHED list.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Tailscale HTTPS - Show the "Serve is not enabled on your tailnet." error in GUI.

1 participant