From bfc5fc00000b9f99ab2753dcd9346f2a235843da Mon Sep 17 00:00:00 2001 From: Neil Date: Fri, 18 Sep 2026 21:51:38 -0700 Subject: [PATCH] fix: harden paired host session retirement --- ...ime-environment-session-retirement.test.ts | 48 +++++ src/main/ipc/session.ts | 13 +- .../removed-host-scrollback-snapshots.test.ts | 168 ++++++++++++++++++ .../loading-store/session-host-partitions.ts | 48 ++++- 4 files changed, 273 insertions(+), 4 deletions(-) create mode 100644 src/main/persistence/loading-store/removed-host-scrollback-snapshots.test.ts diff --git a/src/main/ipc/runtime-environment-session-retirement.test.ts b/src/main/ipc/runtime-environment-session-retirement.test.ts index d7baf5feb2b..05e63f3e9db 100644 --- a/src/main/ipc/runtime-environment-session-retirement.test.ts +++ b/src/main/ipc/runtime-environment-session-retirement.test.ts @@ -395,3 +395,51 @@ describe('removal failure ordering', () => { expect(base.store.getWorkspaceSessionHostIds()).toContain(hostId) }) }) + +describe('async session admission failures', () => { + it.each(['session:set', 'session:patch'])( + 'resolves %s without a write when the pairing catalog cannot be read', + async (channel) => { + const { dir, store } = fixture() + const environment = pair(dir, 'unreadable') + const hostId = toRuntimeExecutionHostId(environment.id) + writeFileSync(getEnvironmentStorePath(dir), '{broken') + const error = vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect(invokeWrite(channel, session('unverifiable'), hostId)).resolves.toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).not.toContain(hostId) + expect(error).toHaveBeenCalledWith( + '[session] Failed to establish runtime session partition authority:', + expect.any(Error) + ) + } + ) + + it.each(['session:set', 'session:patch'])( + 'resolves %s without a write when main namespace custody cannot be established', + async (channel) => { + const { store } = fixture() + vi.spyOn(store, 'getRepos').mockImplementationOnce(() => { + throw new Error('folder_workspace_connection_ambiguous') + }) + vi.spyOn(console, 'error').mockImplementation(() => {}) + await expect( + invokeWrite(channel, session('unverifiable'), 'runtime:unverifiable') + ).resolves.toBeUndefined() + expect(store.getWorkspaceSessionHostIds()).not.toContain('runtime:unverifiable') + } + ) + + it.each(['session:set', 'session:patch'])( + 'preserves actual Store failures for %s', + async (channel) => { + const { store } = fixture() + const method = channel === 'session:set' ? 'setWorkspaceSession' : 'patchWorkspaceSession' + vi.spyOn(store, method).mockImplementationOnce(() => { + throw new Error('actual Store write failure') + }) + await expect(invokeWrite(channel, session('local'), 'local')).rejects.toThrow( + 'actual Store write failure' + ) + } + ) +}) diff --git a/src/main/ipc/session.ts b/src/main/ipc/session.ts index ca854f9e87f..67e34105060 100644 --- a/src/main/ipc/session.ts +++ b/src/main/ipc/session.ts @@ -22,13 +22,13 @@ export function registerSessionHandlers(store: Store): void { }) ipcMain.handle('session:set', (_event, args: WorkspaceSessionState, hostId?: string | null) => { - if (canCreateRendererSessionPartition(store, hostId)) { + if (isRendererSessionAdmitted(store, hostId)) { store.setWorkspaceSession(args, hostId) } }) ipcMain.handle('session:patch', (_event, args: WorkspaceSessionPatch, hostId?: string | null) => { - if (canCreateRendererSessionPartition(store, hostId)) { + if (isRendererSessionAdmitted(store, hostId)) { store.patchWorkspaceSession(args, hostId) } }) @@ -67,3 +67,12 @@ export function registerSessionHandlers(store: Store): void { } ) } + +function isRendererSessionAdmitted(store: Store, hostId?: string | null): boolean { + try { + return canCreateRendererSessionPartition(store, hostId) + } catch (error) { + console.error('[session] Failed to establish runtime session partition authority:', error) + return false + } +} diff --git a/src/main/persistence/loading-store/removed-host-scrollback-snapshots.test.ts b/src/main/persistence/loading-store/removed-host-scrollback-snapshots.test.ts new file mode 100644 index 00000000000..3d8eabe7a34 --- /dev/null +++ b/src/main/persistence/loading-store/removed-host-scrollback-snapshots.test.ts @@ -0,0 +1,168 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { + getProfileTerminalScrollbackSnapshotRoot, + writeTerminalScrollbackSnapshotSync +} from '../../terminal-scrollback-snapshots' + +const { appPaths } = vi.hoisted(() => ({ appPaths: { userData: '' } })) +vi.mock('electron', () => ({ + app: { + getPath: () => appPaths.userData || tmpdir(), + getName: () => 'orca-test', + getVersion: () => '0.0.0-test', + isPackaged: false, + on() {}, + whenReady: () => Promise.resolve() + }, + safeStorage: { + isEncryptionAvailable: () => false, + encryptString: (value: string) => Buffer.from(value), + decryptString: (value: Buffer) => value.toString() + }, + ipcMain: { on() {}, handle() {} }, + BrowserWindow: { getAllWindows: () => [] } +})) + +const { Store } = await import('./store') +const leafId = '33333333-3333-4333-8333-333333333333' +const cleanups: (() => Promise)[] = [] +afterEach(async () => { + vi.restoreAllMocks() + for (const cleanup of cleanups.splice(0)) { + await cleanup() + } +}) + +function fixture() { + const dir = mkdtempSync(join(tmpdir(), 'orca-retired-snapshots-')) + appPaths.userData = dir + const dataFile = join(dir, 'profile', 'orca-data.json') + const store = new Store({ dataFile }) + const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(dataFile) + cleanups.push(async () => { + store.freezeWrites() + await store.waitForPendingWrite() + rmSync(dir, { force: true, recursive: true }) + }) + function snapshot(tabId: string, root = snapshotRoot) { + const ref = writeTerminalScrollbackSnapshotSync({ + tabId, + leafId, + buffer: `scrollback for ${tabId}`, + storage: { snapshotRoot: root } + }) + if (!ref) { + throw new Error('Snapshot fixture could not be written') + } + return { ref, path: join(root, `${ref}.bin`) } + } + return { dir, dataFile, store, snapshot } +} + +function session(tabId: string, ref: string): WorkspaceSessionState { + const worktreeId = 'remote-repo::/project' + return { + ...getDefaultWorkspaceSession(), + tabsByWorktree: { + [worktreeId]: [ + { + id: tabId, + worktreeId, + ptyId: null, + title: 'Remote', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + scrollbackRefsByLeafId: { [leafId]: ref } + } + } + } +} + +describe('removed runtime host scrollback files', () => { + it('removes only the retired host snapshot after persisting its removal', () => { + const { dataFile, store, snapshot } = fixture() + const removed = snapshot('removed') + const untouched = snapshot('untouched') + store.setWorkspaceSession(session('removed', removed.ref), 'runtime:removed') + store.setWorkspaceSession(session('untouched', untouched.ref), 'runtime:untouched') + store.flushOrThrow() + expect(store.removeRuntimeWorkspaceSessionPartition('runtime:removed')).toBe(true) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).workspaceSessionsByHostId).not.toHaveProperty( + 'runtime:removed' + ) + expect(existsSync(removed.path)).toBe(false) + expect(store.readTerminalScrollbackSnapshot(untouched.ref)).toBe('scrollback for untouched') + }) + + it.each(['local', 'ssh:remaining', 'runtime:remaining'] as const)( + 'preserves a snapshot still referenced by %s', + (hostId) => { + const { store, snapshot } = fixture() + const shared = snapshot('shared') + store.setWorkspaceSession(session('shared', shared.ref), hostId) + store.setWorkspaceSession(session('shared', shared.ref), 'runtime:removed') + expect(store.removeRuntimeWorkspaceSessionPartition('runtime:removed')).toBe(true) + expect(store.readTerminalScrollbackSnapshot(shared.ref)).toBe('scrollback for shared') + expect( + store.getWorkspaceSession(hostId).terminalLayoutsByTabId.shared.scrollbackRefsByLeafId + ).toEqual({ [leafId]: shared.ref }) + } + ) + + it('preserves snapshot files if the profile save fails', () => { + const { dataFile, store, snapshot } = fixture() + const removed = snapshot('removed') + store.setWorkspaceSession(session('removed', removed.ref), 'runtime:removed') + store.flushOrThrow() + vi.spyOn(store, 'flushOrThrow').mockImplementationOnce(() => { + throw new Error('disk full') + }) + vi.spyOn(console, 'warn').mockImplementation(() => {}) + expect(store.removeRuntimeWorkspaceSessionPartition('runtime:removed')).toBe(true) + expect(JSON.parse(readFileSync(dataFile, 'utf8')).workspaceSessionsByHostId).toHaveProperty( + 'runtime:removed' + ) + expect(existsSync(removed.path)).toBe(true) + }) + + it('preserves snapshot files when profile writes are frozen', () => { + const { store, snapshot } = fixture() + const removed = snapshot('removed') + store.setWorkspaceSession(session('removed', removed.ref), 'runtime:removed') + store.flushOrThrow() + store.freezeWrites() + store.removeRuntimeWorkspaceSessionPartition('runtime:removed') + expect(existsSync(removed.path)).toBe(true) + }) + + it('does not remove shared legacy fallback files', () => { + const { dir, store, snapshot } = fixture() + const legacy = snapshot('legacy', join(dir, 'terminal-scrollback')) + store.setWorkspaceSession(session('legacy', legacy.ref), 'runtime:removed') + store.removeRuntimeWorkspaceSessionPartition('runtime:removed') + expect(existsSync(legacy.path)).toBe(true) + }) + + it.each(['local', 'ssh:remaining'] as const)('refuses to remove %s', (hostId) => { + const { store, snapshot } = fixture() + const retained = snapshot('retained') + store.setWorkspaceSession(session('retained', retained.ref), hostId) + expect(store.removeRuntimeWorkspaceSessionPartition(hostId)).toBe(false) + expect(existsSync(retained.path)).toBe(true) + }) +}) diff --git a/src/main/persistence/loading-store/session-host-partitions.ts b/src/main/persistence/loading-store/session-host-partitions.ts index c9f39d22da9..96fd57b21ba 100644 --- a/src/main/persistence/loading-store/session-host-partitions.ts +++ b/src/main/persistence/loading-store/session-host-partitions.ts @@ -12,7 +12,11 @@ import { pruneLocalTerminalScrollbackBuffers } from '../../../shared/workspace-s import { pruneWorkspaceSessionBrowserHistory } from '../../../shared/workspace-session-browser-history' import { withoutRedundantGlobalFields } from '../../../shared/workspace-session-host-field-ownership' import { getRepoIdFromWorktreeId } from '../../../shared/worktree/id' -import { readTerminalScrollbackSnapshotSync } from '../../terminal-scrollback-snapshots' +import { + collectTerminalScrollbackSnapshotRefs, + deleteTerminalScrollbackSnapshotSync, + readTerminalScrollbackSnapshotSync +} from '../../terminal-scrollback-snapshots' import { preserveRuntimeAuthoredWorkspaceSessionFields } from '../runtime-authored-workspace-session-fields' import { findWorktreeIdForTab } from '../restoring-sessions/pane-identity-migration' import { invalidateLocalWorktreeMetadataPruneInputs } from '../../local-worktree-metadata-prune-gate' @@ -32,7 +36,11 @@ import type { TerminalBindingRecoveryOperations } from './terminal-binding-recov type SessionHostPartitionOperationsRuntime = Pick< StoreRuntimeState, - 'state' | 'terminalScrollbackSnapshotStorage' + | 'state' + | 'terminalScrollbackSnapshotStorage' + | 'flushOrThrow' + | 'writesFrozen' + | 'quitFlushStarted' > const sessionHostPartitionOperationsContext = Symbol('SessionHostPartitionOperations') @@ -88,6 +96,7 @@ export class SessionHostPartitionOperations { ) { return false } + const prior = this.getWorkspaceSession(hostId) const partitions = { ...this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId } @@ -95,6 +104,7 @@ export class SessionHostPartitionOperations { this[sessionHostPartitionOperationsContext].runtime.state.workspaceSessionsByHostId = partitions invalidateLocalWorktreeMetadataPruneInputs() scheduleSave(this[sessionHostPartitionOperationsContext].scheduling) + releaseRemovedHostScrollbackSnapshots(this, prior) return true } @@ -235,3 +245,37 @@ export function installSessionHostPartitionOperationsContext( value: source[sessionHostPartitionOperationsContext] }) } + +function releaseRemovedHostScrollbackSnapshots( + owner: SessionHostPartitionOperations, + prior: WorkspaceSessionState +): void { + const { runtime } = owner[sessionHostPartitionOperationsContext] + const refs = collectTerminalScrollbackSnapshotRefs(prior) + if (refs.size === 0 || runtime.writesFrozen || runtime.quitFlushStarted) { + return + } + for (const hostId of owner.getWorkspaceSessionHostIds()) { + for (const ref of collectTerminalScrollbackSnapshotRefs(owner.getWorkspaceSession(hostId))) { + refs.delete(ref) + } + } + if (refs.size === 0) { + return + } + try { + // Never remove files while the durable profile can still reference them. + runtime.flushOrThrow() + } catch (error) { + console.warn( + '[terminal-scrollback] Retaining removed host snapshots after save failure:', + error + ) + return + } + // Legacy fallback files can still belong to another profile. + const storage = { ...runtime.terminalScrollbackSnapshotStorage, fallbackSnapshotRoot: null } + for (const ref of refs) { + deleteTerminalScrollbackSnapshotSync(ref, storage) + } +}