Report the keyring gap inline in headless serve

Deferring the probe to the first window is right for the desktop app, but serve
never opens one, so the fallback timer became its only path. That moved the
stall to after `printServeReady`: the runtime advertises itself, a relay or
mobile client pairs, and only then does the main thread freeze on the keyring —
stalling pings and PTY pumps, which a client reads as a dead host.

Serve now reports inline, which is the timing it already had, and blocks before
anything is advertised rather than under a live client.

STA-5765
This commit is contained in:
Brennan Benson
2026-08-28 02:36:42 -07:00
parent 1d817832ca
commit 642fe5eada
3 changed files with 47 additions and 3 deletions
@@ -73,7 +73,11 @@ describe('scheduleSecretProtectionGapReport', () => {
})
const schedule = (): void =>
scheduleSecretProtectionGapReport({ dataFile, log: (m) => void logged.push(m) })
scheduleSecretProtectionGapReport({
dataFile,
log: (m) => void logged.push(m),
deferUntilFirstWindow: true
})
/** Runs an already-queued setImmediate; the 1ms is slack, not a delay under test. */
const drain = (): void => void vi.advanceTimersByTime(1)
@@ -127,4 +131,27 @@ describe('scheduleSecretProtectionGapReport', () => {
vi.runAllTimers()
expect(probes).toBe(1)
})
it('reports inline in headless serve, before the runtime is advertised as ready', () => {
// Why not deferred: serve opens no window, so the fallback would fire only after
// clients could already have paired, and a frozen main thread reads as a dead host.
scheduleSecretProtectionGapReport({
dataFile,
log: (m) => void logged.push(m),
deferUntilFirstWindow: false
})
expect(probes).toBe(1)
expect(logged).toEqual(['[secrets] The OS keyring is unavailable.'])
})
it('leaves no timer armed in headless serve', () => {
scheduleSecretProtectionGapReport({
dataFile,
log: (m) => void logged.push(m),
deferUntilFirstWindow: false
})
expect(vi.getTimerCount()).toBe(0)
vi.runAllTimers()
expect(probes).toBe(1)
})
})
@@ -19,11 +19,27 @@ import { reportSecretProtectionGap } from './secret-protection-report'
// serve has no window to wait on.
const REPORT_FALLBACK_MS = 15_000
export function scheduleSecretProtectionGapReport(options: {
export function scheduleSecretProtectionGapReport({
deferUntilFirstWindow,
...options
}: {
dataFile: string
force?: boolean
log?: (message: string) => void
/**
* Why headless serve reports inline instead: it never opens a window, so the fallback
* timer is the only path — and by then the runtime has been advertised as ready and
* clients may have paired. Freezing the main thread under a live client stalls pings
* and PTY pumps, which reads as a dead host. Blocking before anything is advertised is
* the timing serve already had, and the safer of the two.
*/
deferUntilFirstWindow: boolean
}): void {
if (!deferUntilFirstWindow) {
reportSecretProtectionGap(options)
return
}
let ran = false
const run = (): void => {
if (ran) {
+2 -1
View File
@@ -2352,7 +2352,8 @@ void app.whenReady().then(async () => {
// and must not gate the first window (STA-5765).
scheduleSecretProtectionGapReport({
dataFile: activeOrcaProfile.dataFile,
force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1'
force: process.env.ORCA_ALWAYS_REPORT_SECRET_PROTECTION === '1',
deferUntilFirstWindow: !isServeMode
})
// Why here: the host key store is a sidecar of the same profile, and every SSH connect consults
// it. Left unbound it reports nothing trusted, which is safe but silently discards our own