From c0a51bcd88937d788f42efe8f1943b7723972162 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sun, 25 Oct 2026 17:27:13 -0700 Subject: [PATCH] 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 * 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 Co-authored-by: Claude Opus 5.5 --- .../orcad-runtime-conversion.test.ts | 14 ++- .../orcad-source-scrollback-state.test.ts | 93 ++++++++++++++++++- .../orcad-source-scrollback-state.ts | 16 ++-- src/main/terminal-scrollback-snapshots.ts | 28 ++++-- .../e2e/ssh-orcad-legacy-relay-bridge.spec.ts | 39 ++++++-- 5 files changed, 161 insertions(+), 29 deletions(-) diff --git a/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts b/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts index 6b778a6f284..31151dcb596 100644 --- a/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts +++ b/src/main/managed-server/lifecycle/orcad-runtime-conversion.test.ts @@ -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) diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts index 6e58013c091..388efa619c4 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.test.ts @@ -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) { + 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([ diff --git a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts index 7c27194ed9d..88ec5f6b08e 100644 --- a/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts +++ b/src/main/persistence/migrating-orcad-catalog/orcad-source-scrollback-state.ts @@ -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}` ) diff --git a/src/main/terminal-scrollback-snapshots.ts b/src/main/terminal-scrollback-snapshots.ts index a2936ff2c40..a64d221d37b 100644 --- a/src/main/terminal-scrollback-snapshots.ts +++ b/src/main/terminal-scrollback-snapshots.ts @@ -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 { diff --git a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts index c6bc02d7489..e775a164081 100644 --- a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts +++ b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts @@ -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) { }) } +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 { 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 })