fix(tailscale): retry status json after serve startup (#91553)

* fix(tailscale): restrict status retries

* test(tailscale): cover retry eligibility

* refactor(tailscale): add status retry helper

* refactor(tailscale): split status retry helper

* fix(tailscale): scope status retry to serve startup

Co-authored-by: TUARAN <729922845@qq.com>

* fix(tailscale): recognize app service startup errors

Co-authored-by: TUARAN <729922845@qq.com>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
TUARAN
2026-07-21 23:43:56 +08:00
committed by GitHub
parent f9ede1e39a
commit 3a5b2764f8
4 changed files with 147 additions and 21 deletions
+8 -3
View File
@@ -8,6 +8,7 @@ const mocks = vi.hoisted(() => ({
enableTailscaleFunnel: vi.fn(async (_port: number) => undefined),
disableTailscaleFunnel: vi.fn(async () => undefined),
getTailnetHostname: vi.fn<() => Promise<string | null>>(async () => null),
getTailnetHostnameAfterServe: vi.fn<() => Promise<string | null>>(async () => null),
hasTailscaleFunnelRouteForPort: vi.fn(async (_port: number) => false),
}));
@@ -17,6 +18,7 @@ vi.mock("../infra/tailscale.js", () => ({
enableTailscaleFunnel: mocks.enableTailscaleFunnel,
disableTailscaleFunnel: mocks.disableTailscaleFunnel,
getTailnetHostname: mocks.getTailnetHostname,
getTailnetHostnameAfterServe: mocks.getTailnetHostnameAfterServe,
hasTailscaleFunnelRouteForPort: mocks.hasTailscaleFunnelRouteForPort,
}));
@@ -41,6 +43,7 @@ afterEach(() => {
mocks.enableTailscaleFunnel.mockResolvedValue(undefined);
mocks.disableTailscaleFunnel.mockResolvedValue(undefined);
mocks.getTailnetHostname.mockResolvedValue(null);
mocks.getTailnetHostnameAfterServe.mockResolvedValue(null);
mocks.hasTailscaleFunnelRouteForPort.mockResolvedValue(false);
});
@@ -55,6 +58,8 @@ describe("startGatewayTailscaleExposure preserveFunnel", () => {
});
expect(mocks.enableTailscaleServe).toHaveBeenCalledWith(18789);
expect(mocks.getTailnetHostnameAfterServe).toHaveBeenCalledOnce();
expect(mocks.getTailnetHostname).not.toHaveBeenCalled();
expect(mocks.hasTailscaleFunnelRouteForPort).not.toHaveBeenCalled();
});
@@ -113,7 +118,7 @@ describe("startGatewayTailscaleExposure preserveFunnel", () => {
it("passes serviceName through to Tailscale Serve setup and cleanup", async () => {
const logTailscale = createLogger();
mocks.getTailnetHostname.mockResolvedValue("node.tailnet.ts.net");
mocks.getTailnetHostnameAfterServe.mockResolvedValue("node.tailnet.ts.net");
const cleanup = await startGatewayTailscaleExposure({
tailscaleMode: "serve",
@@ -158,7 +163,7 @@ describe("startGatewayTailscaleExposure preserveFunnel", () => {
});
it("prepares one tailnet-only Serve origin for the Gateway lifecycle", async () => {
mocks.getTailnetHostname.mockResolvedValue("node.tailnet.ts.net");
mocks.getTailnetHostnameAfterServe.mockResolvedValue("node.tailnet.ts.net");
const cleanup = await startGatewayTailscaleExposure({
tailscaleMode: "serve",
@@ -202,7 +207,7 @@ describe("startGatewayTailscaleExposure preserveFunnel", () => {
["omits the DNS suffix", "node"],
])("does not derive a Service URL when Tailscale %s", async (_name, hostname) => {
const logTailscale = createLogger();
mocks.getTailnetHostname.mockResolvedValue(hostname);
mocks.getTailnetHostnameAfterServe.mockResolvedValue(hostname);
await startGatewayTailscaleExposure({
tailscaleMode: "serve",
+6 -1
View File
@@ -7,6 +7,7 @@ import {
enableTailscaleFunnel,
enableTailscaleServe,
getTailnetHostname,
getTailnetHostnameAfterServe,
hasTailscaleFunnelRouteForPort,
} from "../infra/tailscale.js";
import { resolveTailscalePublishedHost } from "../shared/tailscale-status.js";
@@ -55,7 +56,11 @@ export async function startGatewayTailscaleExposure(params: {
} else {
await enableTailscaleFunnel(params.port);
}
const host = await getTailnetHostname().catch(() => null);
const host = await (
params.tailscaleMode === "serve" && !preservedFunnel
? getTailnetHostnameAfterServe()
: getTailnetHostname()
).catch(() => null);
if (host) {
const uiPath = params.controlUiBasePath ? `${params.controlUiBasePath}/` : "/";
const publicHost = resolveTailscalePublishedHost({
+62
View File
@@ -5,6 +5,7 @@ import * as tailscale from "./tailscale.js";
const {
getTailnetHostname,
getTailnetHostnameAfterServe,
readTailscaleWhoisIdentity,
enableTailscaleServe,
disableTailscaleServe,
@@ -75,6 +76,67 @@ describe("tailscale helpers", () => {
expect(host).toBe("noisy.tailnet.ts.net");
});
it.each([
[new Error("Failed to connect to local Tailscale daemon; not running?")],
[new Error("failed to connect to local Tailscale service; is Tailscale running?")],
[Object.assign(new Error("Command timed out"), { timedOut: true, signal: "SIGTERM" })],
])("retries post-Serve status after a transient failure", async (failure) => {
vi.useFakeTimers();
const exec = vi
.fn()
.mockRejectedValueOnce(failure)
.mockResolvedValueOnce({
stdout: JSON.stringify({
Self: { DNSName: "retry.tailnet.ts.net.", TailscaleIPs: ["100.7.7.7"] },
}),
});
const hostPromise = getTailnetHostnameAfterServe(exec);
await vi.runAllTimersAsync();
const host = await hostPromise;
expect(host).toBe("retry.tailnet.ts.net");
expect(exec).toHaveBeenCalledTimes(2);
expectExecCall(exec, 1, tailscaleBin, ["status", "--json"], {
timeoutMs: 5000,
maxBuffer: 400_000,
logOutput: false,
});
expectExecCall(exec, 2, tailscaleBin, ["status", "--json"], {
timeoutMs: 5000,
maxBuffer: 400_000,
logOutput: false,
});
});
it.each([
["missing binary", new Error("spawn tailscale ENOENT")],
["permission failure", new Error("permission denied")],
])("does not retry post-Serve status after a permanent %s", async (_name, failure) => {
const exec = vi.fn().mockRejectedValue(failure);
await expect(getTailnetHostnameAfterServe(exec)).rejects.toThrow(failure.message);
expect(exec).toHaveBeenCalledTimes(1);
});
it("does not retry malformed post-Serve status JSON", async () => {
const exec = vi.fn().mockResolvedValue({ stdout: "{not json}" });
await expect(getTailnetHostnameAfterServe(exec)).rejects.toThrow(SyntaxError);
expect(exec).toHaveBeenCalledTimes(1);
});
it("keeps ordinary hostname lookup single-attempt", async () => {
const failure = new Error("Failed to connect to local Tailscale daemon; not running?");
const exec = vi.fn().mockRejectedValue(failure);
await expect(getTailnetHostname(exec, tailscaleBin)).rejects.toThrow(failure.message);
expect(exec).toHaveBeenCalledTimes(1);
});
it("parses noisy JSON output from tailscale whois", async () => {
const exec = vi.fn().mockResolvedValue({
stdout:
+71 -17
View File
@@ -12,6 +12,10 @@ import {
import { logVerbose } from "../globals.js";
import { runExec } from "../process/exec.js";
import { toErrorObject } from "./errors.js";
import { retryAsync } from "./retry.js";
const TAILSCALE_STATUS_ATTEMPTS = 3;
const TAILSCALE_STATUS_RETRY_DELAY_MS = 500;
function parsePossiblyNoisyJsonObject(stdout: string): Record<string, unknown> {
const trimmed = stdout.trim();
@@ -23,6 +27,45 @@ function parsePossiblyNoisyJsonObject(stdout: string): Record<string, unknown> {
return JSON.parse(trimmed) as Record<string, unknown>;
}
function tailnetHostnameFromStatus(parsed: Record<string, unknown>): string {
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: undefined;
const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined;
const ips = Array.isArray(self?.TailscaleIPs)
? ((parsed.Self as { TailscaleIPs?: string[] }).TailscaleIPs ?? [])
: [];
if (dns && dns.length > 0) {
return dns.replace(/\.$/, "");
}
const [firstIp] = ips;
if (firstIp !== undefined) {
return firstIp;
}
throw new Error("Could not determine Tailscale DNS or IP");
}
function isTransientTailscaleStatusError(error: unknown): boolean {
const record = readRecord(error);
const detail = [
error instanceof Error ? error.message : undefined,
typeof record?.stderr === "string" ? record.stderr : undefined,
typeof record?.stdout === "string" ? record.stdout : undefined,
]
.filter((value): value is string => Boolean(value))
.join("\n")
.toLowerCase();
return (
record?.timedOut === true ||
detail.includes("failed to connect to local tailscale daemon") ||
detail.includes("failed to connect to local tailscale service") ||
detail.includes("connection refused") ||
detail.includes("503 service unavailable")
);
}
/**
* Locate Tailscale binary using multiple strategies:
* 1. PATH lookup (via which command)
@@ -121,23 +164,7 @@ export async function getTailnetHostname(exec: typeof runExec = runExec, detecte
timeoutMs: 5000,
maxBuffer: 400_000,
});
const parsed = stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
const self =
typeof parsed.Self === "object" && parsed.Self !== null
? (parsed.Self as Record<string, unknown>)
: undefined;
const dns = typeof self?.DNSName === "string" ? self.DNSName : undefined;
const ips = Array.isArray(self?.TailscaleIPs)
? ((parsed.Self as { TailscaleIPs?: string[] }).TailscaleIPs ?? [])
: [];
if (dns && dns.length > 0) {
return dns.replace(/\.$/, "");
}
const [firstIp] = ips;
if (firstIp !== undefined) {
return firstIp;
}
throw new Error("Could not determine Tailscale DNS or IP");
return tailnetHostnameFromStatus(stdout ? parsePossiblyNoisyJsonObject(stdout) : {});
} catch (err) {
lastError = err;
}
@@ -179,6 +206,33 @@ async function getTailscaleBinary(): Promise<string> {
return cachedTailscaleBinary ?? "tailscale";
}
/** Resolve the hostname after Serve startup, while the local daemon may still be settling. */
export async function getTailnetHostnameAfterServe(
exec: typeof runExec = runExec,
): Promise<string> {
const candidate = await getTailscaleBinary();
const parsed = await retryAsync(
async () => {
const { stdout } = await exec(candidate, ["status", "--json"], {
timeoutMs: 5000,
maxBuffer: 400_000,
// Hostname discovery is best-effort. Avoid scary command-failure logs while the
// local daemon settles after Serve configuration.
logOutput: false,
});
return stdout ? parsePossiblyNoisyJsonObject(stdout) : {};
},
{
attempts: TAILSCALE_STATUS_ATTEMPTS,
minDelayMs: TAILSCALE_STATUS_RETRY_DELAY_MS,
maxDelayMs: TAILSCALE_STATUS_RETRY_DELAY_MS,
jitter: 0,
shouldRetry: isTransientTailscaleStatusError,
},
);
return tailnetHostnameFromStatus(parsed);
}
type ExecErrorDetails = {
stdout?: unknown;
stderr?: unknown;