{"record":{"id":"1e55ef49f6643a7b","repo":"decolua/9router","slug":"cancelled-1e55ef","errorCode":null,"errorMessage":"cancelled","messagePattern":"cancelled","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"info","filePath":"src/lib/tunnel/tailscale/healthCheck.js","lineNumber":24,"sourceCode":"  let hostname;\n  try { hostname = new URL(url).hostname; } catch { return false; }\n\n  if (!await resolveDns(hostname, HEALTH_CHECK.dnsTimeoutMs)) return false;\n\n  try {\n    const res = await fetch(`${url}/api/health`, {\n      signal: AbortSignal.timeout(HEALTH_CHECK.fetchTimeoutMs),\n    });\n    return res.ok;\n  } catch {\n    return false;\n  }\n}\n\nexport async function waitForHealth(url, cancelToken = { cancelled: false }) {\n  const start = Date.now();\n  while (Date.now() - start < HEALTH_CHECK.timeoutMs) {\n    if (cancelToken.cancelled) throw new Error(\"cancelled\");\n    if (await probeUrlAlive(url)) return true;\n    await new Promise((r) => setTimeout(r, HEALTH_CHECK.intervalMs));\n  }\n  throw new Error(`Health check timeout after ${HEALTH_CHECK.timeoutMs}ms`);\n}\n","sourceCodeStart":6,"sourceCodeEnd":30,"githubUrl":"https://github.com/decolua/9router/blob/90b52e06ffd666b7929554211474d01588f6b1f8/src/lib/tunnel/tailscale/healthCheck.js#L6-L30","documentation":"Identical cooperative-cancellation mechanism as the cloudflare health check, implemented in the tailscale tunnel's waitForHealth(): while polling the funnel URL, a true cancelToken.cancelled throws 'cancelled' immediately. It signals that a caller (enableTailscale path) aborted the health wait.","triggerScenarios":"User disables the tailscale tunnel while enableTailscale() is waiting for the funnel URL to become healthy; the manager sets cancelToken.cancelled = true and the next loop iteration throws. Reusing a cancelled token object across attempts also triggers it instantly.","commonSituations":"Toggling tailscale off during startup in the dashboard; reconnect logic resetting the token mid-wait; shared token state left cancelled from a previous attempt.","solutions":["Handle 'cancelled' as expected control flow in the caller, not as an error to report.","Ensure each enableTailscale() attempt creates a fresh { cancelled: false } token.","Avoid sharing one token object between concurrent enable/disable paths.","Defer disable until the enable/health phase completes, or lock the toggle during startup."],"exampleFix":"// before\nawait waitForHealth(url, token);\n// after\ntry {\n  await waitForHealth(url, token);\n} catch (e) {\n  if (e.message === \"cancelled\") return;\n  throw e;\n}","handlingStrategy":"try-catch","validationCode":"const cancelToken = { cancelled: false }; // fresh per attempt\nif (cancelToken.cancelled) return;","typeGuard":"const isCancelError = (e) => e instanceof Error && e.message === \"cancelled\";","tryCatchPattern":"try {\n  await waitForHealth(funnelUrl, token);\n} catch (e) {\n  if (isCancelError(e)) return;\n  throw e;\n}","preventionTips":["Use one cancel token per attempt, created fresh in enableTailscale().","Route all cancellations through the manager's token so state stays consistent.","Log cancels at info level, not error level.","Don't run disable and enable concurrently on the same service."],"tags":["cancellation","tailscale","tunnel","health-check"],"backgroundTag":"operation-cancelled","analyzedSha":"90b52e06ffd666b7929554211474d01588f6b1f8","analyzedAt":"2026-08-30T21:05:45.952Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}