From f0aae14c684a84cd1eeca88339238406c30f3ed7 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Mon, 20 Jul 2026 16:01:38 +1000 Subject: [PATCH] fix(desktop): retry OAuth cookie read on cold-start jar race (#67769) A `persist:` partition's cookie store hydrates lazily, so the first cookies.get() on a fresh launch can return empty for a signed-in user. That false-negative made hasLiveOauthSession() throw "not signed in", which on the no-retry initial boot path surfaced as the transient "Hermes couldn't start" OAuth overlay that always cleared on Retry. hasLiveOauthSession now reads once (no added latency on the happy path); only on an empty read does it warm the store (flushStorageData + a throwaway get, memoized) and re-read with a bounded ~180ms backoff before trusting the negative. Genuinely signed-out users still resolve false quickly and get the overlay. Fixes the whole class: the same function backs the reconnect path and the Settings connected indicator. --- apps/desktop/electron/main.ts | 84 ++++++++++++++++++++++++++++++++--- 1 file changed, 77 insertions(+), 7 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 646eefc6eb2d..e0dd2c4fb549 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -5212,6 +5212,50 @@ function getOauthSession() { return oauthSession } +// Cold-start cookie-jar warm-up. A `persist:` partition materialized via +// session.fromPartition() loads its on-disk cookie store LAZILY: the very first +// cookies.get() on a fresh cold start can resolve BEFORE the jar has finished +// hydrating from disk and return an empty array — even though the user is +// signed in. That false-negative used to make hasLiveOauthSession() report +// "not signed in", which on the initial boot path (startHermes → the renderer's +// single-shot boot() with no retry) surfaced as the "Hermes couldn't start" +// OAuth overlay that vanishes the instant the user clicks Retry. +// +// We force the store to hydrate once, up front: flushStorageData() then a +// throwaway cookies.get(). The promise is memoized so every caller awaits the +// same single warm-up. Best-effort — any error resolves so we fall back to the +// live read (which then does its own bounded re-check). +let oauthCookieWarmup: Promise | null = null + +function warmOauthCookieStore() { + if (oauthCookieWarmup) { + return oauthCookieWarmup + } + + oauthCookieWarmup = (async () => { + const sess = getOauthSession() + + if (!sess) { + // App not ready yet — don't memoize a no-op; let a later call retry. + oauthCookieWarmup = null + + return + } + + try { + // flushStorageData() forces Chromium to reconcile the in-memory cookie + // monster with the on-disk SQLite store; the subsequent get() then reads + // a populated jar rather than racing the lazy first-access load. + sess.flushStorageData?.() + await sess.cookies.get({}) + } catch { + // Best effort; the real read below re-checks with bounded retries. + } + })() + + return oauthCookieWarmup +} + // Bare + prefixed variants of the session cookies live in // connection-config.ts (cookiesHaveSession / cookiesHaveLiveSession). See // that module for details. @@ -5258,19 +5302,45 @@ async function hasLiveOauthSession(baseUrl) { const parsed = new URL(baseUrl) - try { - const cookies = await sess.cookies.get({ url: baseUrl }) - - return cookiesHaveLiveSession(cookies) - } catch { + const readLive = async () => { try { - const cookies = await sess.cookies.get({ domain: parsed.hostname }) + const cookies = await sess.cookies.get({ url: baseUrl }) return cookiesHaveLiveSession(cookies) } catch { - return false + try { + const cookies = await sess.cookies.get({ domain: parsed.hostname }) + + return cookiesHaveLiveSession(cookies) + } catch { + return false + } } } + + // First read against the (possibly still-hydrating) jar. + if (await readLive()) { + return true + } + + // Cold-start false-negative guard. A `persist:` partition's cookie store + // loads lazily, so the FIRST read on a fresh boot can come back empty even + // for a signed-in user — the exact race that produced the transient "Hermes + // couldn't start / not signed in" overlay that Retry always cleared. Before + // trusting a negative, force the store to hydrate and re-read a couple of + // times with a short backoff. A genuinely signed-out user still resolves + // false quickly (≤ ~180ms); a signed-in user racing the load now wins. + await warmOauthCookieStore() + + for (const delayMs of [30, 60, 90]) { + if (await readLive()) { + return true + } + + await new Promise(resolve => setTimeout(resolve, delayMs)) + } + + return readLive() } async function clearOauthSession(baseUrl) {