From b55417e97c322ad569372fb5b7ac882c1d2556ee Mon Sep 17 00:00:00 2001 From: Gagan Date: Mon, 6 Jul 2026 21:07:13 +0530 Subject: [PATCH] =?UTF-8?q?fix(apps):=20pace=20device-flow=20polls=20in=20?= =?UTF-8?q?core=20=E2=80=94=20slow=5Fdown=20made=20auth=20spin=20forever?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GitHub rate-limits the device-code token endpoint: a poll arriving even slightly under the flow interval returns slow_down and permanently raises the required interval. The renderer polled on a fixed 5s timer (exactly the limit), so one jittery request tripped slow_down and every poll after it was 'too fast' — GitHub answered slow_down forever and the dialog sat on 'Waiting for authorization' even after the user approved. Core now tracks lastTokenPollAt and skips the request until the flow's current interval (base +1s margin) has elapsed, so slow_down bumps take effect and the flow recovers. The renderer timer is just a heartbeat (2s). --- .../src/components/apps/publish-dialog.tsx | 4 +++- apps/x/packages/core/src/apps/github-auth.ts | 19 ++++++++++++++++++- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx index 6eba1137..f14dd7e0 100644 --- a/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx +++ b/apps/x/apps/renderer/src/components/apps/publish-dialog.tsx @@ -62,7 +62,9 @@ export function PublishDialog({ folder, appName, onClose, onPublished }: { setError(e instanceof Error ? e.message : String(e)) setPhase('auth') }) - }, 5000) + // Heartbeat only — core paces the actual GitHub requests to the + // flow's required interval (and skips the request when it's too soon). + }, 2000) } catch (e) { setError(e instanceof Error ? e.message : String(e)) } diff --git a/apps/x/packages/core/src/apps/github-auth.ts b/apps/x/packages/core/src/apps/github-auth.ts index 1de109a9..8c5eec0d 100644 --- a/apps/x/packages/core/src/apps/github-auth.ts +++ b/apps/x/packages/core/src/apps/github-auth.ts @@ -35,6 +35,8 @@ type PendingFlow = { deviceCode: string; intervalMs: number; expiresAt: number; + /** Last time we actually hit GitHub's token endpoint (pacing). */ + lastTokenPollAt?: number; /** Token already issued but the identity fetch failed — retry that step. */ issuedToken?: string; identityAttempts?: number; @@ -71,7 +73,10 @@ export async function startDeviceFlow(): Promise<{ userCode: string; verificatio }; pending = { deviceCode: body.device_code, - intervalMs: (body.interval || 5) * 1000, + // +1s safety margin: GitHub measures arrival spacing, and a request + // that lands even slightly early gets `slow_down`, which RAISES the + // required interval for the rest of the flow. + intervalMs: ((body.interval || 5) + 1) * 1000, expiresAt: Date.now() + body.expires_in * 1000, }; return { userCode: body.user_code, verificationUri: body.verification_uri, expiresIn: body.expires_in }; @@ -93,6 +98,18 @@ export async function pollDeviceFlow(): Promise { let accessToken = pending.issuedToken; if (!accessToken) { + // Pacing lives HERE, not in the renderer: the renderer's timer is just + // a heartbeat. GitHub rate-limits the token endpoint per device code — + // polling faster than the flow's interval returns `slow_down` and + // permanently raises the required interval, and a caller that keeps + // its own fixed cadence then gets `slow_down` on EVERY poll (looks + // "pending" forever, even after the user authorized). Skip the request + // entirely until the current interval has elapsed. + const now = Date.now(); + if (pending.lastTokenPollAt !== undefined && now - pending.lastTokenPollAt < pending.intervalMs) { + return { status: 'pending' }; + } + pending.lastTokenPollAt = now; const res = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { ...GH_HEADERS, 'Content-Type': 'application/x-www-form-urlencoded' },