Files
orca/src/main/quit-path-durable-write-blocking.test.ts
T
Brennan BensonandSiddiqui Qamar 5a59bc5bc4 fix(grok): stop Orca's Grok hooks from costing anything outside Orca (#16666)
* fix(grok): stop Orca's Grok hooks from costing anything outside Orca

Orca registers Grok agent-status hooks in the global $GROK_HOME/hooks. Grok
loads that directory on every session, so a Grok run that Orca did not launch
still paid for the hook on every event, and Orca rewrote the file even after a
user had emptied it to opt out (#15518).

The registered POSIX command now guards on ORCA_PANE_KEY before doing anything.
That variable is part of the pane identity Orca injects into terminals it
launches, and unlike the port and token it never comes from the endpoint file,
so it is present exactly when the session belongs to Orca. A standalone session
short-circuits without spawning a shell for the managed script at all. The same
guard is applied to the remote install, because a remote host runs standalone
Grok sessions too.

PreToolUse is no longer registered. It is a blocking hook, so Orca sat on the
critical path of every tool call and doubled the per-tool spawns, for a
transition PostToolUse already reports.

Windows cannot use the guard: the command there must be a single spawnable
token, so it is a bare script path with no shell to evaluate a test. For that
case the hooks are removed when Orca quits -- locally, on WSL guests, and on
connected SSH hosts -- and reinstalled on the next launch. A config the user has
emptied is left alone on startup; turning the setting back on in Settings is an
explicit and later choice, so that path reinstalls.

Removal is careful about what it is deleting. It strips only Orca's own entries,
keeps user-authored ones, and deletes the file only when no hook entries remain
-- keying that off the whole object would leave a stray non-hook key behind, and
the emptied-config check would then read that remnant as a deliberate opt-out
and never reinstall. A config the user has symlinked into a dotfiles repo is
written through rather than unlinked, and is exempt from the emptied-config
check for the same reason: after a quit it is a file Orca emptied, not one the
user did.

Writes go through temp+rename. Grok refuses to build a sandbox profile for a
hook JSON with more than one hard link, so publishing by hard link would fail
any session that started during the write.

Install and removal on remote hosts now read the platform from the same field.
They did not, so a Windows remote whose bridge env was incomplete had hooks
installed and never removed.

Co-authored-by: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com>

* fix(grok): preserve hook state outside Orca

---------

Co-authored-by: Siddiqui Qamar <137684575+siddqamar@users.noreply.github.com>
2026-08-26 12:48:52 -07:00

497 lines
17 KiB
TypeScript

import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { mkdtempSync, readFileSync, existsSync, rmSync, utimesSync, writeFileSync } from 'node:fs'
import type * as NodeFs from 'node:fs'
import type * as NodeFsPromises from 'node:fs/promises'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { installFakeAppEnvironment } from '../../config/scripts/vitest-host-ports-setup'
// Why these tests exist: will-quit used to run stats.flush() and store.flush() synchronously,
// before preventDefault(). On a stalled network profile mount those fsync/rename syscalls park
// the Electron main thread uninterruptibly — the app stops repainting and Force Quit stops
// working. The teardown deadline cannot rescue that, because its own timer needs the thread it
// would have to bound. The fix is to make the quit path awaitable, not to bound it.
const testState = { dir: '' }
const fsCalls = vi.hoisted(() => {
const calls = {
recording: false,
dirPrefix: '',
syncCalls: [] as string[],
/** Set to park every in-scope async fs call — a stalled mount, without stalling the thread. */
holdAsync: null as Promise<void> | null,
waitAsync: null as ((fn: string, target: string) => Promise<void> | null) | null,
reset(): void {
calls.syncCalls.length = 0
calls.holdAsync = null
calls.waitAsync = null
},
inScope(target: unknown): target is string {
return (
typeof target === 'string' && calls.dirPrefix !== '' && target.startsWith(calls.dirPrefix)
)
},
recordSync(fn: string, target: unknown): void {
if (calls.recording && calls.inScope(target)) {
calls.syncCalls.push(`${fn}:${target}`)
}
}
}
return calls
})
vi.mock('node:fs', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFs>()
const patched: Record<string, unknown> = { ...actual }
for (const name of Object.keys(actual)) {
const original = (actual as unknown as Record<string, unknown>)[name]
if (!name.endsWith('Sync') || typeof original !== 'function') {
continue
}
const fn = original as (...args: unknown[]) => unknown
const wrapper = (...args: unknown[]): unknown => {
fsCalls.recordSync(name, args[0])
return fn(...args)
}
patched[name] = Object.assign(wrapper, fn)
}
return { ...patched, default: patched }
})
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<typeof NodeFsPromises>()
const patched: Record<string, unknown> = { ...actual }
for (const name of [
'stat',
'access',
'rename',
'copyFile',
'rm',
'mkdir',
'open',
'readFile',
'writeFile',
'link'
]) {
const fn = (actual as unknown as Record<string, (...args: unknown[]) => unknown>)[name]
patched[name] = async (...args: unknown[]): Promise<unknown> => {
if (fsCalls.inScope(args[0]) && fsCalls.holdAsync) {
await fsCalls.holdAsync
}
if (fsCalls.inScope(args[0]) && typeof args[0] === 'string') {
await fsCalls.waitAsync?.(name, args[0])
}
return fn(...args)
}
}
return { ...patched, default: patched }
})
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({
getCohortAtEmit: vi.fn().mockReturnValue({ nth_repo_added: 2 })
}))
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`enc:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').slice('enc:'.length)
}
}))
type TestStore = {
updateUI(updates: { sidebarWidth?: number; activeView?: string }): void
setGitHubCache(cache: unknown): void
waitForPendingWrite(): Promise<void>
flushAsync(): Promise<void>
flushOrThrow(): void
stageWorkspaceSessionBeforeUnload(session: Record<string, unknown>): void
}
type TestStatsCollector = {
onAgentStart(ptyId: string, at: number, repo?: string, worktree?: string): void
getSummary(): { totalAgentsSpawned: number; totalAgentTimeMs: number }
flushAsync(): Promise<void>
}
async function createStore(dir: string): Promise<TestStore> {
testState.dir = dir
vi.resetModules()
const { Store, initDataPath } = await import('./persistence')
// Why: userData resolves through AppEnvironment; point it at this file's temp dir.
installFakeAppEnvironment({ getPath: () => testState.dir })
initDataPath()
return new Store() as unknown as TestStore
}
async function createStatsCollector(dir: string): Promise<TestStatsCollector> {
testState.dir = dir
vi.resetModules()
const { StatsCollector, initStatsPath } = await import('./stats/collector')
initStatsPath()
return new StatsCollector() as unknown as TestStatsCollector
}
const dataFile = (dir: string): string => join(dir, 'orca-data.json')
const statsFile = (dir: string): string => join(dir, 'orca-stats.json')
const activeViewFile = (dir: string): string => join(dir, 'active-view.json')
/** Resolves once the macrotask queue turns over — false if the main thread is parked. */
function eventLoopTurned(): Promise<boolean> {
return new Promise((resolve) => setTimeout(() => resolve(true), 0))
}
describe('quit-path durable writes never park the main thread', () => {
const dirs: string[] = []
function makeDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'orca-quit-path-'))
dirs.push(dir)
return dir
}
beforeEach(() => {
fsCalls.recording = false
fsCalls.dirPrefix = ''
fsCalls.reset()
})
afterEach(() => {
vi.useRealTimers()
for (const dir of dirs.splice(0)) {
rmSync(dir, { recursive: true, force: true })
}
})
it('store.flushAsync() issues no synchronous fs syscalls', async () => {
const dir = makeDir()
const store = await createStore(dir)
store.updateUI({ sidebarWidth: 321 })
fsCalls.dirPrefix = dir
fsCalls.recording = true
await store.flushAsync()
fsCalls.recording = false
expect(fsCalls.syncCalls).toEqual([])
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(321)
})
it('renderer unload staging issues no synchronous fs syscalls', async () => {
const dir = makeDir()
const store = await createStore(dir)
fsCalls.dirPrefix = dir
fsCalls.recording = true
const leafId = '11111111-1111-4111-8111-111111111111'
store.stageWorkspaceSessionBeforeUnload({
tabsByWorktree: {
'remote-repo::/remote': [
{
id: 'remote-tab',
ptyId: 'remote-pty',
worktreeId: 'remote-repo::/remote',
title: 'Remote',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'remote-tab': {
root: { type: 'leaf', leafId },
activeLeafId: leafId,
expandedLeafId: null,
buffersByLeafId: { [leafId]: 'remote-scrollback' }
}
},
openFilesByWorktree: {}
})
fsCalls.recording = false
expect(fsCalls.syncCalls).toEqual([])
await store.flushAsync()
const layout = JSON.parse(readFileSync(dataFile(dir), 'utf-8')).workspaceSession
.terminalLayoutsByTabId['remote-tab']
const ref = layout.scrollbackRefsByLeafId[leafId]
expect(layout.buffersByLeafId).toBeUndefined()
expect(readFileSync(join(dir, 'terminal-scrollback', `${ref}.bin`), 'utf-8')).toBe(
'remote-scrollback'
)
})
it('store.flushAsync() also moves the active-view and github-cache sidecars off the thread', async () => {
const dir = makeDir()
const staleCacheTemp = join(dir, 'orca-github-cache.json.999999.1.orphan.tmp')
writeFileSync(staleCacheTemp, 'stale', 'utf-8')
const staleSeconds = (Date.now() - 25 * 60 * 60 * 1000) / 1000
utimesSync(staleCacheTemp, staleSeconds, staleSeconds)
const store = await createStore(dir)
store.updateUI({ activeView: 'activity' })
store.setGitHubCache({ pullRequestsByWorktree: {} })
fsCalls.dirPrefix = dir
fsCalls.recording = true
await store.flushAsync()
fsCalls.recording = false
expect(fsCalls.syncCalls).toEqual([])
expect(JSON.parse(readFileSync(activeViewFile(dir), 'utf-8')).activeView).toBe('activity')
expect(existsSync(join(dir, 'orca-github-cache.json'))).toBe(true)
expect(existsSync(staleCacheTemp)).toBe(false)
})
it('stats.flushAsync() issues no synchronous fs syscalls', async () => {
const dir = makeDir()
const stats = await createStatsCollector(dir)
stats.onAgentStart('pty-1', 1_000, 'repo', 'worktree')
fsCalls.dirPrefix = dir
fsCalls.recording = true
await stats.flushAsync()
fsCalls.recording = false
expect(fsCalls.syncCalls).toEqual([])
expect(JSON.parse(readFileSync(statsFile(dir), 'utf-8')).aggregates.totalAgentsSpawned).toBe(1)
})
it('Grok session cleanup issues no synchronous fs syscalls', async () => {
const dir = makeDir()
const previousGrokHome = process.env.GROK_HOME
process.env.GROK_HOME = dir
vi.resetModules()
const { GrokHookService } = await import('./grok/hook-service')
const service = new GrokHookService()
try {
const configPath = join(dir, 'hooks', 'orca-status.json')
const configDir = join(dir, 'hooks')
const { mkdirSync } = await import('node:fs')
mkdirSync(configDir, { recursive: true })
writeFileSync(
configPath,
`${JSON.stringify({
hooks: {
SessionStart: [
{ hooks: [{ type: 'command', command: '/home/test/.orca/agent-hooks/grok-hook.sh' }] }
]
}
})}\n`
)
fsCalls.dirPrefix = dir
fsCalls.recording = true
await service.removeAsync()
fsCalls.recording = false
expect(fsCalls.syncCalls).toEqual([])
expect(existsSync(configPath)).toBe(false)
} finally {
fsCalls.recording = false
if (previousGrokHome === undefined) {
delete process.env.GROK_HOME
} else {
process.env.GROK_HOME = previousGrokHome
}
}
})
it('stats.flushAsync() closes out live agents before it returns, not when its write lands', async () => {
// will-quit calls this before killAllPty(), which skips runtime.onPtyExit(). If the
// closeout moved behind the await, every agent still running at quit would lose its
// session time — the whole reason the flush is ordered ahead of the kill.
const dir = makeDir()
const stats = await createStatsCollector(dir)
stats.onAgentStart('pty-1', Date.now() - 4_000)
fsCalls.dirPrefix = dir
fsCalls.holdAsync = new Promise(() => {})
const pending = stats.flushAsync()
expect(stats.getSummary().totalAgentTimeMs).toBeGreaterThan(0)
void pending
})
it('a stalled mount leaves the event loop live instead of parking it', async () => {
const dir = makeDir()
const store = await createStore(dir)
store.updateUI({ sidebarWidth: 654 })
fsCalls.dirPrefix = dir
// Every fs call from here on never resolves — the mount is gone.
fsCalls.holdAsync = new Promise(() => {})
const pending = store.flushAsync()
// The whole point: timers still fire, so the app still repaints and the quit
// deadline below can still be reached. A sync flush would have blocked here.
await expect(eventLoopTurned()).resolves.toBe(true)
void pending
})
it('the quit teardown deadline can now bound a wedged state flush', async () => {
const dir = makeDir()
const store = await createStore(dir)
const { settleTeardownWithinDeadline } = await import('./quit-teardown-deadline')
store.updateUI({ sidebarWidth: 999 })
fsCalls.dirPrefix = dir
fsCalls.holdAsync = new Promise(() => {})
const pending = store.flushAsync()
const outstanding = await settleTeardownWithinDeadline(
[{ name: 'state', promise: pending }],
25
)
expect(outstanding).toEqual(['state'])
// Cut short before the rename, so the previous file is still whole — bounded loss, no corruption.
expect(existsSync(dataFile(dir))).toBe(false)
})
it('store.flushAsync() drains an in-flight debounced write before writing', async () => {
vi.useFakeTimers()
const dir = makeDir()
const store = await createStore(dir)
store.updateUI({ sidebarWidth: 100 })
await vi.advanceTimersByTimeAsync(1_000)
store.updateUI({ sidebarWidth: 200 })
const flushed = store.flushAsync()
await vi.runAllTimersAsync()
await flushed
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(200)
})
it('store.flushAsync() is one idempotent final barrier', async () => {
const dir = makeDir()
const store = await createStore(dir)
store.updateUI({ sidebarWidth: 777 })
const first = store.flushAsync()
const second = store.flushAsync()
expect(second).toBe(first)
expect(() => store.flushOrThrow()).toThrow('final persistence')
await first
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(777)
})
it('serializes github-cache snapshots and keeps the newest generation', async () => {
const dir = makeDir()
const store = await createStore(dir)
let releaseWrite!: () => void
const writeRelease = new Promise<void>((resolve) => {
releaseWrite = resolve
})
let signalWrite!: () => void
const writeStarted = new Promise<void>((resolve) => {
signalWrite = resolve
})
let held = false
fsCalls.dirPrefix = dir
fsCalls.recording = true
fsCalls.waitAsync = (fn, target) => {
if (held || fn !== 'writeFile' || !target.includes('orca-github-cache.json')) {
return null
}
held = true
signalWrite()
return writeRelease
}
store.setGitHubCache({ version: 1 })
const finalFlush = store.flushAsync()
await writeStarted
store.setGitHubCache({ version: 2 })
releaseWrite()
await finalFlush
fsCalls.recording = false
expect(JSON.parse(readFileSync(join(dir, 'orca-github-cache.json'), 'utf-8'))).toEqual({
version: 2
})
})
it('stats.flushAsync() wins over a debounced write already in flight', async () => {
const dir = makeDir()
const stats = await createStatsCollector(dir)
stats.onAgentStart('pty-a', 1_000)
stats.onAgentStart('pty-b', 1_000)
const inflight = (stats as unknown as { enqueueWrite(): Promise<void> }).enqueueWrite.call(
stats
)
await stats.flushAsync()
await inflight
// Serialized writes plus the generation guard: the flush's fuller state survives.
const persisted = JSON.parse(readFileSync(statsFile(dir), 'utf-8'))
expect(persisted.aggregates.totalAgentsSpawned).toBe(2)
expect(persisted.aggregates.totalAgentTimeMs).toBeGreaterThan(0)
})
it('the quit flush is the last write — later mutations do not schedule another', async () => {
// A teardown step that touches the store would otherwise arm a debounced write with
// nothing awaiting it, leaving a rename to race the process exit.
vi.useFakeTimers()
const dir = makeDir()
const store = await createStore(dir)
store.updateUI({ sidebarWidth: 10 })
await store.flushAsync()
store.updateUI({ sidebarWidth: 20, activeView: 'tasks' })
fsCalls.dirPrefix = dir
fsCalls.recording = true
await vi.runAllTimersAsync()
fsCalls.recording = false
expect(fsCalls.syncCalls).toEqual([])
expect(JSON.parse(readFileSync(dataFile(dir), 'utf-8')).ui.sidebarWidth).toBe(10)
expect(JSON.parse(readFileSync(activeViewFile(dir), 'utf-8')).activeView).toBe('terminal')
})
it('sweeps orphaned state and stats temp files before the final write', async () => {
const dir = makeDir()
const stateTemp = `${dataFile(dir)}.999999.1.orphan.tmp`
const statsTemp = `${statsFile(dir)}.999999.1.orphan.tmp`
const freshOtherProcessTemp = `${dataFile(dir)}.999998.1.live.tmp`
writeFileSync(stateTemp, 'stale', 'utf-8')
writeFileSync(statsTemp, 'stale', 'utf-8')
writeFileSync(freshOtherProcessTemp, 'in-flight', 'utf-8')
const staleSeconds = (Date.now() - 25 * 60 * 60 * 1000) / 1000
utimesSync(stateTemp, staleSeconds, staleSeconds)
utimesSync(statsTemp, staleSeconds, staleSeconds)
const store = await createStore(dir)
const stats = await createStatsCollector(dir)
store.updateUI({ sidebarWidth: 123 })
stats.onAgentStart('pty-sweep', Date.now())
await Promise.all([store.flushAsync(), stats.flushAsync()])
expect(existsSync(stateTemp)).toBe(false)
expect(existsSync(statsTemp)).toBe(false)
expect(existsSync(freshOtherProcessTemp)).toBe(true)
})
it('store.flushAsync() resolves instead of throwing when the profile mount rejects writes', async () => {
const dir = makeDir()
const store = await createStore(dir)
store.updateUI({ sidebarWidth: 42 })
rmSync(dir, { recursive: true, force: true })
// It joins the teardown barrier; a rejection there is noise that must not cancel the quit.
await expect(store.flushAsync()).resolves.toBeUndefined()
})
})