decolua/9router · info · Error

tailscale cancelled

Error message

tailscale cancelled

What it means

The tailscale manager's throwIfCancelled() throws 'tailscale cancelled' when the service cancelToken is cancelled at a checkpoint inside enableTailscale(). It aborts in-progress tailscale setup (auth, spawn, health wait) promptly when the user disables the tunnel. This is cooperative cancellation, not a functional failure.

Source

Thrown at src/lib/tunnel/tailscale/manager.js:20

import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, isTailscaleLoggedInStrict, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js";
import { waitForHealth } from "./healthCheck.js";
import { getSettings, updateSettings } from "@/lib/localDb";
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";

initDbHooks(getSettings, updateSettings);

const svc = {
  cancelToken: { cancelled: false },
  spawnInProgress: false,
  lastRestartAt: 0,
  activeLocalPort: null,
};

export function getTailscaleService() { return svc; }
export function isTailscaleReconnecting() { return svc.spawnInProgress; }

function throwIfCancelled(token) {
  if (token.cancelled) throw new Error("tailscale cancelled");
}

export async function enableTailscale(localPort = 20128) {
  console.log(`[Tailscale] enable start (port=${localPort})`);
  svc.cancelToken = { cancelled: false };
  svc.activeLocalPort = localPort;
  svc.spawnInProgress = true;
  const token = svc.cancelToken;

  try {
    const sudoPass = getCachedPassword() || await loadEncryptedPassword() || "";
    await startDaemonWithPassword(sudoPass);
    console.log("[Tailscale] daemon ready");
    throwIfCancelled(token);

    const existing = loadState();
    const shortId = existing?.shortId || generateShortId();
    const tsHostname = shortId;

View on GitHub (pinned to 90b52e06ff)

Solutions

  1. Catch message === 'tailscale cancelled' in callers and treat as a normal abort.
  2. Create a fresh cancelToken at the start of each enableTailscale() call.
  3. Serialize enable/disable/reconnect operations so they don't interleave on svc.cancelToken.
  4. Inspect svc state (getTailscaleService(), isTailscaleReconnecting()) before re-enabling after a cancellation.

Example fix

// before
await enableTailscale(20128);
// after
try {
  await enableTailscale(20128);
} catch (e) {
  if (e.message === "tailscale cancelled") return;
  throw e;
}
Defensive patterns

Strategy: try-catch

Validate before calling

if (getTailscaleService().cancelToken?.cancelled) return; // cancel pending — don't enable

Type guard

const isTailscaleCancel = (e) => e instanceof Error && e.message === "tailscale cancelled";

Try / catch

try {
  await enableTailscale(port);
} catch (e) {
  if (isTailscaleCancel(e)) return; // expected abort
  throw e;
}

Prevention

When it happens

Trigger: disableTailscale() (or reconnect logic) sets svc.cancelToken.cancelled = true while enableTailscale() is still running; the next throwIfCancelled() checkpoint throws. Also occurs when a stale cancelled token is carried into a new enable attempt.

Common situations: Rapid off/on toggling of the tailscale tunnel in the dashboard; a reconnect cycle cancelling the original enable; concurrent enable/disable calls racing on the shared service token.

Related errors


AI-assisted analysis of decolua/9router@90b52e06ff (2026-08-30). Data as JSON: /api/errors/c476628d7958aca6. Report an issue: GitHub.