mirror of
https://github.com/stablyai/orca.git
synced 2026-10-25 00:03:59 +00:00
* 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
354 lines
11 KiB
TypeScript
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()
|
|
})
|
|
})
|