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.
This commit is contained in:
Ben Barclay 2026-07-20 16:01:38 +10:00 committed by GitHub
parent 3aeded6e32
commit f0aae14c68
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -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<void> | 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) {