Stop the OS keyring probe from gating the first window on Linux

1.4.190 added an at-rest secret protection report and called it from the
`app.whenReady()` startup path, before the first window is created.
`describeProtectionGap()` asks Electron `safeStorage` whether the OS keyring
is usable, and on Linux that is a blocking D-Bus round trip to
`org.freedesktop.secrets`. A keyring that is present but locked with no unlock
prompter never answers, so the call sits until D-Bus times it out and the app
shows no window for over a minute.

Measured on Ubuntu 24.04 against a Secret Service that accepts the connection
and never replies, time to first window:

  1.4.188                          1.06s   (never contacts the keyring)
  1.4.190                         76.05s
  1.4.190 --password-store=basic   1.06s   (probe bypassed)

Nothing on the startup path consumes the report, so it now waits for the first
window's `ready-to-show`, with a timer fallback because that event can fail to
fire when the GPU cannot present and headless serve has no window at all. Same
build under the same hanging keyring: first window 80.48s -> 5.31s, with the
report still delivered.

STA-5765
This commit is contained in:
Brennan Benson
2026-08-28 02:36:42 -07:00
parent caef20fec8
commit 065a2addd1
3 changed files with 164 additions and 4 deletions
@@ -0,0 +1,112 @@
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { setSecretStore } from '../../shared/secret-store'
type Listener = (...args: unknown[]) => void
const appListeners = new Map<string, Listener[]>()
vi.mock('electron', () => ({
app: {
once: (event: string, listener: Listener) => {
const existing = appListeners.get(event) ?? []
existing.push(listener)
appListeners.set(event, existing)
}
}
}))
const { scheduleSecretProtectionGapReport } = await import('./deferred-secret-protection-report')
/** Stands in for the BrowserWindow the app creates first. */
function fakeWindow(): { once: (event: string, listener: Listener) => void; reveal: () => void } {
const listeners: Listener[] = []
return {
once: (event, listener) => {
if (event === 'ready-to-show') {
listeners.push(listener)
}
},
reveal: () => listeners.splice(0).forEach((listener) => listener())
}
}
function createWindow(): ReturnType<typeof fakeWindow> {
const window = fakeWindow()
appListeners.get('browser-window-created')?.splice(0).forEach((listener) => listener({}, window))
return window
}
describe('scheduleSecretProtectionGapReport', () => {
let dir: string
let dataFile: string
let probes: number
let logged: string[]
beforeEach(() => {
vi.useFakeTimers()
appListeners.clear()
dir = mkdtempSync(join(tmpdir(), 'orca-deferred-secret-report-'))
dataFile = join(dir, 'orca-data.json')
probes = 0
logged = []
setSecretStore({
isEncryptionAvailable: () => true,
encryptString: (plainText) => Buffer.from(plainText),
decryptString: (cipher) => cipher.toString(),
describeProtectionGap: () => {
// Why count here: this is the call that blocks on the OS keyring.
probes += 1
return 'The OS keyring is unavailable.'
}
})
})
afterEach(() => {
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
})
const schedule = (): void =>
scheduleSecretProtectionGapReport({ dataFile, log: (m) => void logged.push(m) })
it('does not probe the keyring while the first window is still being created', () => {
// Why this is the regression: probing here blocked the window for 76s on a locked
// Linux keyring, which reads to the user as "the app will not open" (STA-5765).
schedule()
createWindow()
expect(probes).toBe(0)
expect(logged).toEqual([])
})
it('probes once the window is ready to show', () => {
schedule()
const window = createWindow()
window.reveal()
vi.runAllTimers()
expect(probes).toBe(1)
expect(logged).toEqual(['[secrets] The OS keyring is unavailable.'])
})
it('still reports when ready-to-show never fires, so a failed reveal is not silent', () => {
// Why: a GPU/driver failure can keep ready-to-show from ever firing, and headless
// serve has no window at all.
schedule()
createWindow()
vi.advanceTimersByTime(15_000)
vi.runAllTimers()
expect(probes).toBe(1)
})
it('probes only once when the window reveals and the fallback also elapses', () => {
schedule()
const window = createWindow()
window.reveal()
vi.runAllTimers()
vi.advanceTimersByTime(60_000)
vi.runAllTimers()
expect(probes).toBe(1)
})
})
@@ -0,0 +1,46 @@
import { app, type BrowserWindow } from 'electron'
import { reportSecretProtectionGap } from './secret-protection-report'
/**
* Run the at-rest secret protection report once the app is up, never before.
*
* Why deferred: `describeProtectionGap()` asks Electron `safeStorage` whether the OS
* keyring is usable, and on Linux that is a blocking D-Bus round trip to
* `org.freedesktop.secrets`. A keyring that is present but locked with no unlock
* prompter never answers, so the call sits until D-Bus times it out — measured at 76s
* to first window on Ubuntu 24.04, against 1s on the build before the report existed
* (STA-5765). Run before the first window, that reads to the user as "the app will not
* open"; the window is gated behind a diagnostic whose result nothing on the startup
* path consumes.
*/
// Why a fallback as well as the window event: `ready-to-show` can fail to fire at all
// when the GPU/driver cannot present (see main-window-state-lifecycle), and headless
// serve has no window to wait on.
const REPORT_FALLBACK_MS = 15_000
export function scheduleSecretProtectionGapReport(options: {
dataFile: string
force?: boolean
log?: (message: string) => void
}): void {
let ran = false
const run = (): void => {
if (ran) {
return
}
ran = true
clearTimeout(fallback)
// Why setImmediate: keep the blocking keyring probe off the event handler that
// reveals the window, so the reveal paints first.
setImmediate(() => {
reportSecretProtectionGap(options)
})
}
const fallback = setTimeout(run, REPORT_FALLBACK_MS)
fallback.unref?.()
app.once('browser-window-created', (_event: Electron.Event, window: BrowserWindow) => {
window.once('ready-to-show', run)
})
}
+6 -4
View File
@@ -36,7 +36,7 @@ import { setSpeechServiceFactories } from './speech/speech-runtime-service'
import { setWorktreeWatcherRemoval } from './ipc/worktree-watcher-removal'
import { setSecretStore } from '../shared/secret-store'
import { ElectronSecretStore } from './host/electron-secret-store'
import { reportSecretProtectionGap } from './host/secret-protection-report'
import { scheduleSecretProtectionGapReport } from './host/deferred-secret-protection-report'
import { initSessionParseCachePersistence } from './ai-vault/session-parse-cache-persistence'
import { ensureActiveOrcaProfile, initOrcaProfilePaths } from './orca-profiles/profile-index-store'
import { getOrcaCloudAuthConfig } from './orca-profiles/profile-cloud-auth-config'
@@ -2346,9 +2346,11 @@ void app.whenReady().then(async () => {
dataFile: activeOrcaProfile.dataFile,
storageAuthority: isServeMode ? 'runtime' : 'desktop'
})
// Why here and not at install time: the report remembers what it last said, and that
// state lives beside the profile data file, which does not exist until now.
reportSecretProtectionGap({
// Why armed here and not at install time: the report remembers what it last said, and
// that state lives beside the profile data file, which does not exist until now.
// Why scheduled and not called: the report probes the OS keyring, which blocks on Linux
// and must not gate the first window (STA-5765).
scheduleSecretProtectionGapReport({
dataFile: activeOrcaProfile.dataFile,
force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1'
})