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