mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
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:
@@ -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
@@ -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'
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user