diff --git a/src/gateway/server-tailscale.test.ts b/src/gateway/server-tailscale.test.ts index 29575d6f743..b7cf1127bcc 100644 --- a/src/gateway/server-tailscale.test.ts +++ b/src/gateway/server-tailscale.test.ts @@ -73,7 +73,7 @@ const modeCases = [ ]; describe.each(modeCases)( - "startGatewayTailscaleExposure (%s)", + "startGatewayTailscaleExposure ($mode)", ({ mode, enableMock, disableMock }) => { beforeEach(() => { vi.restoreAllMocks(); @@ -195,5 +195,39 @@ describe.each(modeCases)( await cleanupB?.(); expect(disableMock).toHaveBeenCalledTimes(1); }); + + it("falls back to unguarded cleanup when the ownership guard cannot claim", async () => { + const logTailscale = { + info: vi.fn(), + warn: vi.fn(), + }; + + const cleanup = await startGatewayTailscaleExposure({ + tailscaleMode: mode, + resetOnExit: true, + port: 18789, + logTailscale, + ownerStore: { + async claim() { + throw new Error("lock dir unavailable"); + }, + async replaceIfCurrent() { + return false; + }, + async runCleanupIfCurrentOwner() { + return false; + }, + }, + }); + + expect(cleanup).not.toBeNull(); + expect(enableMock).toHaveBeenCalledTimes(1); + expect(logTailscale.warn).toHaveBeenCalledWith( + `${mode} ownership guard unavailable: lock dir unavailable`, + ); + + await cleanup?.(); + expect(disableMock).toHaveBeenCalledTimes(1); + }); }, ); diff --git a/src/gateway/server-tailscale.ts b/src/gateway/server-tailscale.ts index 73dfb792fae..c83f5cbd745 100644 --- a/src/gateway/server-tailscale.ts +++ b/src/gateway/server-tailscale.ts @@ -79,9 +79,15 @@ function createTailscaleExposureOwnerStore(): TailscaleExposureOwnerStore { if (Date.now() - stat.mtimeMs < lockStaleMs) { return; } - // All lock holders only perform short file I/O plus the Tailscale CLI calls, - // and those helpers already time out after 15s. If the lock still exists after - // the wider stale window, assume the holder is wedged and break it. + try { + const raw = await fs.readFile(ownerLockPath, "utf8"); + const parsed = JSON.parse(raw) as { pid?: unknown }; + if (typeof parsed.pid === "number" && isPidAlive(parsed.pid)) { + return; + } + } catch { + // Unreadable lock state is treated as stale so a dead holder cannot block recovery. + } await fs.unlink(ownerLockPath).catch(() => {}); } catch (err) { if ((err as NodeJS.ErrnoException | undefined)?.code !== "ENOENT") { @@ -153,15 +159,19 @@ function createTailscaleExposureOwnerStore(): TailscaleExposureOwnerStore { }); }, async runCleanupIfCurrentOwner(token, cleanup) { - return await withOwnerLock(async () => { + const shouldRunCleanup = await withOwnerLock(async () => { const current = await readOwner(); if (current?.token !== token) { return false; } - await cleanup(); await deleteOwnerFile(); return true; }); + if (!shouldRunCleanup) { + return false; + } + await cleanup(); + return true; }, }; } @@ -179,7 +189,16 @@ export async function startGatewayTailscaleExposure(params: { } const ownerStore = params.ownerStore ?? createTailscaleExposureOwnerStore(); - const { owner, previousOwner } = await ownerStore.claim(params.tailscaleMode, params.port); + let owner: TailscaleExposureOwnerRecord | null = null; + let previousOwner: TailscaleExposureOwnerRecord | null = null; + + try { + ({ owner, previousOwner } = await ownerStore.claim(params.tailscaleMode, params.port)); + } catch (err) { + params.logTailscale.warn( + `${params.tailscaleMode} ownership guard unavailable: ${err instanceof Error ? err.message : String(err)}`, + ); + } try { if (params.tailscaleMode === "serve") { @@ -197,13 +216,15 @@ export async function startGatewayTailscaleExposure(params: { params.logTailscale.info(`${params.tailscaleMode} enabled`); } } catch (err) { - const nextOwner = - previousOwner && isPidAlive(previousOwner.pid) - ? previousOwner - : params.resetOnExit - ? owner - : null; - await ownerStore.replaceIfCurrent(owner.token, nextOwner).catch(() => {}); + if (owner) { + const nextOwner = + previousOwner && isPidAlive(previousOwner.pid) + ? previousOwner + : params.resetOnExit + ? owner + : null; + await ownerStore.replaceIfCurrent(owner.token, nextOwner).catch(() => {}); + } params.logTailscale.warn( `${params.tailscaleMode} failed: ${err instanceof Error ? err.message : String(err)}`, ); @@ -215,15 +236,26 @@ export async function startGatewayTailscaleExposure(params: { return async () => { try { - const cleanedUp = await ownerStore.runCleanupIfCurrentOwner(owner.token, async () => { - if (params.tailscaleMode === "serve") { - await disableTailscaleServe(); - } else { - await disableTailscaleFunnel(); + if (owner) { + const cleanedUp = await ownerStore.runCleanupIfCurrentOwner(owner.token, async () => { + if (params.tailscaleMode === "serve") { + await disableTailscaleServe(); + } else { + await disableTailscaleFunnel(); + } + }); + if (!cleanedUp) { + params.logTailscale.info( + `${params.tailscaleMode} cleanup skipped: not the current owner`, + ); } - }); - if (!cleanedUp) { - params.logTailscale.info(`${params.tailscaleMode} cleanup skipped: not the current owner`); + return; + } + + if (params.tailscaleMode === "serve") { + await disableTailscaleServe(); + } else { + await disableTailscaleFunnel(); } } catch (err) { params.logTailscale.warn(