fix(tunnel): make tailscale probes non-blocking to prevent UI freeze

Convert isTailscaleLoggedIn to cached non-blocking getter and turn
isTailscaleRunningStrict / isTailscaleLoggedInStrict into async execAsync
probes. The status poll no longer blocks the event loop, so dashboard
navigation stays responsive while tunnel/tailscale checks run.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
decolua
2026-06-08 09:32:29 +07:00
parent c5815ad3f0
commit 289214a2ea
4 changed files with 49 additions and 17 deletions

View File

@@ -29,6 +29,7 @@ export {
isTailscaleRunning,
isTailscaleRunningStrict,
isTailscaleLoggedIn,
isTailscaleLoggedInStrict,
installTailscale,
startLogin,
startDaemonWithPassword,

View File

@@ -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." };

View File

@@ -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();

View File

@@ -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);