mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix: harden paired host session retirement
This commit is contained in:
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user