fix(ssh): don't refuse a host's move over a pane that saved no terminal history (#33681)

* fix(ssh): don't refuse a host's move over a pane that saved no terminal history

A pane with an empty inline buffer, a deleted snapshot, or a zero-byte
snapshot has no history to lose, but the move's projection filed it
under "saved terminal history Orca could not read" and refused the whole
host. Tell absent snapshots apart from unreadable ones and move the
former without history; unreadable history still blocks.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ssh): keep looking in the fallback root when a scrollback snapshot is empty

A zero-byte snapshot in the primary root returned 'absent' without checking
the fallback root, so a pane whose history lived there moved without it.
Also check ENOENT inline rather than importing a window staging helper.

* test(e2e): seed unreadable relay scrollback as an over-limit snapshot

A missing snapshot file now means the pane saved no history, so the
leave-behind spec needs history that exists but cannot be read.

---------

Co-authored-by: Younghwan Chae <chyohw97@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Neil
2026-10-25 17:27:13 -07:00
committed by GitHub
co-authored by Younghwan Chae Claude Opus 5.5
parent 478ad9109c
commit c0a51bcd88
5 changed files with 161 additions and 29 deletions
@@ -1,4 +1,4 @@
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, mkdirSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -24,7 +24,12 @@ import { reconcileManagedOrcadSshTargets, visibleRepos } from '../../ssh/orcad-r
import { SshConnectionStore } from '../../ssh/connection/ssh-connection-store'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../../shared/constants'
import { toSshExecutionHostId } from '../../../shared/execution-host'
import { makeTerminalScrollbackSnapshotRef } from '../../terminal-scrollback-snapshots'
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits'
import {
getProfileTerminalScrollbackSnapshotRoot,
getTerminalScrollbackSnapshotPath,
makeTerminalScrollbackSnapshotRef
} from '../../terminal-scrollback-snapshots'
const mocks = vi.hoisted(() => {
const state: { targetStore: unknown } = { targetStore: null }
@@ -183,6 +188,11 @@ describe('converting an SSH host into a managed server', () => {
mkdirSync(reportDirectory)
const dataFile = join(reportDirectory, 'orca-data.json')
writeFileSync(dataFile, JSON.stringify(profile))
const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(dataFile)
mkdirSync(snapshotRoot)
const snapshotPath = getTerminalScrollbackSnapshotPath(ref, { snapshotRoot })!
writeFileSync(snapshotPath, '')
truncateSync(snapshotPath, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1)
store = createSqliteTestStore(Store, { dataFile })
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the registry wraps this real test store.
mocks.state.targetStore = new SshConnectionStore(store as never)
@@ -1,6 +1,19 @@
import { describe, expect, it } from 'vitest'
import { mkdirSync, mkdtempSync, rmSync, truncateSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { getDefaultWorkspaceSession } from '../../../shared/constants'
import type { OrcadMigrationTerminalScrollbackSnapshot } from '../../../shared/orcad-migration/orcad-migration-scrollback'
import { hasDuplicateOrcadMigrationScrollbackDescriptors } from './orcad-source-scrollback-state'
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../../shared/terminal-scrollback-limits'
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
import {
makeTerminalScrollbackSnapshotRef,
type TerminalScrollbackSnapshotStorage
} from '../../terminal-scrollback-snapshots'
import {
hasDuplicateOrcadMigrationScrollbackDescriptors,
projectOrcadMigrationSessionScrollback
} from './orcad-source-scrollback-state'
const FIRST: OrcadMigrationTerminalScrollbackSnapshot = {
tabId: 'tab-1',
@@ -10,7 +23,83 @@ const FIRST: OrcadMigrationTerminalScrollbackSnapshot = {
byteLength: 1
}
let root: string
let storage: TerminalScrollbackSnapshotStorage
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'orcad-source-scrollback-'))
storage = { snapshotRoot: join(root, 'primary'), fallbackSnapshotRoot: join(root, 'fallback') }
mkdirSync(join(root, 'primary'))
mkdirSync(join(root, 'fallback'))
})
afterEach(() => rmSync(root, { recursive: true, force: true }))
function project(layout: Partial<TerminalLayoutSnapshot>) {
return projectOrcadMigrationSessionScrollback(
{
...getDefaultWorkspaceSession(),
terminalLayoutsByTabId: {
'tab-1': { root: null, activeLeafId: null, expandedLeafId: null, ...layout }
}
},
storage
)
}
function storeSnapshot(leafId: string, contents: string | number, dir = 'primary'): string {
const ref = makeTerminalScrollbackSnapshotRef('tab-1', leafId)
const path = join(root, dir, `${ref}.bin`)
writeFileSync(path, typeof contents === 'string' ? contents : '')
if (typeof contents === 'number') {
truncateSync(path, contents)
}
return ref
}
describe('orcad source scrollback projection', () => {
it('moves panes that saved no terminal history without blocking the move', () => {
const projected = project({
buffersByLeafId: { 'leaf-empty': '' },
scrollbackRefsByLeafId: {
'leaf-deleted': makeTerminalScrollbackSnapshotRef('tab-1', 'leaf-deleted'),
'leaf-zero': storeSnapshot('leaf-zero', 0)
}
})
expect(projected.blockedItems).toEqual([])
expect(projected.snapshots).toEqual([])
expect(projected.session.terminalLayoutsByTabId['tab-1'].scrollbackRefsByLeafId).toBeUndefined()
})
it('carries history from the fallback root when the primary copy is empty or missing', () => {
const zeroRef = storeSnapshot('leaf-zero', 0)
storeSnapshot('leaf-zero', 'zero', 'fallback')
const missingRef = storeSnapshot('leaf-missing', 'missing', 'fallback')
const projected = project({
scrollbackRefsByLeafId: { 'leaf-zero': zeroRef, 'leaf-missing': missingRef }
})
expect(projected.blockedItems).toEqual([])
expect(projected.snapshots.map((snapshot) => [snapshot.leafId, snapshot.byteLength])).toEqual([
['leaf-missing', 7],
['leaf-zero', 4]
])
})
it('still blocks the move on saved history it cannot read', () => {
const projected = project({
scrollbackRefsByLeafId: {
'leaf-big': storeSnapshot('leaf-big', TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1)
}
})
expect(projected.blockedItems).toEqual([
'saved terminal history Orca could not read for tab tab-1'
])
})
it('refuses duplicate refs or tab/leaf identities across merged fragments', () => {
expect(
hasDuplicateOrcadMigrationScrollbackDescriptors([
@@ -10,6 +10,7 @@ import type { WorkspaceSessionState } from '../../../shared/workspace-session-st
import {
makeTerminalScrollbackSnapshotRef,
readTerminalScrollbackStoredBytesSync,
readTerminalScrollbackStoredSnapshotSync,
type TerminalScrollbackSnapshotStorage
} from '../../terminal-scrollback-snapshots'
import { compareKeys } from './orcad-source-key-order'
@@ -64,14 +65,13 @@ export function projectOrcadMigrationSessionScrollback(
const bytes = buffer
? Buffer.from(buffer, 'utf8')
: sourceRef
? readTerminalScrollbackStoredBytesSync(sourceRef, storage)
: null
if (
!ref ||
!bytes ||
bytes.length === 0 ||
bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT
) {
? readTerminalScrollbackStoredSnapshotSync(sourceRef, storage)
: 'absent'
if (bytes === 'absent') {
// Nothing was saved, so the pane moves with no history to lose.
continue
}
if (!ref || !bytes || bytes.length > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) {
;(leaveBehind ? leftBehindItems : blockedItems).push(
`saved terminal history Orca could not read for tab ${tabId}`
)
+21 -7
View File
@@ -79,18 +79,32 @@ export function readTerminalScrollbackStoredBytesSync(
ref: string,
storage?: TerminalScrollbackSnapshotStorage
): Buffer | null {
for (const path of snapshotReadPaths(ref, storage)) {
const bytes = readTerminalScrollbackStoredSnapshotSync(ref, storage)
return bytes === 'absent' ? null : bytes
}
/** `'absent'` when no root holds saved bytes; `null` when a snapshot exists but can't be read. */
export function readTerminalScrollbackStoredSnapshotSync(
ref: string,
storage?: TerminalScrollbackSnapshotStorage
): Buffer | 'absent' | null {
const paths = snapshotReadPaths(ref, storage)
let unreadable = paths.length === 0
for (const path of paths) {
try {
const size = statSync(path).size
if (size <= 0 || size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) {
if (statSync(path).size > TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT) {
return null
}
return readFileSync(path)
} catch {
// Try the next snapshot root when this read fails.
const bytes = readFileSync(path)
// An empty file holds no history, but another root may.
if (bytes.length > 0) {
return bytes
}
} catch (error) {
unreadable ||= !(error instanceof Error && 'code' in error && error.code === 'ENOENT')
}
}
return null
return unreadable ? null : 'absent'
}
function trailingUtf8Bytes(value: string, maxBytes: number): Buffer {
+29 -10
View File
@@ -1,6 +1,7 @@
// Upgrade a live detached 1.4.224 relay to managed orcad while preserving its original tab/shell across SSH loss and explicit disconnect.
// Retire the pane/lease only on exit; never touch another desktop’s relay. If its project already moved, keep connected without moving until the shell exits.
// COMPAT(legacy-relay-bridge): revisit 2027-01-15, with src/main/ssh/legacy-relay/.
import { mkdirSync, truncateSync, writeFileSync } from 'node:fs'
import type { ElectronApplication, Page, TestInfo } from '@stablyai/playwright-test'
import { z } from 'zod'
import { expect, test } from './helpers/orca-app'
@@ -13,7 +14,14 @@ import {
readPersistedProfileState
} from './helpers/persisted-profile-state'
import { workspaceSessionStateSchema } from '../../src/shared/workspace-session-schema'
import { makeTerminalScrollbackSnapshotRef } from '../../src/main/terminal-scrollback-snapshots'
import {
getProfileTerminalScrollbackSnapshotRoot,
getTerminalScrollbackSnapshotPath,
makeTerminalScrollbackSnapshotRef
} from '../../src/main/terminal-scrollback-snapshots'
import { TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT } from '../../src/shared/terminal-scrollback-limits'
import { getOrcaProfileDataFile } from '../../src/shared/profile-state-storage-paths'
import { DEFAULT_LOCAL_ORCA_PROFILE_ID } from '../../src/shared/orca-profiles'
import { getOrcadSshDeployment } from '../../src/shared/runtime-environments'
import { toSshExecutionHostId } from '../../src/shared/execution-host'
import { toAppSshPtyId } from '../../src/shared/ssh-pty-id'
@@ -102,10 +110,23 @@ function launchUpgraded(session: ReturnType<typeof createRestartSession>) {
})
}
function writeOverLimitScrollbackSnapshot(userDataDir: string, ref: string): void {
const snapshotRoot = getProfileTerminalScrollbackSnapshotRoot(
getOrcaProfileDataFile(DEFAULT_LOCAL_ORCA_PROFILE_ID, userDataDir)
)
const path = getTerminalScrollbackSnapshotPath(ref, { snapshotRoot })
if (!path) {
throw new Error(`Expected a valid scrollback ref, got ${ref}`)
}
mkdirSync(snapshotRoot, { recursive: true })
writeFileSync(path, '')
truncateSync(path, TERMINAL_SCROLLBACK_STORE_BYTE_LIMIT + 1)
}
async function verifyRunningRelayUpgrade(
testRepoPath: string,
testInfo: TestInfo,
missingScrollback = false
unreadableScrollback = false
): Promise<void> {
test.setTimeout(25 * 60_000)
host = startDockerSshHostTarget(testInfo)
@@ -140,7 +161,10 @@ async function verifyRunningRelayUpgrade(
{ targetId, relayPtyId: ours.relayPtyId }
)
const terminalTabId = seeded.terminalTabId!
if (missingScrollback) {
if (unreadableScrollback) {
const ref = makeTerminalScrollbackSnapshotRef(terminalTabId, seeded.terminalLeafId)
// A missing file is no history at all; an over-limit one is saved history Orca cannot read.
writeOverLimitScrollbackSnapshot(session.userDataDir, ref)
mutateStoppedProfileState(session.userDataDir, (state) => {
const partitions = z
.record(z.string(), workspaceSessionStateSchema)
@@ -150,12 +174,7 @@ async function verifyRunningRelayUpgrade(
if (!layout) {
throw new Error('Expected the released terminal layout')
}
layout.scrollbackRefsByLeafId = {
[seeded.terminalLeafId]: makeTerminalScrollbackSnapshotRef(
terminalTabId,
seeded.terminalLeafId
)
}
layout.scrollbackRefsByLeafId = { [seeded.terminalLeafId]: ref }
state.workspaceSessionsByHostId = partitions
state.sshRemotePtyLeases = [
...z.array(z.unknown()).parse(state.sshRemotePtyLeases),
@@ -193,7 +212,7 @@ async function verifyRunningRelayUpgrade(
}
)
if (missingScrollback) {
if (unreadableScrollback) {
await expect
.poll(() => managedServer(page, targetId), { timeout: 120_000 })
.toMatchObject({ kind: 'relay', reason: 'refused', canLeaveBehind: true })