-
fix(x): OAuth callback eaten by dead flow's server via keep-alive socket reuse (#850)
发布于
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
下载附件
- Stamp Connection: close on every callback response — one-shot per-flow