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:
@@ -29,6 +29,7 @@ export {
|
||||
isTailscaleRunning,
|
||||
isTailscaleRunningStrict,
|
||||
isTailscaleLoggedIn,
|
||||
isTailscaleLoggedInStrict,
|
||||
installTailscale,
|
||||
startLogin,
|
||||
startDaemonWithPassword,
|
||||
|
||||
@@ -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." };
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user