diff --git a/src/lib/tunnel/index.js b/src/lib/tunnel/index.js index e6f0c434..4bff3658 100644 --- a/src/lib/tunnel/index.js +++ b/src/lib/tunnel/index.js @@ -29,6 +29,7 @@ export { isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, + isTailscaleLoggedInStrict, installTailscale, startLogin, startDaemonWithPassword, diff --git a/src/lib/tunnel/tailscale/manager.js b/src/lib/tunnel/tailscale/manager.js index 4415f759..7fe40e7e 100644 --- a/src/lib/tunnel/tailscale/manager.js +++ b/src/lib/tunnel/tailscale/manager.js @@ -1,5 +1,5 @@ import { loadState, generateShortId } from "../shared/state.js"; -import { startFunnel, stopFunnel, isTailscaleRunning, isTailscaleRunningStrict, isTailscaleLoggedIn, startLogin, startDaemonWithPassword, provisionCert } from "./tailscale.js"; +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"; @@ -37,7 +37,7 @@ export async function enableTailscale(localPort = 20128) { const shortId = existing?.shortId || generateShortId(); const tsHostname = shortId; - const loggedIn = isTailscaleLoggedIn(); + const loggedIn = await isTailscaleLoggedInStrict(); console.log(`[Tailscale] loggedIn=${loggedIn}`); if (!loggedIn) { const loginResult = await startLogin(tsHostname); @@ -72,7 +72,7 @@ export async function enableTailscale(localPort = 20128) { } // Strict probe: bypass cache so we don't false-negative on first invocation - if (!isTailscaleLoggedIn() || !isTailscaleRunningStrict()) { + if (!(await isTailscaleLoggedInStrict()) || !(await isTailscaleRunningStrict())) { console.error("[Tailscale] strict probe failed (device removed?)"); stopFunnel(); return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." }; diff --git a/src/lib/tunnel/tailscale/tailscale.js b/src/lib/tunnel/tailscale/tailscale.js index bd636c37..0f3391a6 100644 --- a/src/lib/tunnel/tailscale/tailscale.js +++ b/src/lib/tunnel/tailscale/tailscale.js @@ -36,6 +36,7 @@ const PROBE_TIMEOUT_MS = 1500; const binCache = { value: undefined, fetchedAt: 0, refreshing: false }; const runningCache = { value: false, fetchedAt: 0, refreshing: false }; +const loggedInCache = { value: false, fetchedAt: 0, refreshing: false }; const funnelUrlCache = { value: null, port: null, fetchedAt: 0, refreshing: false }; function fallbackBin() { @@ -85,24 +86,56 @@ function tsArgs(...args) { return [...SOCKET_FLAG, ...args]; } -export function isTailscaleLoggedIn() { +// Async strict probe: authoritative, awaitable (never blocks event loop). Updates cache. +export async function isTailscaleLoggedInStrict() { const bin = getTailscaleBin(); if (!bin) return false; try { - const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { - encoding: "utf8", + const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: 5000 }); - const json = JSON.parse(out); + const json = JSON.parse(stdout); // BackendState=Running + Self.Online=true → device still exists in tailnet - return json.BackendState === "Running" && json.Self?.Online === true; - } catch (e) { + const loggedIn = json.BackendState === "Running" && json.Self?.Online === true; + loggedInCache.value = loggedIn; + loggedInCache.fetchedAt = Date.now(); + return loggedIn; + } catch { return false; } } +function bgRefreshLoggedIn() { + if (loggedInCache.refreshing) return; + const bin = getTailscaleBin(); + if (!bin) { + loggedInCache.value = false; + loggedInCache.fetchedAt = Date.now(); + return; + } + loggedInCache.refreshing = true; + execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { windowsHide: true, env: { ...process.env, PATH: EXTENDED_PATH }, timeout: PROBE_TIMEOUT_MS }) + .then(({ stdout }) => { + try { + const json = JSON.parse(stdout); + loggedInCache.value = json.BackendState === "Running" && json.Self?.Online === true; + } catch { loggedInCache.value = false; } + }) + .catch(() => { loggedInCache.value = false; }) + .finally(() => { + loggedInCache.fetchedAt = Date.now(); + loggedInCache.refreshing = false; + }); +} + +// Sync getter: never blocks; returns last known state, refreshes in background +export function isTailscaleLoggedIn() { + if (Date.now() - loggedInCache.fetchedAt > PROBE_TTL_MS) bgRefreshLoggedIn(); + return loggedInCache.value; +} + function bgRefreshRunning() { if (runningCache.refreshing) return; const bin = getTailscaleBin(); @@ -132,19 +165,17 @@ export function isTailscaleRunning() { return runningCache.value; } -// Synchronous strict probe for hot user-initiated paths (enable/connect flow). -// Blocks ~PROBE_TIMEOUT_MS at most; updates cache as a side effect. -export function isTailscaleRunningStrict() { +// Async strict probe for hot user-initiated paths (enable/connect flow). +// Awaitable, never blocks event loop; updates cache as a side effect. +export async function isTailscaleRunningStrict() { const bin = getTailscaleBin(); if (!bin) return false; try { - const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { - encoding: "utf8", + const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { windowsHide: true, - stdio: ["ignore", "pipe", "ignore"], timeout: PROBE_TIMEOUT_MS, }); - const json = JSON.parse(out); + const json = JSON.parse(stdout); const running = Object.keys(json.AllowFunnel || {}).length > 0; runningCache.value = running; runningCache.fetchedAt = Date.now(); diff --git a/src/shared/services/initializeApp.js b/src/shared/services/initializeApp.js index a0574897..da12becf 100644 --- a/src/shared/services/initializeApp.js +++ b/src/shared/services/initializeApp.js @@ -173,7 +173,7 @@ async function safeRestartTailscale(reason) { // Tailscale daemon is OS-level with built-in reconnect; trust it when running (even on netchange). // Startup uses strict probe — cached state is cold after process/dev reload. - const running = reason === "startup" ? isTailscaleRunningStrict() : isTailscaleRunning(); + const running = reason === "startup" ? await isTailscaleRunningStrict() : isTailscaleRunning(); if (running) return; const force = FORCE_RESTART_REASONS.test(reason);