{"record":{"id":"15a0912bcce98a5d","repo":"decolua/9router","slug":"cancelled","errorCode":null,"errorMessage":"cancelled","messagePattern":"cancelled","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"info","filePath":"src/lib/tunnel/cloudflare/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/cloudflare/healthCheck.js#L6-L30","documentation":"waitForHealth() polls a tunnel URL until it responds, bounded by HEALTH_CHECK.timeoutMs. Between polls it checks the caller-supplied cancelToken; if token.cancelled is true it throws 'cancelled' immediately instead of continuing to poll. This is a cooperative-cancellation signal, not a failure of the health check itself — some upstream code (enableTunnel) cancelled the wait.","triggerScenarios":"User disables the tunnel or navigates away while enableTunnel() is still waiting for the cloudflared tunnel URL to become healthy; the manager sets cancelToken.cancelled = true and the next poll iteration throws. Also thrown if the same shared cancelToken object is reused across calls with cancelled already true.","commonSituations":"Clicking 'disable tunnel' in the dashboard during startup; a reconnect flow resetting the token mid-wait; or stale token reuse causing an immediate 'cancelled' even though no one cancelled this attempt.","solutions":["Treat 'cancelled' as an expected control-flow signal — catch it and stop silently rather than surfacing as an error.","Ensure a fresh cancelToken ({ cancelled: false }) is created for every enableTunnel() call (the manager does this in enableTunnel).","If you see it spuriously, check whether code shares one token object across enable/disable calls.","Wait for the health check to complete before issuing a disable, or gate disable UI during startup."],"exampleFix":"// before\nawait waitForHealth(url, sharedToken);\n// after\nconst token = { cancelled: false }; // fresh token per attempt\ntry {\n  await waitForHealth(url, token);\n} catch (e) {\n  if (e.message === \"cancelled\") return; // user cancelled — not an error\n  throw e;\n}","handlingStrategy":"try-catch","validationCode":"// Ensure a fresh token before waiting\nconst cancelToken = { cancelled: false };\nif (cancelToken.cancelled) return; // don't even start","typeGuard":"const isCancelError = (e) => e instanceof Error && e.message === \"cancelled\";","tryCatchPattern":"try {\n  await waitForHealth(url, token);\n} catch (e) {\n  if (isCancelError(e)) return; // expected abort\n  throw e;\n}","preventionTips":["Create a new { cancelled: false } token per enable attempt; never share tokens.","Only flip cancelled=true from a single, well-defined disable path.","Distinguish 'cancelled' from timeout errors in logging so cancels aren't mistaken for failures.","Avoid issuing disable during the health-wait phase unless the user explicitly cancels."],"tags":["cancellation","cloudflare","tunnel","health-check"],"backgroundTag":"operation-cancelled","analyzedSha":"90b52e06ffd666b7929554211474d01588f6b1f8","analyzedAt":"2026-08-30T21:05:45.952Z","schemaVersion":2},"datasetVersion":"2026-08-30T23:17:21.991Z"}