Files
orca/src/main/ipc/orca-profiles.test.ts
T
Neil 8ab85c9bfc fix(quit): stop durable state writes from parking the main thread on quit (#11931)
* fix(quit): stop durable state writes from parking the main thread

will-quit ran stats.flush() and store.flush() synchronously, before
preventDefault(). Both fsync and rename a multi-MB file on the profile
directory. When that directory sits on a stalled network mount the
syscall enters an uninterruptible wait: the app stops repainting and
stops responding to Force Quit, because a process blocked in the kernel
ignores SIGTERM and SIGKILL alike.

The existing 20s teardown deadline could not bound this. Its timer runs
on the very thread the syscall parked, so it never fires. The fix is to
make the quit path awaitable rather than to try to bound it — a quit
that is slow but responsive stays killable by the OS.

- preventDefault() now runs first, so every teardown step is free to await
- stats and state gain flushAsync() twins that use node:fs/promises
- both join the existing teardown barrier, which can now actually bound them
- the pass-2 will-quit re-entry returns early instead of re-running teardown
- quitFlushStarted makes the quit flush the last write, so a teardown step
  touching the store cannot arm a debounce that races process exit

Making the swap async cost the atomicity of check-generation-then-rename:
a writer parked on await rename has already cleared the guard, so a later
synchronous flush could be clobbered by stale state. Both async writers now
claim their temp path, and the sync writers delete it, turning that swap
into a swallowed ENOENT.

Atomic temp+rename is unchanged, so a write cut short by the deadline
leaves the previous file whole — bounded loss, never corruption.

* fix(quit): harden async persistence finalization

* fix(persistence): bound best-effort flushes
2026-08-01 19:22:39 -07:00

354 lines
11 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const {
handlers,
appExitMock,
appQuitMock,
appRelaunchMock,
relaunchAppMock,
destroySystemTrayMock,
createLocalOrcaProfileMock,
getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsentMock,
setActiveOrcaProfileMock,
transferOrcaProfileProjectMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
appQuitMock: vi.fn(),
appRelaunchMock: vi.fn(),
relaunchAppMock: vi.fn(),
destroySystemTrayMock: vi.fn(),
createLocalOrcaProfileMock: vi.fn(),
getOrcaProfileListStateMock: vi.fn(),
seedNewOrcaProfileTelemetryConsentMock: vi.fn(),
setActiveOrcaProfileMock: vi.fn(),
transferOrcaProfileProjectMock: vi.fn()
}))
vi.mock('electron', () => ({
app: {
exit: appExitMock,
quit: appQuitMock,
relaunch: appRelaunchMock,
getPath: () => '/tmp/orca-user-data'
},
ipcMain: {
handle: vi.fn((channel: string, handler: (_event: unknown, args?: unknown) => unknown) => {
handlers.set(channel, handler)
})
}
}))
vi.mock('../tray/system-tray', () => ({
destroySystemTray: destroySystemTrayMock
}))
vi.mock('../app-relaunch', () => ({
relaunchApp: relaunchAppMock
}))
vi.mock('../orca-profiles/profile-index-store', () => ({
createLocalOrcaProfile: createLocalOrcaProfileMock,
getOrcaProfileListState: getOrcaProfileListStateMock,
seedNewOrcaProfileTelemetryConsent: seedNewOrcaProfileTelemetryConsentMock,
setActiveOrcaProfile: setActiveOrcaProfileMock
}))
function makeStoreMock(flushPendingOrThrowAsync = vi.fn()): {
flushPendingOrThrowAsync: typeof flushPendingOrThrowAsync
freezeWrites: ReturnType<typeof vi.fn>
getSettings: () => Record<string, never>
} {
return { flushPendingOrThrowAsync, freezeWrites: vi.fn(), getSettings: () => ({}) }
}
vi.mock('../orca-profiles/profile-project-transfer', () => ({
transferOrcaProfileProject: transferOrcaProfileProjectMock
}))
import { registerOrcaProfileHandlers } from './orca-profiles'
describe('registerOrcaProfileHandlers', () => {
beforeEach(() => {
vi.useFakeTimers()
handlers.clear()
appExitMock.mockReset()
appQuitMock.mockReset()
appRelaunchMock.mockReset()
relaunchAppMock.mockReset()
relaunchAppMock.mockImplementation(() => appRelaunchMock())
destroySystemTrayMock.mockReset()
createLocalOrcaProfileMock.mockReset()
getOrcaProfileListStateMock.mockReset()
seedNewOrcaProfileTelemetryConsentMock.mockReset()
setActiveOrcaProfileMock.mockReset()
transferOrcaProfileProjectMock.mockReset()
})
afterEach(() => {
vi.useRealTimers()
})
it('registers list and create handlers', async () => {
const listState = {
activeProfileId: 'local-default',
profiles: [{ id: 'local-default', name: 'Personal' }]
}
const createState = {
...listState,
profile: { id: 'local-work', name: 'Work' }
}
getOrcaProfileListStateMock.mockReturnValue(listState)
createLocalOrcaProfileMock.mockReturnValue(createState)
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({
...listState,
multiProfileUi: false
})
await expect(
Promise.resolve(handlers.get('orcaProfiles:createLocal')?.(null, { name: 'Work' }))
).resolves.toBe(createState)
expect(createLocalOrcaProfileMock).toHaveBeenCalledWith({ name: 'Work' })
})
it('reports multiProfileUi when the env flag is set', async () => {
const previous = process.env.ORCA_MULTI_PROFILE_UI
process.env.ORCA_MULTI_PROFILE_UI = '1'
try {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(Promise.resolve(handlers.get('orcaProfiles:list')?.(null))).resolves.toEqual({
activeProfileId: 'local-default',
profiles: [],
multiProfileUi: true
})
} finally {
if (previous === undefined) {
delete process.env.ORCA_MULTI_PROFILE_UI
} else {
process.env.ORCA_MULTI_PROFILE_UI = previous
}
}
})
it('marks the target profile active, flushes, and relaunches', async () => {
const flush = vi.fn()
const onBeforeRelaunch = vi.fn()
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
setActiveOrcaProfileMock.mockReturnValue({
activeProfileId: 'local-work',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
const resultPromise = Promise.resolve(
handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })
)
await expect(resultPromise).resolves.toEqual({ status: 'relaunching' })
expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('local-work')
expect(flush).toHaveBeenCalledOnce()
expect(onBeforeRelaunch).toHaveBeenCalledOnce()
expect(flush.mock.invocationCallOrder[0]).toBeLessThan(
setActiveOrcaProfileMock.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY
)
expect(flush).toHaveBeenCalledBefore(onBeforeRelaunch)
expect(appRelaunchMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
expect(relaunchAppMock).toHaveBeenCalledWith('profile-switch')
// Why quit, not exit: before-quit/will-quit teardown (scrollback capture,
// PTY kill, daemon checkpoints) must run on a profile switch.
expect(appQuitMock).toHaveBeenCalledOnce()
expect(appExitMock).not.toHaveBeenCalled()
})
it('does not mark a profile active when current profile flush fails', async () => {
const flush = vi.fn(() => {
throw new Error('flush_failed')
})
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock(flush) as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' }))
).rejects.toThrow('flush_failed')
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
expect(appRelaunchMock).not.toHaveBeenCalled()
})
it('does not switch profiles when persistence cannot reach quiescence', async () => {
const flush = vi.fn(() => new Promise<void>(() => {}))
const onBeforeRelaunch = vi.fn()
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
const switchProfile = Promise.resolve(
handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-work' })
)
const rejection = expect(switchProfile).rejects.toThrow('orca_profile_persistence_timeout')
await vi.advanceTimersByTimeAsync(20_000)
await rejection
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
expect(appRelaunchMock).not.toHaveBeenCalled()
expect(onBeforeRelaunch).not.toHaveBeenCalled()
})
it('does not relaunch when switching to the active profile', async () => {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'local-default',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: 'local-default' }))
).resolves.toEqual({ status: 'already-active' })
expect(setActiveOrcaProfileMock).not.toHaveBeenCalled()
expect(appRelaunchMock).not.toHaveBeenCalled()
})
it('rejects invalid profile ids', async () => {
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(handlers.get('orcaProfiles:switch')?.(null, { profileId: ' ' }))
).rejects.toThrow('invalid_orca_profile_id')
})
it('transfers projects between inactive profiles after flushing active state', async () => {
const flush = vi.fn()
const result = {
status: 'transferred',
mode: 'copy',
sourceProfileId: 'personal',
targetProfileId: 'work',
sourceRepoId: 'repo-1',
targetRepoId: 'repo-2',
targetProjectId: 'repo:repo-2'
}
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'personal',
profiles: []
})
transferOrcaProfileProjectMock.mockReturnValue(result)
registerOrcaProfileHandlers(makeStoreMock(flush) as never)
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: ' personal ',
targetProfileId: ' work ',
repoId: ' repo-1 ',
mode: 'copy'
})
)
).resolves.toBe(result)
expect(flush).toHaveBeenCalledOnce()
expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
},
'/tmp/orca-user-data'
)
})
it('moves a project out of the active profile and relaunches into the target profile', async () => {
const flush = vi.fn()
const onBeforeRelaunch = vi.fn()
const result = {
status: 'transferred',
mode: 'move',
sourceProfileId: 'personal',
targetProfileId: 'work',
sourceRepoId: 'repo-1',
targetRepoId: 'repo-1',
targetProjectId: 'repo:repo-1'
}
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'personal',
profiles: []
})
transferOrcaProfileProjectMock.mockReturnValue(result)
registerOrcaProfileHandlers(makeStoreMock(flush) as never, { onBeforeRelaunch })
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
})
)
).resolves.toEqual({ ...result, willRelaunch: true })
expect(onBeforeRelaunch).toHaveBeenCalledOnce()
expect(flush).toHaveBeenCalledOnce()
expect(transferOrcaProfileProjectMock).toHaveBeenCalledWith(
{
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'move'
},
'/tmp/orca-user-data'
)
expect(setActiveOrcaProfileMock).toHaveBeenCalledWith('work')
expect(appRelaunchMock).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(150)
expect(appRelaunchMock).toHaveBeenCalledOnce()
expect(relaunchAppMock).toHaveBeenCalledWith('profile-transfer')
expect(appQuitMock).toHaveBeenCalledOnce()
expect(appExitMock).not.toHaveBeenCalled()
})
it('rejects transfers that would mutate the active target profile offline', async () => {
getOrcaProfileListStateMock.mockReturnValue({
activeProfileId: 'work',
profiles: []
})
registerOrcaProfileHandlers(makeStoreMock() as never)
await expect(
Promise.resolve(
handlers.get('orcaProfiles:transferProject')?.(null, {
sourceProfileId: 'personal',
targetProfileId: 'work',
repoId: 'repo-1',
mode: 'copy'
})
)
).rejects.toThrow('active_target_orca_profile_transfer_requires_relaunch')
expect(transferOrcaProfileProjectMock).not.toHaveBeenCalled()
})
})