fix: harden paired host session retirement

This commit is contained in:
Neil
2026-09-19 15:47:53 -07:00
parent dbc098f7e5
commit bfc5fc0000
4 changed files with 273 additions and 4 deletions
@@ -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'
)
}
)
})
+11 -2
View File
@@ -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
}
}
@@ -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<void>)[] = []
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)
})
})
@@ -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)
}
}