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, isTailscaleRunning,
isTailscaleRunningStrict, isTailscaleRunningStrict,
isTailscaleLoggedIn, isTailscaleLoggedIn,
isTailscaleLoggedInStrict,
installTailscale, installTailscale,
startLogin, startLogin,
startDaemonWithPassword, startDaemonWithPassword,

View File

@@ -1,5 +1,5 @@
import { loadState, generateShortId } from "../shared/state.js"; 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 { waitForHealth } from "./healthCheck.js";
import { getSettings, updateSettings } from "@/lib/localDb"; import { getSettings, updateSettings } from "@/lib/localDb";
import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager"; import { getCachedPassword, loadEncryptedPassword, initDbHooks } from "@/mitm/manager";
@@ -37,7 +37,7 @@ export async function enableTailscale(localPort = 20128) {
const shortId = existing?.shortId || generateShortId(); const shortId = existing?.shortId || generateShortId();
const tsHostname = shortId; const tsHostname = shortId;
const loggedIn = isTailscaleLoggedIn(); const loggedIn = await isTailscaleLoggedInStrict();
console.log(`[Tailscale] loggedIn=${loggedIn}`); console.log(`[Tailscale] loggedIn=${loggedIn}`);
if (!loggedIn) { if (!loggedIn) {
const loginResult = await startLogin(tsHostname); 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 // 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?)"); console.error("[Tailscale] strict probe failed (device removed?)");
stopFunnel(); stopFunnel();
return { success: false, error: "Tailscale not connected. Device may have been removed. Please re-login." }; 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 binCache = { value: undefined, fetchedAt: 0, refreshing: false };
const runningCache = { value: false, 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 }; const funnelUrlCache = { value: null, port: null, fetchedAt: 0, refreshing: false };
function fallbackBin() { function fallbackBin() {
@@ -85,24 +86,56 @@ function tsArgs(...args) {
return [...SOCKET_FLAG, ...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(); const bin = getTailscaleBin();
if (!bin) return false; if (!bin) return false;
try { try {
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, { const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} status --json`, {
encoding: "utf8",
windowsHide: true, windowsHide: true,
env: { ...process.env, PATH: EXTENDED_PATH }, env: { ...process.env, PATH: EXTENDED_PATH },
timeout: 5000 timeout: 5000
}); });
const json = JSON.parse(out); const json = JSON.parse(stdout);
// BackendState=Running + Self.Online=true → device still exists in tailnet // BackendState=Running + Self.Online=true → device still exists in tailnet
return json.BackendState === "Running" && json.Self?.Online === true; const loggedIn = json.BackendState === "Running" && json.Self?.Online === true;
} catch (e) { loggedInCache.value = loggedIn;
loggedInCache.fetchedAt = Date.now();
return loggedIn;
} catch {
return false; 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() { function bgRefreshRunning() {
if (runningCache.refreshing) return; if (runningCache.refreshing) return;
const bin = getTailscaleBin(); const bin = getTailscaleBin();
@@ -132,19 +165,17 @@ export function isTailscaleRunning() {
return runningCache.value; return runningCache.value;
} }
// Synchronous strict probe for hot user-initiated paths (enable/connect flow). // Async strict probe for hot user-initiated paths (enable/connect flow).
// Blocks ~PROBE_TIMEOUT_MS at most; updates cache as a side effect. // Awaitable, never blocks event loop; updates cache as a side effect.
export function isTailscaleRunningStrict() { export async function isTailscaleRunningStrict() {
const bin = getTailscaleBin(); const bin = getTailscaleBin();
if (!bin) return false; if (!bin) return false;
try { try {
const out = execSync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, { const { stdout } = await execAsync(`"${bin}" ${SOCKET_FLAG.join(" ")} funnel status --json`, {
encoding: "utf8",
windowsHide: true, windowsHide: true,
stdio: ["ignore", "pipe", "ignore"],
timeout: PROBE_TIMEOUT_MS, timeout: PROBE_TIMEOUT_MS,
}); });
const json = JSON.parse(out); const json = JSON.parse(stdout);
const running = Object.keys(json.AllowFunnel || {}).length > 0; const running = Object.keys(json.AllowFunnel || {}).length > 0;
runningCache.value = running; runningCache.value = running;
runningCache.fetchedAt = Date.now(); 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). // 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. // 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; if (running) return;
const force = FORCE_RESTART_REASONS.test(reason); const force = FORCE_RESTART_REASONS.test(reason);