发布

  • fix(x): OAuth callback eaten by dead flow's server via keep-alive socket reuse (#850)

    frostbyte_neo 发布于 2026-08-12 12:20:33 +00:00

    The first Microsoft connect during onboarding spun forever on macOS. Root
    cause (caught live with state-instrumented repros): each OAuth flow spawns
    its own callback server on port 8080, and Chrome pools its localhost
    connection for minutes. server.close() only stops listening, so after the
    Rowboat sign-in settled, Chrome's socket stayed attached to the dead
    rowboat server's handler. The Microsoft flow bound fresh listeners on the
    freed port, Microsoft redirected with a perfectly valid state, and Chrome
    delivered it down the pooled socket into the wrong flow's closure — where
    the old code swallowed the state mismatch silently (validation threw
    outside the event-emitting try/catch, onCallback was fire-and-forget, and
    the success page rendered unconditionally), so the browser said
    "Authorization Successful" while the app spun until the 10-minute timeout.
    Timing-dependent, hence first-attempt-only and machine-dependent.

    Fixes:

    • Stamp Connection: close on every callback response — one-shot per-flow
      servers must never participate in connection pooling. This kills the
      zombie-socket class outright.
    • On close, also closeIdleConnections() plus a deferred (5s)
      closeAllConnections() for browser preconnect sockets, which idle-close
      spares. Not an immediate closeAllConnections: flows close the server from
      inside onCallback before the awaited response has flushed, and killing
      that socket makes the browser show a connection error instead of the
      result page. chatgpt-signin had the same race and gets the same fix.
    • Await onCallback before responding, and render an honest error page when
      handling fails instead of an unconditional success page.
    • validateCallback gatekeeper in connectProvider: requests whose state
      doesn't match the live flow get a polite close-this-tab page and can no
      longer consume the one-shot callbackHandled guard; remaining validation
      moved inside the try/catch so every failure emits oauth:didConnect and
      the renderer spinner always settles.
    • onError settles immediately on provider errors (e.g. access_denied when
      the user cancels consent) instead of spinning until the timeout.
    • Bind both loopback families (127.0.0.1 primary, ::1 best-effort), since
      listen('localhost') is single-family and browsers pick per-navigation;
      mirrors apps/server.ts.
    • Log every request the callback server receives (path, state, decision)
      so the next incident is diagnosable from the terminal.

    Co-authored-by: Claude Fable 5 noreply@anthropic.com

    下载附件