{"record":{"id":"d1184c2e335d2970","repo":"CherryHQ/cherry-studio","slug":"login-cancelled","errorCode":null,"errorMessage":"Login cancelled","messagePattern":"Login cancelled","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"info","filePath":"src/main/ai/channels/adapters/wechat/WeChatProtocol.ts","lineNumber":603,"sourceCode":"  force?: boolean\n  signal?: AbortSignal\n  onQrUrl?: (url: string) => void\n}\n\n/** Maximum number of expired QR codes before giving up. */\nconst MAX_QR_RETRIES = 3\n\nasync function loginFlow(options: LoginOptions): Promise<Credentials> {\n  if (!options.force) {\n    const existing = await loadCredentials(options.tokenPath)\n    if (existing) return existing\n  }\n\n  let qrRetries = 0\n\n  while (qrRetries < MAX_QR_RETRIES) {\n    if (options.signal?.aborted) {\n      throw new Error('Login cancelled')\n    }\n\n    const qr = await fetchQrCode(options.baseUrl)\n    options.onQrUrl?.(qr.qrcode_img_content)\n    logger.info('QR code generated, waiting for scan', { attempt: qrRetries + 1, maxAttempts: MAX_QR_RETRIES })\n\n    let lastStatus: string | undefined\n\n    for (;;) {\n      if (options.signal?.aborted) {\n        throw new Error('Login cancelled')\n      }\n\n      const status = await pollQrStatus(options.baseUrl, qr.qrcode)\n\n      if (status.status !== lastStatus) {\n        if (status.status === 'scaned') {\n          logger.info('QR code scanned, waiting for confirmation')","sourceCodeStart":585,"sourceCodeEnd":621,"githubUrl":"https://github.com/CherryHQ/cherry-studio/blob/726446b54cd69ffe51a276638672f6d95ca0768c/src/main/ai/channels/adapters/wechat/WeChatProtocol.ts#L585-L621","documentation":"Thrown by loginFlow() in WeChatProtocol at the top of the outer QR-retry loop when the abort signal is already aborted before fetching a new QR code. This is a cancellation path: the caller (typically WeixinBot.login via the adapter, or runLoop's session-expiry re-login) signalled abort and the loop honored it before doing any network I/O for this iteration. Distinct from error 137 which fires mid-poll.","triggerScenarios":"options.signal.aborted is true at the top of the while (qrRetries < MAX_QR_RETRIES) loop. The signal is AbortSignal.any of the caller's signal and the bot's internal loginAbort (WeChatProtocol.ts:708). WeixinBot.stop() aborts loginAbort (line 864), so calling stop() during login triggers this. Also fires if the adapter's performConnect signal was aborted (e.g. disconnect called during connect, WeChatAdapter.ts:49).","commonSituations":"The user clicked disconnect while the QR code was on screen (performDisconnect → bot.stop → loginAbort.abort → next loop iteration throws this); the app is shutting down during login; the user navigated away from the QR screen and the UI cancelled the login; session-expiry re-login in runLoop was aborted because the bot was stopped.","solutions":["This is expected cancellation — catch it in the connect path and treat as a clean disconnect, not an error. WeChatAdapter.performConnect already guards with signal.aborted checks (lines 49, 60).","Ensure the catch in performConnect distinguishes 'Login cancelled' from real failures (the existing .catch at line 51 checks signal.aborted).","Do not retry on this error — the user intentionally cancelled.","Surface a 'Login cancelled' status to the UI rather than an error toast."],"exampleFix":"// before — cancellation surfaces as a generic connect error\nconst credentials = await bot.login({ signal })\n\n// after — recognize cancellation explicitly\ntry {\n  const credentials = await bot.login({ signal })\n} catch (e) {\n  if (signal.aborted || (e instanceof Error && e.message === 'Login cancelled')) {\n    this.sendQrToRenderer('', 'disconnected')\n    return // clean cancel, not an error\n  }\n  throw e\n}","handlingStrategy":"try-catch","validationCode":"// Cancellation cannot be prevented — it is the caller's intent. But you can avoid\n// starting loginFlow if the signal is already aborted, and check before showing a QR.\nasync function safeLogin(bot: WeixinBot, signal: AbortSignal) {\n  if (signal.aborted) {\n    logger.info('Login cancelled before start')\n    return null\n  }\n  return await bot.login({ signal })\n}","typeGuard":"function isLoginCancelled(e: unknown): boolean {\n  return e instanceof Error && e.message === 'Login cancelled'\n}\n\nfunction isAbortError(e: unknown): boolean {\n  return e instanceof Error && (e.name === 'AbortError' || e.name === 'TimeoutError')\n}","tryCatchPattern":"// Treat 'Login cancelled' as a clean stop, not an error — WeChatAdapter.performConnect\n// already checks signal.aborted around the login call (WeChatAdapter.ts:49,60).\ntry {\n  const credentials = await bot.login({ signal })\n} catch (e) {\n  if (isLoginCancelled(e) || signal.aborted) {\n    this.sendQrToRenderer('', 'disconnected')\n    return // clean cancel\n  }\n  throw e\n}","preventionTips":["Check signal.aborted before starting loginFlow to avoid a needless QR fetch.","Map 'Login cancelled' to a 'disconnected' UI status, not an error toast.","Make abort idempotent: WeixinBot.stop() (WeChatProtocol.ts:861) aborts loginAbort once; subsequent calls are safe.","Do not retry on cancellation — the user intended to stop."],"tags":["wechat","login","abort","cancellation","lifecycle"],"backgroundTag":null,"analyzedSha":"726446b54cd69ffe51a276638672f6d95ca0768c","analyzedAt":"2026-08-12T17:30:37.448Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}