perf(renderer): stop full durable-state save on every top-level view switch (#9002) (#9393)

* perf(renderer): stop full durable-state save on every top-level view switch (#9002)

Persist activeView in a tiny profile-scoped sidecar instead of mutating the monolithic recovery snapshot. Active-view-only updates now bypass the broad UI normalization and durable save scheduler, while a 100ms atomic writer coalesces rapid switches and a synchronous shutdown checkpoint closes the immediate-exit race. Legacy state remains a migration and downgrade fallback.

Coordinate renderer shutdown capture through one guarded checkpoint so workspace sessions and the active-view preference both survive graceful reloads, restarts, and quit cancellation.

Add a persistence-boundary test proving the sidecar stays below 64 bytes while orca-data.json remains byte-for-byte unchanged, plus repeated Windows Electron restart coverage and a path-normalization-safe restart fixture.

* harden active-view sidecar: prototype-safe validator, race-free async swap, independent shutdown flush

- isTopLevelView uses Object.hasOwn so a corrupt sidecar can't smuggle
  inherited keys (constructor/__proto__) through as a valid view.
- writeAsync guards the generation check and rename synchronously (renameSync)
  so a shutdown flushOrThrow can no longer interleave and let a stale async
  rename clobber the freshly-written view.
- shutdown checkpoint flushes the durable store and the active-view sidecar in
  independent try/catch blocks so one store's failure can't skip the other.
  Added regression tests for all three.

Co-authored-by: Orca <help@stably.ai>

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
OrcaWin 2026-07-20 04:14:17 -04:00 committed by GitHub
parent 207db02798
commit 7386ef2857
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
31 changed files with 1241 additions and 191 deletions

View File

@ -0,0 +1,58 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { getActiveViewPreferenceFile } from './active-view-preference'
const testState = { dir: '' }
vi.mock('electron', () => ({
app: { getPath: () => testState.dir },
safeStorage: {
isEncryptionAvailable: () => true,
encryptString: (plaintext: string) => Buffer.from(`encrypted:${plaintext}`, 'utf-8'),
decryptString: (ciphertext: Buffer) => ciphertext.toString('utf-8').replace('encrypted:', '')
}
}))
vi.mock('./telemetry/client', () => ({ track: vi.fn() }))
vi.mock('./telemetry/cohort-classifier', () => ({ getCohortAtEmit: vi.fn() }))
vi.mock('./ssh/ssh-config-parser', () => ({
loadUserSshConfig: vi.fn(),
sshConfigHostsToTargets: vi.fn()
}))
describe('active-view persistence boundary', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-active-view-boundary-'))
})
afterEach(() => {
vi.useRealTimers()
rmSync(testState.dir, { recursive: true, force: true })
})
it('persists a view switch without changing the global durable snapshot', async () => {
vi.useFakeTimers()
vi.resetModules()
const { Store } = await import('./persistence')
const dataFile = join(testState.dir, 'orca-data.json')
const store = new Store({ dataFile })
store.flush()
const durableBefore = readFileSync(dataFile, 'utf-8')
store.updateUI({ activeView: 'settings' })
vi.advanceTimersByTime(1_000)
await store.waitForPendingWrite()
const preferenceFile = getActiveViewPreferenceFile(dataFile)
const preferencePayload = readFileSync(preferenceFile, 'utf-8')
expect(Buffer.byteLength(preferencePayload)).toBeLessThan(64)
expect(JSON.parse(preferencePayload)).toEqual({ activeView: 'settings' })
expect(readFileSync(dataFile, 'utf-8')).toBe(durableBefore)
expect(store.getUI().activeView).toBe('settings')
const reloaded = new Store({ dataFile })
expect(reloaded.getUI().activeView).toBe('settings')
})
})

View File

@ -0,0 +1,91 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { ActiveViewPreference, getActiveViewPreferenceFile } from './active-view-preference'
describe('ActiveViewPreference', () => {
let dir: string
let dataFile: string
beforeEach(() => {
dir = mkdtempSync(join(tmpdir(), 'orca-active-view-'))
dataFile = join(dir, 'orca-data.json')
})
afterEach(() => {
vi.useRealTimers()
rmSync(dir, { recursive: true, force: true })
})
it('migrates the legacy durable-state value into the profile sidecar', async () => {
vi.useFakeTimers()
const preference = new ActiveViewPreference(dataFile, 'tasks')
expect(preference.get()).toBe('tasks')
expect(existsSync(getActiveViewPreferenceFile(dataFile))).toBe(false)
// The renderer reasserts its hydrated value once persistence is ready.
expect(preference.set('tasks')).toBe(false)
vi.advanceTimersByTime(100)
await preference.waitForPendingWrite()
expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
activeView: 'tasks'
})
})
it('coalesces rapid switches into one tiny preference write', async () => {
vi.useFakeTimers()
const preference = new ActiveViewPreference(dataFile, 'terminal')
preference.set('settings')
vi.advanceTimersByTime(50)
preference.set('automations')
vi.advanceTimersByTime(50)
expect(existsSync(getActiveViewPreferenceFile(dataFile))).toBe(false)
vi.advanceTimersByTime(50)
await preference.waitForPendingWrite()
expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
activeView: 'automations'
})
})
it('flushes synchronously for an immediate graceful exit', () => {
vi.useFakeTimers()
const preference = new ActiveViewPreference(dataFile, 'terminal')
preference.set('settings')
preference.flushOrThrow()
expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
activeView: 'settings'
})
vi.advanceTimersByTime(100)
expect(JSON.parse(readFileSync(getActiveViewPreferenceFile(dataFile), 'utf-8'))).toEqual({
activeView: 'settings'
})
})
it('ignores an invalid sidecar and invalid updates', () => {
writeFileSync(getActiveViewPreferenceFile(dataFile), '{"activeView":"unknown"}', 'utf-8')
const preference = new ActiveViewPreference(dataFile, 'not-a-view')
expect(preference.get()).toBe('terminal')
expect(preference.set('also-not-a-view')).toBe(false)
expect(preference.get()).toBe('terminal')
})
it('rejects inherited object keys from a corrupt sidecar', () => {
// Why: `constructor`/`__proto__` are truthy under `in`; the sidecar must not
// treat them as a valid view and leave the main surface blank.
writeFileSync(getActiveViewPreferenceFile(dataFile), '{"activeView":"constructor"}', 'utf-8')
const preference = new ActiveViewPreference(dataFile, 'tasks')
expect(preference.get()).toBe('tasks')
expect(preference.set('__proto__')).toBe(false)
expect(preference.get()).toBe('tasks')
})
})

View File

@ -0,0 +1,136 @@
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'
import { mkdir, rm, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import type { TopLevelView } from '../shared/types'
import { isTopLevelView } from '../shared/top-level-view'
const ACTIVE_VIEW_FILE_NAME = 'active-view.json'
const SAVE_DEBOUNCE_MS = 100
type ActiveViewFile = {
activeView: TopLevelView
}
export function getActiveViewPreferenceFile(dataFile: string): string {
return join(dirname(dataFile), ACTIVE_VIEW_FILE_NAME)
}
function readActiveView(file: string): TopLevelView | null {
try {
const parsed = JSON.parse(readFileSync(file, 'utf-8')) as Partial<ActiveViewFile>
return isTopLevelView(parsed.activeView) ? parsed.activeView : null
} catch {
return null
}
}
function serializeActiveView(activeView: TopLevelView): string {
return `${JSON.stringify({ activeView } satisfies ActiveViewFile)}\n`
}
export class ActiveViewPreference {
private readonly file: string
private activeView: TopLevelView
private persistedActiveView: TopLevelView | null
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | null = null
private writeGeneration = 0
constructor(dataFile: string, legacyActiveView: unknown) {
this.file = getActiveViewPreferenceFile(dataFile)
const storedActiveView = readActiveView(this.file)
const fallbackActiveView = isTopLevelView(legacyActiveView) ? legacyActiveView : 'terminal'
this.activeView = storedActiveView ?? fallbackActiveView
this.persistedActiveView = storedActiveView
}
get(): TopLevelView {
return this.activeView
}
set(value: unknown): boolean {
if (!isTopLevelView(value)) {
return false
}
const changed = value !== this.activeView
this.activeView = value
if (
value !== this.persistedActiveView ||
this.writeTimer !== null ||
this.pendingWrite !== null
) {
this.scheduleSave()
}
return changed
}
private scheduleSave(): void {
this.writeGeneration += 1
if (this.writeTimer) {
clearTimeout(this.writeTimer)
}
this.writeTimer = setTimeout(() => {
this.writeTimer = null
const generation = this.writeGeneration
const activeView = this.activeView
const previousWrite = this.pendingWrite ?? Promise.resolve()
const nextWrite = previousWrite
.then(() => this.writeAsync(activeView, generation))
.catch((error) => {
console.error('[active-view] Failed to persist preference:', error)
})
.finally(() => {
if (this.pendingWrite === nextWrite) {
this.pendingWrite = null
}
})
this.pendingWrite = nextWrite
}, SAVE_DEBOUNCE_MS)
}
private async writeAsync(activeView: TopLevelView, generation: number): Promise<void> {
const tmpFile = `${this.file}.${process.pid}.${generation}.tmp`
let renamed = false
try {
await mkdir(dirname(this.file), { recursive: true })
await writeFile(tmpFile, serializeActiveView(activeView), 'utf-8')
// Why: keep the generation guard and the swap synchronous (no await between),
// or a concurrent flushOrThrow could rename a newer view that this stale write
// then clobbers, restoring the prior view on next launch.
if (generation !== this.writeGeneration) {
return
}
renameSync(tmpFile, this.file)
renamed = true
this.persistedActiveView = activeView
} finally {
if (!renamed) {
await rm(tmpFile).catch(() => {})
}
}
}
flushOrThrow(): void {
if (this.writeTimer) {
clearTimeout(this.writeTimer)
this.writeTimer = null
}
const asyncWriteWasInFlight = this.pendingWrite !== null
this.writeGeneration += 1
this.pendingWrite = null
if (!asyncWriteWasInFlight && this.activeView === this.persistedActiveView) {
return
}
mkdirSync(dirname(this.file), { recursive: true })
const tmpFile = `${this.file}.${process.pid}.${this.writeGeneration}.tmp`
writeFileSync(tmpFile, serializeActiveView(this.activeView), 'utf-8')
renameSync(tmpFile, this.file)
this.persistedActiveView = this.activeView
}
async waitForPendingWrite(): Promise<void> {
if (this.pendingWrite) {
await this.pendingWrite
}
}
}

View File

@ -10,7 +10,8 @@ const {
destroySystemTrayMock,
relaunchAppMock,
showOpenDialogMock,
grantFloatingWorkspaceDirectoryMock
grantFloatingWorkspaceDirectoryMock,
registerRendererShutdownCheckpointHandlerMock
} = vi.hoisted(() => ({
handlers: new Map<string, (_event: unknown, args?: unknown) => unknown>(),
appExitMock: vi.fn(),
@ -20,7 +21,8 @@ const {
destroySystemTrayMock: vi.fn(),
relaunchAppMock: vi.fn(),
showOpenDialogMock: vi.fn(),
grantFloatingWorkspaceDirectoryMock: vi.fn()
grantFloatingWorkspaceDirectoryMock: vi.fn(),
registerRendererShutdownCheckpointHandlerMock: vi.fn()
}))
vi.mock('node:child_process', () => ({
@ -103,6 +105,10 @@ vi.mock('./floating-workspace-directory', () => ({
resolveFloatingTerminalCwd: vi.fn()
}))
vi.mock('./renderer-shutdown-checkpoint', () => ({
registerRendererShutdownCheckpointHandler: registerRendererShutdownCheckpointHandlerMock
}))
import { registerAppHandlers } from './app'
describe('registerAppHandlers', () => {
@ -123,6 +129,7 @@ describe('registerAppHandlers', () => {
relaunchAppMock.mockImplementation(() => appRelaunchMock())
showOpenDialogMock.mockReset()
grantFloatingWorkspaceDirectoryMock.mockReset()
registerRendererShutdownCheckpointHandlerMock.mockReset()
processKillSpy = vi.spyOn(process, 'kill').mockReturnValue(true)
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
@ -133,6 +140,14 @@ describe('registerAppHandlers', () => {
Object.defineProperty(process, 'platform', { value: originalPlatform, configurable: true })
})
it('registers the combined renderer shutdown checkpoint', () => {
const store = {}
registerAppHandlers(store as never)
expect(registerRendererShutdownCheckpointHandlerMock).toHaveBeenCalledWith(store)
})
it('marks relaunch as expected shutdown before exiting', async () => {
const onBeforeRelaunch = vi.fn()
registerAppHandlers({} as never, { onBeforeRelaunch })

View File

@ -21,6 +21,7 @@ import {
resolveFloatingTerminalCwd
} from './floating-workspace-directory'
import { isMarkdownDocumentName, markdownDocumentFromFilePath } from './markdown-documents'
import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint'
const KEYBOARD_INPUT_SOURCE_TIMEOUT_MS = 500
const MAC_HITOOLBOX_DOMAIN = 'com.apple.HIToolbox'
@ -253,6 +254,8 @@ async function readKeyboardInputSourceId(): Promise<string | null> {
}
export function registerAppHandlers(store: Store, options: RegisterAppHandlersOptions = {}): void {
registerRendererShutdownCheckpointHandler(store)
ipcMain.handle('app:getFeatureWallAssetBaseUrl', (): string => getFeatureWallAssetBaseUrl())
ipcMain.handle('app:getIdentity', (): AppIdentity => {

View File

@ -0,0 +1,124 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { syncHandlers } = vi.hoisted(() => ({
syncHandlers: new Map<
string,
(event: { returnValue?: unknown }, args: Record<string, unknown>) => void
>()
}))
vi.mock('electron', () => ({
ipcMain: {
on: vi.fn(
(
channel: string,
handler: (event: { returnValue?: unknown }, args: Record<string, unknown>) => void
) => {
syncHandlers.set(channel, handler)
}
)
}
}))
import { registerRendererShutdownCheckpointHandler } from './renderer-shutdown-checkpoint'
describe('registerRendererShutdownCheckpointHandler', () => {
beforeEach(() => {
syncHandlers.clear()
})
it('commits every shutdown state mutation before flushing both stores', () => {
const callOrder: string[] = []
const store = {
setWorkspaceSession: vi.fn((_state, hostId?: string) => {
callOrder.push(`session:${hostId ?? 'local'}`)
}),
updateUI: vi.fn(() => callOrder.push('ui')),
flushOrThrow: vi.fn(() => callOrder.push('flush')),
flushActiveViewPreferenceOrThrow: vi.fn(() => callOrder.push('active-view'))
}
registerRendererShutdownCheckpointHandler(store as never)
const handler = syncHandlers.get('app:persist-before-unload-sync')
expect(handler).toBeDefined()
const event: { returnValue?: unknown } = {}
const localSession = { activeWorktreeId: 'local-worktree' }
const remoteSession = { activeWorktreeId: 'remote-worktree' }
handler?.(event, {
sessions: [{ state: localSession }, { state: remoteSession, hostId: 'runtime:host-1' }],
ui: { activeView: 'settings' }
})
expect(store.setWorkspaceSession).toHaveBeenNthCalledWith(1, localSession, undefined)
expect(store.setWorkspaceSession).toHaveBeenNthCalledWith(2, remoteSession, 'runtime:host-1')
expect(store.updateUI).toHaveBeenCalledWith({ activeView: 'settings' })
expect(store.flushOrThrow).toHaveBeenCalledTimes(1)
expect(store.flushActiveViewPreferenceOrThrow).toHaveBeenCalledTimes(1)
expect(callOrder).toEqual([
'session:local',
'session:runtime:host-1',
'ui',
'flush',
'active-view'
])
expect(event.returnValue).toEqual({ ok: true })
})
it('reports a failed durable checkpoint so the renderer can retry', () => {
const store = {
setWorkspaceSession: vi.fn(),
updateUI: vi.fn(),
flushOrThrow: vi.fn(() => {
throw new Error('disk full')
}),
flushActiveViewPreferenceOrThrow: vi.fn()
}
registerRendererShutdownCheckpointHandler(store as never)
const handler = syncHandlers.get('app:persist-before-unload-sync')
const event: { returnValue?: unknown } = {}
handler?.(event, { sessions: [], ui: { activeView: 'settings' } })
expect(event.returnValue).toEqual({ ok: false })
})
it('still flushes the active-view sidecar when the durable flush throws', () => {
// Why: the two stores are independent; a durable-state failure must not drop
// the tiny active-view checkpoint (and vice versa).
const store = {
setWorkspaceSession: vi.fn(),
updateUI: vi.fn(),
flushOrThrow: vi.fn(() => {
throw new Error('disk full')
}),
flushActiveViewPreferenceOrThrow: vi.fn()
}
registerRendererShutdownCheckpointHandler(store as never)
const handler = syncHandlers.get('app:persist-before-unload-sync')
const event: { returnValue?: unknown } = {}
handler?.(event, { sessions: [], ui: { activeView: 'settings' } })
expect(store.flushActiveViewPreferenceOrThrow).toHaveBeenCalledTimes(1)
expect(event.returnValue).toEqual({ ok: false })
})
it('flushes the durable store even when the active-view flush throws', () => {
const store = {
setWorkspaceSession: vi.fn(),
updateUI: vi.fn(),
flushOrThrow: vi.fn(),
flushActiveViewPreferenceOrThrow: vi.fn(() => {
throw new Error('disk full')
})
}
registerRendererShutdownCheckpointHandler(store as never)
const handler = syncHandlers.get('app:persist-before-unload-sync')
const event: { returnValue?: unknown } = {}
handler?.(event, { sessions: [], ui: { activeView: 'settings' } })
expect(store.flushOrThrow).toHaveBeenCalledTimes(1)
expect(event.returnValue).toEqual({ ok: false })
})
})

View File

@ -0,0 +1,41 @@
import { ipcMain } from 'electron'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { PersistedUIState, WorkspaceSessionState } from '../../shared/types'
import type { Store } from '../persistence'
type PersistBeforeUnloadSyncArgs = {
sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[]
ui: Partial<PersistedUIState>
}
export function registerRendererShutdownCheckpointHandler(store: Store): void {
ipcMain.on('app:persist-before-unload-sync', (event, args: PersistBeforeUnloadSyncArgs) => {
let ok = true
// Why: apply both renderer-owned snapshots before synchronously flushing
// each owning store, so an immediate exit cannot outrun either update.
try {
for (const { state, hostId } of args.sessions) {
store.setWorkspaceSession(state, hostId)
}
store.updateUI(args.ui)
} catch (error) {
console.error('[app] Failed to stage renderer state before unload:', error)
ok = false
}
// Why: the durable snapshot and the active-view sidecar are independent stores;
// flush each on its own so one store's failure can't skip the other's checkpoint.
try {
store.flushOrThrow()
} catch (error) {
console.error('[app] Failed to flush durable state before unload:', error)
ok = false
}
try {
store.flushActiveViewPreferenceOrThrow()
} catch (error) {
console.error('[app] Failed to flush active-view preference before unload:', error)
ok = false
}
event.returnValue = { ok }
})
}

View File

@ -217,6 +217,7 @@ import { normalizeTerminalLineHeight } from '../shared/terminal-line-height-sett
import { normalizeUiLanguage } from '../shared/ui-language'
import { normalizeBrowserPageZoomLevel } from '../shared/browser-page-zoom'
import { persistedUIValuesEqual } from '../shared/persisted-ui-equality'
import { ActiveViewPreference } from './active-view-preference'
import {
normalizeFolderWorkspaceName,
normalizeFolderWorkspaces
@ -2627,6 +2628,7 @@ export type StoreOptions = {
export class Store {
private state: PersistedState
private readonly dataFile: string
private readonly activeViewPreference: ActiveViewPreference
private readonly terminalScrollbackSnapshotStorage: TerminalScrollbackSnapshotStorage
private writeTimer: ReturnType<typeof setTimeout> | null = null
private pendingWrite: Promise<void> | null = null
@ -2667,6 +2669,9 @@ export class Store {
const loaded = this.load()
const normalized = normalizePersistedPaneIdentityState(loaded)
this.state = normalized.state
// Why: activeView is a frequent, tiny preference; keeping it beside the
// profile avoids serializing the multi-MB recovery store on navigation.
this.activeViewPreference = new ActiveViewPreference(this.dataFile, this.state.ui?.activeView)
const adaptedProjectGroups = this.adaptFlatFolderScanProjectGroups()
for (const entry of normalized.migrationUnsupportedEntries) {
setMigrationUnsupportedPty(entry)
@ -3698,9 +3703,7 @@ export class Store {
/** Wait for any in-flight async disk write to complete. Used in tests. */
async waitForPendingWrite(): Promise<void> {
if (this.pendingWrite) {
await this.pendingWrite
}
await Promise.all([this.pendingWrite, this.activeViewPreference.waitForPendingWrite()])
}
// Why githubCache is omitted: it is memory-only during the session (see
@ -3857,6 +3860,10 @@ export class Store {
this.writeToDiskSync({ force: asyncWriteWasInFlight })
}
flushActiveViewPreferenceOrThrow(): void {
this.activeViewPreference.flushOrThrow()
}
// ── Repos ──────────────────────────────────────────────────────────
getRepos(): Repo[] {
@ -5490,17 +5497,31 @@ export class Store {
),
featureTipsSeenIds: normalizeFeatureTipIds(this.state.ui?.featureTipsSeenIds),
contextualToursSeenIds: normalizeContextualTourIds(this.state.ui?.contextualToursSeenIds),
featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions)
featureInteractions: normalizeFeatureInteractions(this.state.ui?.featureInteractions),
activeView: this.activeViewPreference.get()
}
}
updateUI(updates: Partial<PersistedState['ui']>): void {
const sanitizedUpdates = stripMainOwnedTelemetryMarkerFromUI(updates)
const previousUI = this.getUI()
const { activeView, ...durableUpdates } = sanitizedUpdates
const activeViewChanged = this.activeViewPreference.set(activeView)
if (Object.keys(durableUpdates).length === 0) {
if (activeViewChanged) {
this.notifyUIChanged()
}
return
}
const currentUI = {
...getDefaultUIState(),
...stripMainOwnedTelemetryMarkerFromUI(this.state.ui)
}
const previousUI = {
...this.getUI(),
// Why: the legacy field stays unchanged as a migration/downgrade
// fallback; the profile sidecar is authoritative in current builds.
activeView: currentUI.activeView
}
const nextRightSidebarTab =
sanitizedUpdates.rightSidebarTab !== undefined
? normalizeRightSidebarTab(sanitizedUpdates.rightSidebarTab)
@ -5519,16 +5540,17 @@ export class Store {
)
const nextUI = {
...currentUI,
...sanitizedUpdates,
groupBy: sanitizedUpdates.groupBy
? normalizeGroupBy(sanitizedUpdates.groupBy)
...durableUpdates,
groupBy: durableUpdates.groupBy
? normalizeGroupBy(durableUpdates.groupBy)
: normalizeGroupBy(this.state.ui?.groupBy),
sortBy: sanitizedUpdates.sortBy
? normalizeSortBy(sanitizedUpdates.sortBy)
sortBy: durableUpdates.sortBy
? normalizeSortBy(durableUpdates.sortBy)
: normalizeSortBy(this.state.ui?.sortBy),
projectOrderBy: updates.projectOrderBy
? normalizeProjectOrderBy(updates.projectOrderBy)
: normalizeProjectOrderBy(this.state.ui?.projectOrderBy),
activeView: currentUI.activeView,
rightSidebarTab: nextRightSidebarTab,
rightSidebarExplorerView: nextRightSidebarExplorerView,
worktreeCardProperties:
@ -5603,6 +5625,9 @@ export class Store {
: normalizeFeatureInteractions(this.state.ui?.featureInteractions)
}
if (persistedUIValuesEqual(previousUI, nextUI)) {
if (activeViewChanged) {
this.notifyUIChanged()
}
return
}
this.state.ui = nextUI
@ -6595,6 +6620,11 @@ export class Store {
} catch (err) {
console.error('[persistence] Failed to flush state:', err)
}
try {
this.flushActiveViewPreferenceOrThrow()
} catch (err) {
console.error('[active-view] Failed to flush preference:', err)
}
this.writeGithubCacheSnapshotSync()
}

View File

@ -1484,6 +1484,7 @@ describe('createMainWindow', () => {
windowHandlers['will-prevent-unload']()
expect(onQuitAborted).toHaveBeenCalledTimes(1)
expect(webContents.send).toHaveBeenCalledWith('window:unload-prevented')
})
it('allows close after the renderer process is gone', () => {

View File

@ -1148,6 +1148,7 @@ export function createMainWindow(
// aborting Cmd+Q still gets their size saved.
windowClosing = false
opts?.onQuitAborted?.()
mainWindow.webContents.send('window:unload-prevented')
})
const onConfirmClose = (): void => {

View File

@ -928,6 +928,12 @@ export type AppApi = {
/** Reloads the current app renderer through main so expected renderer
* teardown can be classified before Electron emits process-gone events. */
reload: () => Promise<void>
/** Commits the renderer's final locally durable state before unload and
* throws when the blocking durable write fails. */
persistBeforeUnloadSync: (args: {
sessions: { state: WorkspaceSessionState; hostId?: ExecutionHostId }[]
ui: Partial<PersistedUIState>
}) => void
/** Resolves when the daemon PTY provider and hook receiver have either
* started or failed open for the first BrowserWindow. */
awaitFirstWindowStartupServices: () => Promise<void>

View File

@ -184,16 +184,13 @@ import type {
NativeChatReadSessionResult,
NativeChatSubscriptionFrame
} from './api-types'
import {
ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT,
type EditorPrepareHotExitDetail
} from '../shared/editor-save-events'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_APP_RESTART_STARTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT
} from '../shared/updater-renderer-events'
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../shared/renderer-shutdown-events'
import {
ORCA_INTERNAL_FILE_DRAG_TYPE,
createNativeFileDropPayload,
@ -228,11 +225,26 @@ import type {
ReactErrorBoundaryReportResult
} from '../shared/crash-reporting'
import type { PreloadApi } from './api-types'
import {
createUpdaterQuitAbortRelay,
prepareRendererForAppRestart
} from './renderer-restart-preparation'
type NativeFileDropCallback = (data: NativeFileDropPayload) => void
const nativeFileDropCallbacks: NativeFileDropCallback[] = []
let nativeFileDropListenerRegistered = false
const updaterQuitAbortRelay = createUpdaterQuitAbortRelay(
window,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT
)
ipcRenderer.on('updater:status', (_event, status: UpdateStatus) => {
updaterQuitAbortRelay.handleStatus(status)
})
ipcRenderer.on('window:unload-prevented', () => {
window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
})
function getLinuxDisplayServer(): 'wayland' | 'x11' | null {
if (process.platform !== 'linux') {
@ -248,54 +260,6 @@ function getLinuxDisplayServer(): 'wayland' | 'x11' | null {
return process.env.DISPLAY ? 'x11' : null
}
type AppRestartPrepOptions = {
startedEventName: string
abortedEventName: string
}
function requestEditorHotExitBackup(): Promise<void> {
return new Promise<void>((resolve, reject) => {
let claimed = false
window.dispatchEvent(
new CustomEvent<EditorPrepareHotExitDetail>(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, {
detail: {
claim: () => {
claimed = true
},
resolve,
reject: (message) => {
reject(new Error(message))
}
}
})
)
// Why: restart paths can run before the editor autosave controller mounts.
// With no claimant, there are no renderer-owned dirty buffers to back up.
if (!claimed) {
resolve()
}
})
}
async function prepareRendererForAppRestart({
startedEventName,
abortedEventName
}: AppRestartPrepOptions): Promise<void> {
window.dispatchEvent(new Event(startedEventName))
try {
await requestEditorHotExitBackup()
} catch (error) {
window.dispatchEvent(new Event(abortedEventName))
throw error
}
// Dispatch beforeunload now so terminal buffers are captured while panes are
// still mounted; update installs later bypass the ordinary close sequence.
window.dispatchEvent(new Event('beforeunload'))
}
const onNativeFileDrop = (_event: Electron.IpcRendererEvent, data: NativeFileDropPayload): void => {
for (const callback of Array.from(nativeFileDropCallbacks)) {
callback(data)
@ -474,7 +438,7 @@ const api = {
ipcRenderer.invoke('app:getFeatureWallAssetBaseUrl'),
relaunch: (): Promise<void> => ipcRenderer.invoke('app:relaunch'),
restart: async (): Promise<void> => {
await prepareRendererForAppRestart({
await prepareRendererForAppRestart(window, {
startedEventName: ORCA_APP_RESTART_STARTED_EVENT,
abortedEventName: ORCA_APP_RESTART_ABORTED_EVENT
})
@ -486,6 +450,16 @@ const api = {
}
},
reload: (): Promise<void> => ipcRenderer.invoke('app:reload'),
persistBeforeUnloadSync: (
args: Parameters<PreloadApi['app']['persistBeforeUnloadSync']>[0]
) => {
const result = ipcRenderer.sendSync('app:persist-before-unload-sync', args) as {
ok?: unknown
}
if (result?.ok !== true) {
throw new Error('Failed to persist renderer state before unload.')
}
},
awaitFirstWindowStartupServices: (): Promise<void> =>
ipcRenderer.invoke('app:awaitFirstWindowStartupServices'),
startupDiagnostic: (event: string, details?: Record<string, unknown>): Promise<void> =>
@ -2766,14 +2740,15 @@ const api = {
download: () => ipcRenderer.invoke('updater:download'),
dismissNudge: () => ipcRenderer.invoke('updater:dismissNudge'),
quitAndInstall: async (): Promise<void> => {
await prepareRendererForAppRestart({
await prepareRendererForAppRestart(window, {
startedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_STARTED_EVENT,
abortedEventName: ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT
})
updaterQuitAbortRelay.markPrepared()
try {
return await ipcRenderer.invoke('updater:quitAndInstall')
} catch (error) {
window.dispatchEvent(new Event(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT))
updaterQuitAbortRelay.abort()
throw error
}
},

View File

@ -0,0 +1,90 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import type { UpdateStatus } from '../shared/types'
import {
createUpdaterQuitAbortRelay,
prepareRendererForAppRestart
} from './renderer-restart-preparation'
describe('prepareRendererForAppRestart', () => {
it('aborts when the dispatched shutdown checkpoint prevents unload', async () => {
const eventTarget = new EventTarget()
const started = vi.fn()
const aborted = vi.fn()
const checkpoint = vi.fn((event: Event) => event.preventDefault())
eventTarget.addEventListener('restart-started', started)
eventTarget.addEventListener('restart-aborted', aborted)
eventTarget.addEventListener('beforeunload', checkpoint)
await expect(
prepareRendererForAppRestart(eventTarget, {
startedEventName: 'restart-started',
abortedEventName: 'restart-aborted'
})
).rejects.toThrow('Renderer shutdown checkpoint was not completed.')
expect(started).toHaveBeenCalledTimes(1)
expect(checkpoint).toHaveBeenCalledTimes(1)
expect(aborted).toHaveBeenCalledTimes(1)
})
})
describe('createUpdaterQuitAbortRelay', () => {
it('resets a prepared update restart when async updater status reports failure', () => {
const eventTarget = new EventTarget()
const aborted = vi.fn()
eventTarget.addEventListener('update-restart-aborted', aborted)
const relay = createUpdaterQuitAbortRelay(eventTarget, 'update-restart-aborted')
relay.markPrepared()
relay.handleStatus({ state: 'error', message: 'install failed' } satisfies UpdateStatus)
relay.handleStatus({ state: 'error', message: 'duplicate failure' } satisfies UpdateStatus)
expect(aborted).toHaveBeenCalledTimes(1)
})
it('ignores updater errors when no update restart was prepared', () => {
const eventTarget = new EventTarget()
const aborted = vi.fn()
eventTarget.addEventListener('update-restart-aborted', aborted)
const relay = createUpdaterQuitAbortRelay(eventTarget, 'update-restart-aborted')
relay.handleStatus({ state: 'error', message: 'check failed' } satisfies UpdateStatus)
expect(aborted).not.toHaveBeenCalled()
})
})
describe('preload restart wiring', () => {
const source = readFileSync(join(process.cwd(), 'src/preload/index.ts'), 'utf8')
it('relays prevented unload and async updater failure IPC into renderer lifecycle events', () => {
expect(source).toContain("ipcRenderer.on('updater:status'")
expect(source).toContain('updaterQuitAbortRelay.handleStatus(status)')
expect(source).toContain("ipcRenderer.on('window:unload-prevented'")
expect(source).toContain(
'window.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))'
)
})
it('marks updater preparation before invoking main and aborts it on immediate IPC failure', () => {
const start = source.indexOf('quitAndInstall: async (): Promise<void> => {')
const end = source.indexOf('onStatus: (callback) => {', start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const block = source.slice(start, end)
const prepare = block.indexOf('await prepareRendererForAppRestart(window, {')
const markPrepared = block.indexOf('updaterQuitAbortRelay.markPrepared()')
const invoke = block.indexOf("ipcRenderer.invoke('updater:quitAndInstall')")
const abort = block.indexOf('updaterQuitAbortRelay.abort()')
expect(prepare).toBeGreaterThanOrEqual(0)
expect(markPrepared).toBeGreaterThan(prepare)
expect(invoke).toBeGreaterThan(markPrepared)
expect(abort).toBeGreaterThan(invoke)
expect(block).toMatch(
/try \{\s*return await ipcRenderer\.invoke\('updater:quitAndInstall'\)\s*\} catch \(error\) \{\s*updaterQuitAbortRelay\.abort\(\)\s*throw error\s*\}/
)
})
})

View File

@ -0,0 +1,89 @@
import {
ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT,
type EditorPrepareHotExitDetail
} from '../shared/editor-save-events'
import type { UpdateStatus } from '../shared/types'
export type AppRestartPrepOptions = {
startedEventName: string
abortedEventName: string
}
function requestEditorHotExitBackup(eventTarget: EventTarget): Promise<void> {
return new Promise<void>((resolve, reject) => {
let claimed = false
eventTarget.dispatchEvent(
new CustomEvent<EditorPrepareHotExitDetail>(ORCA_EDITOR_PREPARE_HOT_EXIT_EVENT, {
detail: {
claim: () => {
claimed = true
},
resolve,
reject: (message) => {
reject(new Error(message))
}
}
})
)
// Why: restart paths can run before the editor autosave controller mounts.
// With no claimant, there are no renderer-owned dirty buffers to back up.
if (!claimed) {
resolve()
}
})
}
export async function prepareRendererForAppRestart(
eventTarget: EventTarget,
{ startedEventName, abortedEventName }: AppRestartPrepOptions
): Promise<void> {
eventTarget.dispatchEvent(new Event(startedEventName))
try {
await requestEditorHotExitBackup(eventTarget)
// Why: update installs can bypass native close. A cancelable synthetic
// unload both captures mounted terminals and reports checkpoint failure.
const accepted = eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true }))
if (!accepted) {
throw new Error('Renderer shutdown checkpoint was not completed.')
}
} catch (error) {
eventTarget.dispatchEvent(new Event(abortedEventName))
throw error
}
}
export type UpdaterQuitAbortRelay = {
markPrepared: () => void
abort: () => void
handleStatus: (status: UpdateStatus) => void
}
export function createUpdaterQuitAbortRelay(
eventTarget: EventTarget,
abortedEventName: string
): UpdaterQuitAbortRelay {
let prepared = false
const abort = (): void => {
if (!prepared) {
return
}
prepared = false
eventTarget.dispatchEvent(new Event(abortedEventName))
}
return {
markPrepared(): void {
prepared = true
},
abort,
handleStatus(status): void {
// Why: quitAndInstall IPC resolves after scheduling; a later updater
// error is the authoritative signal that the app will remain open.
if (status.state === 'error') {
abort()
}
}
}
}

View File

@ -110,16 +110,26 @@ import { useWebSessionTabsSync } from './runtime/web-session-tabs-sync'
import { useGlobalFileDrop } from './hooks/useGlobalFileDrop'
import { useRadixBodyPointerEventsRecovery } from './hooks/useRadixBodyPointerEventsRecovery'
import { registerUpdaterBeforeUnloadBypass } from './lib/updater-beforeunload'
import {
ORCA_APP_RESTART_ABORTED_EVENT,
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT
} from '../../shared/updater-renderer-events'
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../shared/renderer-shutdown-events'
import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
} from './lib/workspace-session'
import { createSessionWriteSubscriber } from './lib/session-write-subscriber'
import { buildActiveViewUnloadPatch } from './lib/active-view-persist'
import {
buildWorkspaceSessionHostSnapshots,
fetchWorkspaceSessionWithRuntimeHostOwners,
patchWorkspaceSessionByHost,
persistWorkspaceSessionByHostSync
patchWorkspaceSessionByHost
} from './lib/workspace-session-host-persistence'
import {
createShutdownCheckpointBeforeUnloadHandler,
createShutdownCheckpointGuard
} from './lib/shutdown-checkpoint-guard'
import { collectFolderWorkspaceKeysFromSession } from './lib/workspace-session-hydration-keys'
import {
getStartupErrorFallbackUI,
@ -1342,8 +1352,8 @@ function App(): React.JSX.Element {
})
}, [])
// On shutdown, capture terminal scrollback buffers and flush to disk.
// Runs synchronously in beforeunload: capture → Zustand set → sendSync → flush.
// On shutdown, capture terminal scrollback buffers and flush all durable
// renderer state through one synchronous main-process checkpoint.
useEffect(() => {
// Why: beforeunload fires twice during a manual quit — once from the
// synthetic dispatch in the onWindowCloseRequested handler (captures
@ -1352,39 +1362,50 @@ function App(): React.JSX.Element {
// two firings, PTY exit events can arrive and unmount TerminalPanes,
// emptying shutdownBufferCaptures. The guard prevents the second call
// from overwriting the good session data with an empty snapshot.
let shutdownBuffersCaptured = false
const captureAndFlush = (): void => {
if (shutdownBuffersCaptured) {
return
}
if (!shouldPersistWorkspaceSession(useAppStore.getState())) {
return
}
for (const capture of shutdownBufferCaptures.values()) {
try {
capture({ includeLocalBuffers: false })
} catch {
// Don't let one pane's failure block the rest.
const shutdownCheckpoint = createShutdownCheckpointGuard(() => {
const shouldCaptureSession = shouldPersistWorkspaceSession(useAppStore.getState())
if (shouldCaptureSession) {
for (const capture of shutdownBufferCaptures.values()) {
try {
capture({ includeLocalBuffers: false })
} catch {
// Don't let one pane's failure block the rest.
}
}
// Why: agent provider session ids live only in agentStatusByPaneKey,
// which is in-memory. Capture them into the persisted sleeping-session
// map so a daemon/session death while the app is closed can still
// cold-restore via the agent's resume command (#5232).
useAppStore.getState().captureAllSleepingAgentSessions('quit')
}
// Why: agent provider session ids live only in agentStatusByPaneKey,
// which is in-memory. Capture them into the persisted sleeping-session
// map so a daemon/session death while the app is closed can still
// cold-restore via the agent's resume command (#5232).
useAppStore.getState().captureAllSleepingAgentSessions('quit')
// Why: re-read state after capture() calls populated scrollback buffers
// into the store via Zustand setters. The earlier read is only for the
// gating flags and would miss those updates.
const freshState = useAppStore.getState()
persistWorkspaceSessionByHostSync(
window.api.session,
buildWorkspaceSessionPayload(freshState),
freshState
const sessionSnapshots = shouldCaptureSession
? buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState)
: []
// Why: one blocking checkpoint closes the immediate-quit race for both
// the narrow view preference and the larger session recovery snapshots.
window.api.app.persistBeforeUnloadSync({
sessions: sessionSnapshots,
ui: buildActiveViewUnloadPatch(freshState)
})
})
const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint)
window.addEventListener('beforeunload', persistBeforeUnload)
window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)
window.addEventListener(ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT, shutdownCheckpoint.reset)
window.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset)
return () => {
window.removeEventListener('beforeunload', persistBeforeUnload)
window.removeEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)
window.removeEventListener(
ORCA_UPDATER_QUIT_AND_INSTALL_ABORTED_EVENT,
shutdownCheckpoint.reset
)
shutdownBuffersCaptured = true
window.removeEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset)
}
window.addEventListener('beforeunload', captureAndFlush)
return () => window.removeEventListener('beforeunload', captureAndFlush)
}, [])
// Why: beforeunload never fires on a hard kill (crash, forced update
@ -1448,10 +1469,10 @@ function App(): React.JSX.Element {
hideAutomationGeneratedWorkspaces,
showDotfilesByWorktree,
filterRepoIds,
// Why: persist the active view so a reload restores it. openTaskPage etc.
// mutate activeView directly (not via setActiveView), so the value-keyed
// writer is what catches every transition.
activeView,
// Why (#9002): activeView is deliberately NOT included here. It used to
// ride this same 150ms writer (#8265), which meant every top-level view
// switch scheduled a full durable-state save. The narrow preference
// effect below persists it without touching the recovery snapshot.
// Why: rides the same debounced save so dashboard auto-acks (which fire
// on focus/visibility) and the in-memory ack cleanup paths in
// agent-status.ts (close/dismiss) both flow to disk through map
@ -1478,10 +1499,18 @@ function App(): React.JSX.Element {
hideAutomationGeneratedWorkspaces,
showDotfilesByWorktree,
filterRepoIds,
activeView,
acknowledgedAgentsByPaneKey
])
// Why (#9002): activeView has its own tiny profile preference, so it can track
// every switch without scheduling the multi-MB durable-state writer.
useEffect(() => {
if (!persistedUIReady) {
return
}
void window.api.ui.set({ activeView })
}, [activeView, persistedUIReady])
// Apply theme to document
useEffect(() => {
if (!settings) {

View File

@ -254,4 +254,71 @@ describe('renderer startup runtime routing', () => {
expect(source).toContain('statusBarVisible ? (')
expect(source).toContain('h-6 min-h-[24px] shrink-0 border-t border-border')
})
it('keeps activeView off the 150ms debounced UI writer hot path (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const writerStart = source.indexOf('const timer = window.setTimeout(() => {')
const writerEnd = source.indexOf('}, 150)', writerStart)
const writerBlock = source.slice(writerStart, writerEnd)
expect(writerStart).toBeGreaterThanOrEqual(0)
expect(writerEnd).toBeGreaterThan(writerStart)
// Why: this field riding the writer's payload (#8265) is exactly the
// #9002 regression — every switch scheduled a full durable-state save. It
// must persist through its narrow preference or unload path instead. Matched as
// a standalone object-literal property (not the surrounding prose, which
// legitimately references the field name) so the assertion is precise.
expect(writerBlock).not.toMatch(/^\s*activeView,\s*$/m)
const depsStart = source.indexOf('}, [', writerEnd)
const depsEnd = source.indexOf('])', depsStart)
const depsBlock = source.slice(depsStart, depsEnd)
expect(depsBlock).not.toMatch(/^\s*activeView,?\s*$/m)
})
it('persists activeView through its narrow preference on every switch (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const preferenceEffect = [
'// Why (#9002): activeView has its own tiny profile preference',
'void window.api.ui.set({ activeView })',
'}, [activeView, persistedUIReady])'
]
for (const marker of preferenceEffect) {
expect(source).toContain(marker)
}
expect(source).not.toContain('createActiveViewIdleFlush')
expect(source).not.toContain("window.addEventListener('blur', handleBlur)")
})
it('checkpoints activeView and all session snapshots through one beforeunload handler (#9002)', () => {
const source = readFileSync(join(process.cwd(), 'src/renderer/src/App.tsx'), 'utf8')
const checkpointStart = source.indexOf(
'const shutdownCheckpoint = createShutdownCheckpointGuard(() => {'
)
const checkpointEnd = source.indexOf(
'const persistBeforeUnload = createShutdownCheckpointBeforeUnloadHandler(shutdownCheckpoint)',
checkpointStart
)
expect(checkpointStart).toBeGreaterThanOrEqual(0)
expect(checkpointEnd).toBeGreaterThan(checkpointStart)
const checkpointBlock = source.slice(checkpointStart, checkpointEnd)
expect(checkpointBlock).toContain('const sessionSnapshots = shouldCaptureSession')
expect(checkpointBlock).toContain(
'buildWorkspaceSessionHostSnapshots(buildWorkspaceSessionPayload(freshState), freshState)'
)
expect(checkpointBlock).toContain('window.api.app.persistBeforeUnloadSync({')
expect(checkpointBlock).toContain('sessions: sessionSnapshots')
expect(checkpointBlock).toContain('ui: buildActiveViewUnloadPatch(freshState)')
expect(source).toContain(
'window.addEventListener(ORCA_APP_RESTART_ABORTED_EVENT, shutdownCheckpoint.reset)'
)
expect(source).toContain(
'window.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, shutdownCheckpoint.reset)'
)
expect(source).toContain("window.addEventListener('beforeunload', persistBeforeUnload)")
expect(source.match(/window\.addEventListener\('beforeunload'/g) ?? []).toHaveLength(1)
expect(source).not.toContain('window.api.ui.setSync')
})
})

View File

@ -34,6 +34,7 @@ import {
requestEditorSaveQuiesce
} from './editor/editor-autosave'
import { isIntentionalAppRestartInProgress } from '@/lib/updater-beforeunload'
import { preventUnloadAndScheduleShutdownCheckpointReset } from '@/lib/shutdown-checkpoint-guard'
import EditorAutosaveController from './editor/EditorAutosaveController'
import type { Tab, TabContentType, TabGroupLayoutNode, TuiAgent } from '../../../shared/types'
import { hasFeatureInteraction } from '../../../shared/feature-interactions'
@ -448,40 +449,49 @@ function Terminal(): React.JSX.Element | null {
// dirty, so an immediate confirm would leave the window open with no UI.
const windowCloseAfterDirtyRef = useRef<{ isQuitting: boolean } | null>(null)
const proceedToNativeWindowClose = useCallback((isQuitting: boolean) => {
// Why: defer this synthetic unload until we are actually ready to close so
// a dirty-tab preventDefault() does not fire during the initial quit IPC
// (that path can emit will-prevent-unload and clear isQuitting in main).
window.dispatchEvent(new Event('beforeunload'))
if (!isQuitting) {
const state = useAppStore.getState()
const localPtyIds = Object.entries(state.tabsByWorktree).flatMap(
([worktreeId, worktreeTabs]) => {
const connectionId = getConnectionId(worktreeId)
if (connectionId !== null) {
return []
}
return worktreeTabs
.flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? [])
.filter((ptyId) => !isRemoteRuntimePtyId(ptyId))
}
)
if (localPtyIds.length > 0) {
void Promise.all(localPtyIds.map((id) => window.api.pty.hasChildProcesses(id))).then(
(results) => {
if (results.some(Boolean)) {
setWindowCloseDialogOpen(true)
} else {
window.api.ui.confirmWindowClose()
}
}
)
return
}
const confirmNativeWindowClose = useCallback(() => {
// Why: capture only after every close guard has committed. A canceled child-
// process prompt must not consume App's synthetic/native unload guard.
const accepted = window.dispatchEvent(new Event('beforeunload', { cancelable: true }))
if (!accepted) {
return
}
window.api.ui.confirmWindowClose()
}, [])
const proceedToNativeWindowClose = useCallback(
(isQuitting: boolean) => {
if (!isQuitting) {
const state = useAppStore.getState()
const localPtyIds = Object.entries(state.tabsByWorktree).flatMap(
([worktreeId, worktreeTabs]) => {
const connectionId = getConnectionId(worktreeId)
if (connectionId !== null) {
return []
}
return worktreeTabs
.flatMap((tab) => state.ptyIdsByTabId[tab.id] ?? [])
.filter((ptyId) => !isRemoteRuntimePtyId(ptyId))
}
)
if (localPtyIds.length > 0) {
void Promise.all(localPtyIds.map((id) => window.api.pty.hasChildProcesses(id))).then(
(results) => {
if (results.some(Boolean)) {
setWindowCloseDialogOpen(true)
} else {
confirmNativeWindowClose()
}
}
)
return
}
}
confirmNativeWindowClose()
},
[confirmNativeWindowClose]
)
const waitForFileClosed = useCallback((fileId: string, timeoutMs: number): Promise<boolean> => {
if (!useAppStore.getState().openFiles.some((f) => f.id === fileId)) {
return Promise.resolve(true)
@ -2061,7 +2071,7 @@ function Terminal(): React.JSX.Element | null {
}
const dirtyFiles = useAppStore.getState().openFiles.filter((f) => f.isDirty)
if (dirtyFiles.length > 0) {
e.preventDefault()
preventUnloadAndScheduleShutdownCheckpointReset(e, window)
}
}
window.addEventListener('beforeunload', handler)
@ -2545,7 +2555,7 @@ function Terminal(): React.JSX.Element | null {
autoFocus
onClick={() => {
setWindowCloseDialogOpen(false)
window.api.ui.confirmWindowClose()
confirmNativeWindowClose()
}}
>
{translate('auto.components.Terminal.73768427cf', 'Close')}

View File

@ -384,7 +384,7 @@ describe('attachEditorAutosaveController', () => {
}
})
it('backs up dirty editor drafts for hot exit without writing files', async () => {
it('leaves dirty editor drafts ready for the combined hot-exit checkpoint', async () => {
const writeFile = vi.fn().mockResolvedValue(undefined)
const setSync = vi.fn()
const eventTarget = new EventTarget()
@ -422,13 +422,7 @@ describe('attachEditorAutosaveController', () => {
await vi.advanceTimersByTimeAsync(1000)
expect(writeFile).not.toHaveBeenCalled()
expect(setSync).toHaveBeenCalledTimes(1)
expect(setSync.mock.calls[0][0].openFilesByWorktree['wt-1'][0]).toEqual(
expect.objectContaining({
filePath: '/repo/file.md',
dirtyDraftContent: ''
})
)
expect(setSync).not.toHaveBeenCalled()
expect(store.getState().openFiles[0]?.isDirty).toBe(true)
expect(store.getState().editorDrafts['/repo/file.md']).toBe('')
} finally {

View File

@ -5,11 +5,7 @@ import type { StoreApi } from 'zustand'
import type { AppState } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { getConnectionIdForFile } from '@/lib/connection-context'
import {
buildWorkspaceSessionPayload,
shouldPersistWorkspaceSession
} from '@/lib/workspace-session'
import { persistWorkspaceSessionByHostSync } from '@/lib/workspace-session-host-persistence'
import { shouldPersistWorkspaceSession } from '@/lib/workspace-session'
import { findWorktreeById } from '@/store/slices/worktree-helpers'
import { writeRuntimeFile } from '@/runtime/runtime-file-client'
import { settingsForRuntimeOwner } from '@/runtime/runtime-rpc-client'
@ -340,17 +336,8 @@ export function attachEditorAutosaveController(store: AppStoreApi): () => void {
return
}
// Why: restart/update may quit before the debounced session writer fires.
// Write the full session now so dirty drafts restore as unsaved tabs.
if (shouldPersistWorkspaceSession(state)) {
// Why: runtime-owned worktree slices persist under their host
// partition, mirroring the debounced writer's split.
persistWorkspaceSessionByHostSync(
window.api.session,
buildWorkspaceSessionPayload(state),
state
)
}
// Why: preload dispatches beforeunload immediately after this resolves;
// App owns the one combined session/UI checkpoint for restart and update.
detail.resolve()
} catch (error) {
detail.reject(String((error as Error)?.message ?? error))

View File

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest'
import { buildActiveViewUnloadPatch } from './active-view-persist'
describe('buildActiveViewUnloadPatch', () => {
it('does not overwrite persisted UI before startup hydration completes', () => {
expect(buildActiveViewUnloadPatch({ activeView: 'terminal', persistedUIReady: false })).toEqual(
{}
)
})
it('checkpoints the latest view after startup hydration completes', () => {
expect(buildActiveViewUnloadPatch({ activeView: 'tasks', persistedUIReady: true })).toEqual({
activeView: 'tasks'
})
})
})

View File

@ -0,0 +1,14 @@
import type { PersistedUIState, TopLevelView } from '../../../shared/types'
type ActiveViewUnloadState = {
activeView: TopLevelView
persistedUIReady: boolean
}
export function buildActiveViewUnloadPatch(
state: ActiveViewUnloadState
): Partial<PersistedUIState> {
// Why: unloading during startup must not overwrite the saved view with the
// renderer default before persisted UI hydration finishes.
return state.persistedUIReady ? { activeView: state.activeView } : {}
}

View File

@ -0,0 +1,111 @@
import { readFileSync } from 'node:fs'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import {
createShutdownCheckpointBeforeUnloadHandler,
createShutdownCheckpointGuard,
preventUnloadAndScheduleShutdownCheckpointReset
} from './shutdown-checkpoint-guard'
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events'
describe('createShutdownCheckpointGuard', () => {
it('dedupes the synthetic and native unload events in one close attempt', () => {
const persist = vi.fn()
const guard = createShutdownCheckpointGuard(persist)
expect(guard.persistOnce()).toBe(true)
expect(guard.persistOnce()).toBe(true)
expect(persist).toHaveBeenCalledTimes(1)
})
it('allows a new checkpoint after an aborted restart resets the attempt', () => {
const persist = vi.fn()
const guard = createShutdownCheckpointGuard(persist)
expect(guard.persistOnce()).toBe(true)
guard.reset()
expect(guard.persistOnce()).toBe(true)
expect(persist).toHaveBeenCalledTimes(2)
})
it('retries when the blocking checkpoint throws', () => {
const persist = vi.fn().mockImplementationOnce(() => {
throw new Error('disk full')
})
const guard = createShutdownCheckpointGuard(persist)
expect(guard.persistOnce()).toBe(false)
expect(guard.persistOnce()).toBe(true)
expect(persist).toHaveBeenCalledTimes(2)
})
it('retries after a prevented reload resets the completed checkpoint', () => {
const eventTarget = new EventTarget()
const persist = vi.fn()
const guard = createShutdownCheckpointGuard(persist)
const checkpoint = createShutdownCheckpointBeforeUnloadHandler(guard)
const preventReload = (event: Event): void => event.preventDefault()
eventTarget.addEventListener('beforeunload', checkpoint)
eventTarget.addEventListener('beforeunload', preventReload)
expect(eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true }))).toBe(false)
guard.reset()
eventTarget.removeEventListener('beforeunload', preventReload)
expect(eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true }))).toBe(true)
expect(persist).toHaveBeenCalledTimes(2)
})
it('cancels unload when persistence fails and remains retryable', () => {
const eventTarget = new EventTarget()
const persist = vi.fn().mockImplementationOnce(() => {
throw new Error('disk full')
})
const guard = createShutdownCheckpointGuard(persist)
const checkpoint = createShutdownCheckpointBeforeUnloadHandler(guard)
eventTarget.addEventListener('beforeunload', checkpoint)
expect(eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true }))).toBe(false)
expect(eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true }))).toBe(true)
expect(persist).toHaveBeenCalledTimes(2)
})
it('resets after a paired-web dirty-file veto regardless of listener order', async () => {
const eventTarget = new EventTarget()
const persist = vi.fn()
const guard = createShutdownCheckpointGuard(persist)
const preventReload = (event: Event): void => {
preventUnloadAndScheduleShutdownCheckpointReset(event, eventTarget)
}
eventTarget.addEventListener('beforeunload', preventReload)
eventTarget.addEventListener('beforeunload', createShutdownCheckpointBeforeUnloadHandler(guard))
eventTarget.addEventListener(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT, guard.reset)
expect(eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true }))).toBe(false)
await Promise.resolve()
eventTarget.removeEventListener('beforeunload', preventReload)
expect(eventTarget.dispatchEvent(new Event('beforeunload', { cancelable: true }))).toBe(true)
expect(persist).toHaveBeenCalledTimes(2)
})
it('wires dirty editor unload vetoes to the paired-web checkpoint reset', () => {
const source = readFileSync(
join(process.cwd(), 'src/renderer/src/components/Terminal.tsx'),
'utf8'
)
const dirtyGuardStart = source.indexOf(
'const dirtyFiles = useAppStore.getState().openFiles.filter((f) => f.isDirty)'
)
const dirtyGuardEnd = source.indexOf("window.addEventListener('beforeunload', handler)")
expect(dirtyGuardStart).toBeGreaterThanOrEqual(0)
expect(dirtyGuardEnd).toBeGreaterThan(dirtyGuardStart)
expect(source.slice(dirtyGuardStart, dirtyGuardEnd)).toContain(
'preventUnloadAndScheduleShutdownCheckpointReset(e, window)'
)
})
})

View File

@ -0,0 +1,51 @@
import { ORCA_RENDERER_UNLOAD_PREVENTED_EVENT } from '../../../shared/renderer-shutdown-events'
export type ShutdownCheckpointGuard = {
persistOnce: () => boolean
reset: () => void
}
export function createShutdownCheckpointGuard(persist: () => void): ShutdownCheckpointGuard {
let persisted = false
return {
persistOnce(): boolean {
if (persisted) {
return true
}
try {
persist()
} catch {
// Why: browser event targets swallow listener exceptions. Returning a
// failure lets the caller cancel unload and keep this attempt retryable.
return false
}
persisted = true
return true
},
reset(): void {
persisted = false
}
}
}
export function createShutdownCheckpointBeforeUnloadHandler(
guard: ShutdownCheckpointGuard
): (event: Event) => void {
return (event): void => {
if (!guard.persistOnce()) {
event.preventDefault()
}
}
}
export function preventUnloadAndScheduleShutdownCheckpointReset(
event: Event,
eventTarget: EventTarget
): void {
event.preventDefault()
// Why: paired web has no Electron will-prevent-unload callback. Defer until
// all beforeunload listeners finish so their successful checkpoint is reset.
queueMicrotask(() => {
eventTarget.dispatchEvent(new Event(ORCA_RENDERER_UNLOAD_PREVENTED_EVENT))
})
}

View File

@ -4,10 +4,13 @@ import type { WorkspaceSessionState } from '../../../shared/types'
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
import {
buildHostIdByWorktreeId,
buildWorkspaceSessionHostSnapshots,
fetchWorkspaceSessionFromHosts,
fetchWorkspaceSessionWithRuntimeHostOwners,
patchWorkspaceSessionByHost,
persistWorkspaceSessionByHost
persistWorkspaceSessionByHost,
persistWorkspaceSessionByHostSync,
type HostPersistenceState
} from './workspace-session-host-persistence'
describe('fetchWorkspaceSessionFromHosts', () => {
@ -384,6 +387,55 @@ describe('fetchWorkspaceSessionFromHosts', () => {
expect(owner(worktreeWorkspaceKey(worktreeId))).toBe('runtime:env-1')
})
it('builds local-first host snapshots reused by synchronous persistence', () => {
const localWorktreeId = 'local-repo::C:\\src\\local'
const remoteWorktreeId = 'remote-repo::/srv/remote'
const makeTab = (id: string, worktreeId: string) => ({
id,
ptyId: null,
worktreeId,
title: id,
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
})
const payload: WorkspaceSessionState = {
...getDefaultWorkspaceSession(),
tabsByWorktree: {
[localWorktreeId]: [makeTab('local-tab', localWorktreeId)],
[remoteWorktreeId]: [makeTab('remote-tab', remoteWorktreeId)]
}
}
const state = {
repos: [
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }
],
worktreesByRepo: {
'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }],
'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }]
}
} satisfies HostPersistenceState
const snapshots = buildWorkspaceSessionHostSnapshots(payload, state)
expect(snapshots.map((snapshot) => snapshot.hostId)).toEqual([undefined, 'runtime:env-1'])
expect(snapshots[0].state.tabsByWorktree).toEqual({
[localWorktreeId]: [expect.objectContaining({ id: 'local-tab' })]
})
expect(snapshots[1].state.tabsByWorktree).toEqual({
[remoteWorktreeId]: [expect.objectContaining({ id: 'remote-tab' })]
})
const setSync = vi.fn()
persistWorkspaceSessionByHostSync({ get: vi.fn(), patch: vi.fn(), setSync }, payload, state)
expect(setSync.mock.calls).toEqual(
snapshots.map((snapshot) => [snapshot.state, snapshot.hostId])
)
})
})
describe('persistWorkspaceSessionByHost', () => {

View File

@ -43,6 +43,11 @@ export type WorkspaceSessionHostRead = {
runtimeHostIdByWorkspaceSessionKey: Record<string, ExecutionHostId>
}
export type WorkspaceSessionHostSnapshot = {
state: WorkspaceSessionState
hostId?: ExecutionHostId
}
const WORKSPACE_SESSION_KEYED_FIELDS = [
'tabsByWorktree',
'openFilesByWorktree',
@ -245,16 +250,26 @@ export async function persistWorkspaceSessionByHost(
await api.flush()
}
/** Build local-first full-session snapshots for the beforeunload / quit paths. */
export function buildWorkspaceSessionHostSnapshots(
payload: WorkspaceSessionState,
state: HostPersistenceState
): WorkspaceSessionHostSnapshot[] {
const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state))
return [
{ state: slices[LOCAL_EXECUTION_HOST_ID] ?? payload },
...nonLocalEntries(slices).map(([hostId, hostState]) => ({ state: hostState, hostId }))
]
}
/** Synchronous full-session split for the beforeunload / quit paths. */
export function persistWorkspaceSessionByHostSync(
api: SessionApi,
payload: WorkspaceSessionState,
state: HostPersistenceState
): void {
const slices = splitWorkspaceSessionByHost(payload, buildHostIdByWorktreeId(state))
api.setSync(slices[LOCAL_EXECUTION_HOST_ID] ?? payload)
for (const [hostId, slice] of nonLocalEntries(slices)) {
api.setSync(slice, hostId)
for (const snapshot of buildWorkspaceSessionHostSnapshots(payload, state)) {
api.setSync(snapshot.state, snapshot.hostId)
}
}

View File

@ -35,6 +35,7 @@ import {
applyManualRepoOrder,
normalizeManualRepoOrder
} from '../../../../shared/manual-repo-order'
import { isTopLevelView } from '../../../../shared/top-level-view'
import type { UsagePercentageDisplay } from '../../../../shared/usage-percentage-display'
import {
DEFAULT_USAGE_PERCENTAGE_DISPLAY,
@ -503,26 +504,13 @@ function hydratedUIPartialMatchesState(state: AppState, hydrated: Partial<UISlic
)
}
// Record keys are exhaustive over TopLevelView, so a new view can't be silently missed.
const TOP_LEVEL_VIEW_LOOKUP: Record<TopLevelView, true> = {
terminal: true,
settings: true,
tasks: true,
activity: true,
automations: true,
space: true,
skills: true,
mobile: true
}
const KNOWN_TOP_LEVEL_VIEWS = new Set<string>(Object.keys(TOP_LEVEL_VIEW_LOOKUP))
function sanitizeHydratedActiveView(
value: PersistedUIState['activeView'],
experimentalActivityEnabled: boolean
): TopLevelView {
// Why: older data (pre-activeView) or a view a different build doesn't have
// falls back to terminal rather than rendering nothing.
if (typeof value !== 'string' || !KNOWN_TOP_LEVEL_VIEWS.has(value)) {
if (!isTopLevelView(value)) {
return 'terminal'
}
// Why: activity is hidden when its setting is off, so restoring it lands on a
@ -531,7 +519,7 @@ function sanitizeHydratedActiveView(
if (value === 'activity' && !experimentalActivityEnabled) {
return 'terminal'
}
return value as TopLevelView
return value
}
let agentSendTargetModeInstanceCounter = 0

View File

@ -140,6 +140,41 @@ function installClipboardImageBase64(contentBase64: string): void {
})
}
describe('web before-unload persistence', () => {
beforeEach(() => {
vi.resetModules()
})
afterEach(() => {
vi.unstubAllGlobals()
})
it('persists final UI and host-partitioned sessions synchronously', async () => {
const { api, storage } = await installApi('Linux')
api.app.persistBeforeUnloadSync({
sessions: [
{ state: { activeWorktreeId: 'local-worktree' } as never },
{
state: { activeWorktreeId: 'remote-worktree' } as never,
hostId: 'runtime:web-env-1'
}
],
ui: { activeView: 'settings' }
})
expect(JSON.parse(storage.getItem('orca.web.workspaceSession.v1') ?? '{}')).toMatchObject({
activeWorktreeId: 'local-worktree'
})
expect(
JSON.parse(storage.getItem('orca.web.workspaceSession.v1.runtime:web-env-1') ?? '{}')
).toMatchObject({ activeWorktreeId: 'remote-worktree' })
expect(JSON.parse(storage.getItem('orca.web.ui.v1') ?? '{}')).toMatchObject({
activeView: 'settings'
})
})
})
function installClipboardImageBlob(blob: Blob): {
getType: ReturnType<typeof vi.fn>
read: ReturnType<typeof vi.fn>

View File

@ -495,6 +495,14 @@ function createWebPreloadApi(): Partial<PreloadApi> {
relaunch: () => Promise.resolve(window.location.reload()),
restart: () => Promise.resolve(window.location.reload()),
reload: () => Promise.resolve(window.location.reload()),
persistBeforeUnloadSync: ({ sessions, ui }) => {
// Why: beforeunload cannot await the paired runtime, so the web adapter
// guarantees immediate browser-local durability for the final snapshot.
for (const { state, hostId } of sessions) {
writeJson(sessionStorageKeyForHost(hostId), sanitizeWebRuntimeWorkspaceSession(state))
}
writeJson(UI_STORAGE_KEY, mergeWebUIState(readLocalWebUIState(), ui))
},
awaitFirstWindowStartupServices: () => Promise.resolve(),
startupDiagnostic: () => Promise.resolve(),
getKeyboardInputSourceId: () => Promise.resolve(null),

View File

@ -0,0 +1 @@
export const ORCA_RENDERER_UNLOAD_PREVENTED_EVENT = 'orca:renderer-unload-prevented'

View File

@ -0,0 +1,20 @@
import type { TopLevelView } from './types'
// Record keys are exhaustive so adding a top-level view also updates every
// persistence boundary that validates values loaded from disk or IPC.
const TOP_LEVEL_VIEW_LOOKUP: Record<TopLevelView, true> = {
terminal: true,
settings: true,
tasks: true,
activity: true,
automations: true,
space: true,
skills: true,
mobile: true
}
export function isTopLevelView(value: unknown): value is TopLevelView {
// Why: hasOwn (not `in`) so inherited keys like "constructor"/"__proto__" from a
// corrupt sidecar can't pass as a view and leave the main surface blank.
return typeof value === 'string' && Object.hasOwn(TOP_LEVEL_VIEW_LOOKUP, value)
}

View File

@ -3,11 +3,11 @@
*
* Reproduces the reported bug (renderer reload / relaunch always snapped back
* to the terminal, discarding whichever top-level view Tasks, Automations,
* etc. the user had open) and asserts the fix: activeView now rides the
* PersistedUIState pipeline and is restored on the first (startup) hydration.
* etc. the user had open) and asserts the fix: activeView now rides its
* profile preference pipeline and is restored on the first startup hydration.
*
* Restart-persistence lives in E2E, not a store unit test: it needs the real
* write -> orca-data.json -> ui.get() -> hydratePersistedUI round-trip across
* write -> active-view.json -> ui.get() -> hydratePersistedUI round-trip across
* two Electron launches sharing one userDataDir, then the render layer proving
* the page actually came back with a real repo/worktree attached so the
* relaunch also exercises the startup worktree hydration path (which must not
@ -15,7 +15,7 @@
*/
import { existsSync, readFileSync } from 'node:fs'
import type { ElectronApplication, Page } from '@stablyai/playwright-test'
import type { ElectronApplication } from '@stablyai/playwright-test'
import { test, expect } from './helpers/orca-app'
import { getStoreState, waitForSessionReady } from './helpers/store'
import { attachRepoAndOpenTerminal, createRestartSession } from './helpers/orca-restart'
@ -29,10 +29,6 @@ function seededRepoPathOrSkip(): string {
return repoPath
}
async function readPersistedActiveView(page: Page): Promise<string | undefined> {
return page.evaluate(() => window.api.ui.get().then((ui) => ui.activeView))
}
test('restores the active top-level view (Tasks) after an app restart', async (// oxlint-disable-next-line no-empty-pattern -- Playwright's second fixture arg is testInfo; the first must be an object destructure to opt out of the default fixture set.
{}, testInfo) => {
test.setTimeout(300_000)
@ -72,12 +68,8 @@ test('restores the active top-level view (Tasks) after an app restart', async (/
// And the terminal grid is not the active surface.
await expect(first.page.locator('.xterm')).not.toBeVisible({ timeout: 10_000 })
// The debounced writer must flush the view to the main-process UI state
// before we quit, so the relaunch reads it back from disk.
await expect
.poll(async () => readPersistedActiveView(first.page), { timeout: 10_000 })
.toBe('tasks')
// Closing also exercises the synchronous checkpoint that covers the race
// where exit starts before the tiny asynchronous preference write finishes.
await session.close(firstApp)
firstApp = null