mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 08:02:32 +00:00
* fix(terminal): route a parked pane's scrollback patch to the remote host's partition A park capture changes only terminalLayoutsByTabId, so its debounced session patch carries no tabsByWorktree. splitWorkspaceSessionByHost built its tab->worktree index from the patch alone, resolved nothing, and routed every layout to the 'local' partition, where main's pruneLocalTerminalScrollbackBuffers strips scrollback it cannot attribute to a remote worktree. The remote host's runtime:<id> partition never received the capture, so anything parked since the last clean checkpoint was lost on a crash, SIGKILL, or a forced kill during an app update (#21295). Route tab-keyed patch fields with the renderer's live tab catalogs as a fallback when the payload names no tab rows. Payload rows still win, so full-payload writes are byte-identical. Once routed to runtime:<id>, main merges the partition's own prior tabsByWorktree and the prune preserves. Proven by tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts: a hard kill (no checkpoint) then relaunch, asserting the capture is in the remote host's partition on disk. Mutation: reverting the routing fix turns that assertion red and fails the 3 catalog-dependent unit routing tests. (cherry picked from commit58a344c1d4) * test(terminal): read both scrollback homes in the restart spec, and ratchet the resolver to the cap's home list The restart spec read only buffersByLeafId, but the ordinary park now writes localOnlyScrollbackByTabId, so its own proof reported a false zero and both tests failed for the wrong reason. Both readers now go through resolveLeafScrollbackBuffers: the on-disk reader calls it directly (it is a pure function), and the store reader — which runs inside page.evaluate — reaches it through a new window.__terminalParkingDebug.resolveLeafScrollback(tabId) handle. resolveTabScrollbackBuffers is typed off TERMINAL_SCROLLBACK_SESSION_HOMES and its unit test enumerates that constant, so adding a third home fails to compile and fails a test until the resolver reads it — the 'no consumer reads a home directly' invariant becomes enforceable. The clean-quit control no longer asserts tokenAfterReveal (measured true, true, false on identical product code; the live host can serve the reveal from its own tail). It keeps the five deterministic fields and logs the reveal; the hard-kill test still asserts it, because there the host is forced unavailable and the reveal must come from the client copy. (cherry picked from commit0cd3db1489) * docs(persistence): pin why the local-only scrollback home stays outside full normalization The two scrollback homes look symmetric (TERMINAL_SCROLLBACK_SESSION_HOMES), so the missing key reads as an oversight. It is load-bearing: adding it would route the field through the fail-closed strip and reintroduce the loss this branch fixes. The renderer prunes it with attribution before the patch is sent, so the cap still holds without main as a second line. (cherry picked from commit1092e35b0a)
This commit is contained in:
@@ -8,6 +8,11 @@ import {
|
||||
type TerminalScrollbackSnapshotStorage
|
||||
} from '../../terminal-scrollback-snapshots'
|
||||
|
||||
// Why localOnlyScrollbackByTabId is deliberately NOT here despite TERMINAL_SCROLLBACK_SESSION_HOMES
|
||||
// pairing it with terminalLayoutsByTabId: full normalization reaches the fail-closed strip in
|
||||
// workspace-session-terminal-buffers.ts, which drops a leaf whose worktree main cannot attribute —
|
||||
// the renderer already capped that field with attribution in hand, and re-stripping it here is the
|
||||
// scrollback loss the local-only home exists to prevent. Add it only together with that strip.
|
||||
const WORKSPACE_SESSION_PATCH_FULL_NORMALIZATION_KEYS = new Set<keyof WorkspaceSessionState>([
|
||||
'tabsByWorktree',
|
||||
'terminalLayoutsByTabId'
|
||||
|
||||
@@ -1,5 +1,43 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { resolveLeafScrollbackBuffers } from './leaf-scrollback-resolution'
|
||||
import { TERMINAL_SCROLLBACK_SESSION_HOMES } from '../../../../shared/workspace-session-terminal-buffers'
|
||||
import {
|
||||
resolveLeafScrollbackBuffers,
|
||||
resolveTabScrollbackBuffers,
|
||||
type TerminalScrollbackSessionHomes
|
||||
} from './leaf-scrollback-resolution'
|
||||
|
||||
// Why enumerated off the cap's constant: the invariant "no consumer reads a home directly" only
|
||||
// holds while the resolver reads every home the session can persist. A third entry in
|
||||
// TERMINAL_SCROLLBACK_SESSION_HOMES fails here (and the Pick type above) until the resolver reads it.
|
||||
describe.each(TERMINAL_SCROLLBACK_SESSION_HOMES)('resolveTabScrollbackBuffers reads %s', (home) => {
|
||||
const sessionWithBytesOnlyIn: Record<
|
||||
(typeof TERMINAL_SCROLLBACK_SESSION_HOMES)[number],
|
||||
TerminalScrollbackSessionHomes
|
||||
> = {
|
||||
terminalLayoutsByTabId: {
|
||||
terminalLayoutsByTabId: {
|
||||
'tab-1': {
|
||||
root: null,
|
||||
activeLeafId: null,
|
||||
expandedLeafId: null,
|
||||
buffersByLeafId: { 'leaf-1': 'from-shared' }
|
||||
}
|
||||
},
|
||||
localOnlyScrollbackByTabId: {}
|
||||
},
|
||||
localOnlyScrollbackByTabId: {
|
||||
terminalLayoutsByTabId: {},
|
||||
localOnlyScrollbackByTabId: { 'tab-1': { 'leaf-1': 'from-local-only' } }
|
||||
}
|
||||
}
|
||||
|
||||
it('returns the bytes that home holds for the tab', () => {
|
||||
expect(resolveTabScrollbackBuffers(sessionWithBytesOnlyIn[home], 'tab-1')).toEqual({
|
||||
'leaf-1': home === 'terminalLayoutsByTabId' ? 'from-shared' : 'from-local-only'
|
||||
})
|
||||
expect(resolveTabScrollbackBuffers(sessionWithBytesOnlyIn[home], 'tab-other')).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLeafScrollbackBuffers', () => {
|
||||
it('returns the shared layout buffers when nothing is held locally', () => {
|
||||
|
||||
@@ -1,4 +1,13 @@
|
||||
import type { TerminalLayoutSnapshot } from '../../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../../shared/workspace-session-state-types'
|
||||
import type { TERMINAL_SCROLLBACK_SESSION_HOMES } from '../../../../shared/workspace-session-terminal-buffers'
|
||||
|
||||
/** The session fields a tab's scrollback can live in — typed off the same constant the cap
|
||||
* enumerates, so a third home is a compile error here until this resolver reads it. */
|
||||
export type TerminalScrollbackSessionHomes = Pick<
|
||||
WorkspaceSessionState,
|
||||
(typeof TERMINAL_SCROLLBACK_SESSION_HOMES)[number]
|
||||
>
|
||||
|
||||
export type LeafScrollbackHomes = {
|
||||
/** `TerminalLayoutSnapshot.buffersByLeafId` — shared with peers through the remote projection. */
|
||||
@@ -20,3 +29,14 @@ export function resolveLeafScrollbackBuffers({
|
||||
}
|
||||
return sharedBuffers ? { ...sharedBuffers, ...localOnly } : localOnly
|
||||
}
|
||||
|
||||
/** Same read, addressed by tab over a session-shaped record (the store or a parsed session file). */
|
||||
export function resolveTabScrollbackBuffers(
|
||||
session: Partial<TerminalScrollbackSessionHomes>,
|
||||
tabId: string
|
||||
): Record<string, string> | undefined {
|
||||
return resolveLeafScrollbackBuffers({
|
||||
shared: session.terminalLayoutsByTabId?.[tabId],
|
||||
localOnly: session.localOnlyScrollbackByTabId?.[tabId]
|
||||
})
|
||||
}
|
||||
|
||||
@@ -4,6 +4,8 @@ import {
|
||||
type TerminalColdParkPolicyOverrides
|
||||
} from './terminal-hidden-view-parking'
|
||||
import { getParkedTerminalWatcherTabIds } from './terminal-parked-tab-watchers'
|
||||
import { resolveTabScrollbackBuffers } from './leaf-scrollback-resolution'
|
||||
import { useAppStore } from '@/store'
|
||||
|
||||
export type TerminalWorktreeParkingDebugVerdict = {
|
||||
worktreeId: string
|
||||
@@ -51,6 +53,9 @@ export function registerTerminalParkingDebugHandle(): void {
|
||||
parkDelayMs:
|
||||
getTerminalParkingPolicyOverrides().coldParkDelayMs ?? TERMINAL_TAB_COLD_PARK_DELAY_MS,
|
||||
parkedTabIds: () => getParkedTerminalWatcherTabIds(),
|
||||
// Why through the resolver: a spec that reads one store home directly reports a false zero
|
||||
// whenever the bytes live in the other one.
|
||||
resolveLeafScrollback: (tabId) => resolveTabScrollbackBuffers(useAppStore.getState(), tabId),
|
||||
retentionLimit: getTerminalParkingPolicyOverrides().retentionLimit ?? null,
|
||||
worktreeVerdicts: () => worktreeVerdicts
|
||||
}
|
||||
|
||||
Vendored
+2
@@ -84,6 +84,8 @@ declare global {
|
||||
__terminalParkingDebug?: {
|
||||
parkDelayMs: number
|
||||
parkedTabIds: () => string[]
|
||||
/** A tab's scrollback across both store homes, via the one resolver production reads through. */
|
||||
resolveLeafScrollback: (tabId: string) => Record<string, string> | undefined
|
||||
retentionLimit: number | null
|
||||
worktreeVerdicts: () => TerminalWorktreeParkingDebugVerdict[]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { getDefaultWorkspaceSession } from '../../../shared/constants'
|
||||
import type { TerminalLayoutSnapshot } from '../../../shared/terminal-tab-types'
|
||||
import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types'
|
||||
import { folderWorkspaceKey, worktreeWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
@@ -482,6 +483,112 @@ describe('fetchWorkspaceSessionFromHosts', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('patchWorkspaceSessionByHost tab-keyed routing', () => {
|
||||
const remoteWorktreeId = 'remote-repo::/srv/remote'
|
||||
const localWorktreeId = 'local-repo::/home/me/local'
|
||||
const remoteTab = {
|
||||
id: 'remote-tab',
|
||||
ptyId: null,
|
||||
worktreeId: remoteWorktreeId,
|
||||
title: 'Remote',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: 0,
|
||||
createdAt: 1
|
||||
}
|
||||
const parkedLayout: TerminalLayoutSnapshot = {
|
||||
root: { type: 'leaf', leafId: 'leaf-1' },
|
||||
activeLeafId: 'leaf-1',
|
||||
expandedLeafId: null,
|
||||
buffersByLeafId: { 'leaf-1': 'scrollback captured at park' }
|
||||
}
|
||||
const catalog = {
|
||||
repos: [
|
||||
{ id: 'local-repo', connectionId: null, executionHostId: 'local' },
|
||||
{ id: 'remote-repo', connectionId: null, executionHostId: 'runtime:env-1' }
|
||||
],
|
||||
worktreesByRepo: {
|
||||
'local-repo': [{ id: localWorktreeId, repoId: 'local-repo' }],
|
||||
'remote-repo': [{ id: remoteWorktreeId, repoId: 'remote-repo', hostId: 'runtime:env-1' }]
|
||||
}
|
||||
} satisfies HostPersistenceState
|
||||
|
||||
it('routes a layouts-only patch to the partition of the tab the live catalog names', async () => {
|
||||
// A park capture changes only terminalLayoutsByTabId, so the patch carries no tab rows. Routed
|
||||
// by the payload alone it fell into 'local', where main strips scrollback it cannot attribute
|
||||
// to a remote worktree — the runtime partition never received the capture (#21295).
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{ get: vi.fn(), patch, setSync: vi.fn() },
|
||||
{ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } },
|
||||
{ ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } }
|
||||
)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
{ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } },
|
||||
'runtime:env-1'
|
||||
)
|
||||
expect(patch).toHaveBeenCalledWith({ terminalLayoutsByTabId: {} })
|
||||
})
|
||||
|
||||
it('routes a remote-session-id-only patch the same way', async () => {
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{ get: vi.fn(), patch, setSync: vi.fn() },
|
||||
{ remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } },
|
||||
{ ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } }
|
||||
)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
{ remoteSessionIdsByTabId: { 'remote-tab': 'sess-1' } },
|
||||
'runtime:env-1'
|
||||
)
|
||||
expect(patch).toHaveBeenCalledWith({ remoteSessionIdsByTabId: {} })
|
||||
})
|
||||
|
||||
it('resolves a tab only the unified catalog lists', async () => {
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{ get: vi.fn(), patch, setSync: vi.fn() },
|
||||
{ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } },
|
||||
{
|
||||
...catalog,
|
||||
unifiedTabsByWorktree: {
|
||||
[remoteWorktreeId]: [{ id: 'remote-tab', worktreeId: remoteWorktreeId }]
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
{ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } },
|
||||
'runtime:env-1'
|
||||
)
|
||||
})
|
||||
|
||||
it("lets the payload's own tab row outrank the live catalog", async () => {
|
||||
// Main merges the payload's tabsByWorktree into whichever partition it lands in, so the layout
|
||||
// must follow the tab row in this write even when the store has since moved the tab.
|
||||
const patch = vi.fn().mockResolvedValue(undefined)
|
||||
|
||||
await patchWorkspaceSessionByHost(
|
||||
{ get: vi.fn(), patch, setSync: vi.fn() },
|
||||
{
|
||||
tabsByWorktree: { [localWorktreeId]: [{ ...remoteTab, worktreeId: localWorktreeId }] },
|
||||
terminalLayoutsByTabId: { 'remote-tab': parkedLayout }
|
||||
},
|
||||
{ ...catalog, tabsByWorktree: { [remoteWorktreeId]: [remoteTab] } }
|
||||
)
|
||||
|
||||
expect(patch).toHaveBeenCalledTimes(1)
|
||||
expect(patch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ terminalLayoutsByTabId: { 'remote-tab': parkedLayout } })
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildHostIdByWorktreeId nested ownership', () => {
|
||||
it('persists an SSH worktree in its paired HUB session partition', () => {
|
||||
const worktreeId = 'nested-repo::/srv/remote-wt'
|
||||
|
||||
@@ -31,8 +31,13 @@ import {
|
||||
indexWorkspaceRuntimeHostOwnership,
|
||||
type WorkspaceRuntimeOwnerProjection
|
||||
} from './workspace-runtime-host-ownership'
|
||||
import {
|
||||
buildWorktreeIdByTabId,
|
||||
extendWorktreeIdByTabId,
|
||||
type WorkspaceTabOwnerCatalog
|
||||
} from '../../../shared/workspace-session-host-records'
|
||||
|
||||
export type HostPersistenceState = {
|
||||
export type HostPersistenceState = WorkspaceTabOwnerCatalog & {
|
||||
repos: readonly Pick<Repo, 'id' | 'connectionId' | 'executionHostId'>[]
|
||||
projectGroups?: readonly { id: string; executionHostId?: string | null }[]
|
||||
folderWorkspaces?: readonly {
|
||||
@@ -214,7 +219,14 @@ function splitWorkspaceSessionForWrite(
|
||||
mode: HostSessionWriteMode
|
||||
): HostSessionSlices {
|
||||
const routing = buildHostSessionRouting(state)
|
||||
const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId)
|
||||
// Why the live catalogs: a debounced patch carries only the fields that changed, so a park
|
||||
// capture's layouts-only patch names no tab rows. Routed by the payload alone, every tab-keyed
|
||||
// row fell into 'local', where main pruned the scrollback it could not attribute to a remote
|
||||
// worktree — the runtime partition never received the capture (#21295).
|
||||
const worktreeIdByTabId = extendWorktreeIdByTabId(buildWorktreeIdByTabId(payload), state)
|
||||
const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId, {
|
||||
worktreeIdByTabId
|
||||
})
|
||||
attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode)
|
||||
return slices
|
||||
}
|
||||
|
||||
@@ -210,6 +210,37 @@ describe('splitWorkspaceSessionByHost', () => {
|
||||
expect(slices[RUNTIME_A]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes tab-keyed rows through a caller-supplied tab index when the payload has no tab rows', () => {
|
||||
// A debounced patch that changed only layouts (a park capture) or only PTY bindings carries
|
||||
// no tabsByWorktree; the index stands in for the rows the payload never mentioned.
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalLayoutsByTabId: { 't-a': makeLayout() },
|
||||
remoteSessionIdsByTabId: { 't-a': 'sess-a' },
|
||||
terminalPtyIncarnationsByPaneKey: { 't-a:leaf-1': 'inc-3' }
|
||||
}
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix(), {
|
||||
worktreeIdByTabId: new Map([['t-a', 'a-wt-1']])
|
||||
})
|
||||
expect(slices[RUNTIME_A]?.terminalLayoutsByTabId).toHaveProperty('t-a')
|
||||
expect(slices[RUNTIME_A]?.remoteSessionIdsByTabId).toEqual({ 't-a': 'sess-a' })
|
||||
expect(slices[RUNTIME_A]?.terminalPtyIncarnationsByPaneKey).toEqual({ 't-a:leaf-1': 'inc-3' })
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toEqual({})
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.remoteSessionIdsByTabId).toEqual({})
|
||||
})
|
||||
|
||||
it('keeps a tab the supplied index does not name in the local slice', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
terminalLayoutsByTabId: { orphan: makeLayout() }
|
||||
}
|
||||
const slices = splitWorkspaceSessionByHost(state, ownerByPrefix(), {
|
||||
worktreeIdByTabId: new Map([['t-a', 'a-wt-1']])
|
||||
})
|
||||
expect(slices[LOCAL_EXECUTION_HOST_ID]?.terminalLayoutsByTabId).toHaveProperty('orphan')
|
||||
expect(slices[RUNTIME_A]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('routes browser pages via their record worktreeId', () => {
|
||||
const state: WorkspaceSessionState = {
|
||||
...getDefaultWorkspaceSession(),
|
||||
|
||||
@@ -147,11 +147,13 @@ function assignKeyedByResolvedWorktree(
|
||||
|
||||
/** Partition a unified session into per-host slices keyed by ExecutionHostId.
|
||||
* Global fields are copied to the 'local' slice; worktree-scoped data is routed
|
||||
* to its owner host. Entries whose owning worktree is unknown (orphan tabs,
|
||||
* files, pages) stay in 'local' so they are never silently dropped. */
|
||||
* to its owner host. Entries whose owning worktree is unknown to the payload and
|
||||
* to `worktreeIdByTabId` (orphan tabs, files, pages) stay in 'local' so they are
|
||||
* never silently dropped. */
|
||||
export function splitWorkspaceSessionByHost(
|
||||
state: WorkspaceSessionState,
|
||||
hostIdByWorktreeId: HostIdByWorktreeId
|
||||
hostIdByWorktreeId: HostIdByWorktreeId,
|
||||
options: { worktreeIdByTabId?: Map<string, string> } = {}
|
||||
): HostSessionSlices {
|
||||
// Template carries only the global fields; per-field assigners add the rest.
|
||||
// Why: copy only own-keys so a partial patch (where most globals are absent)
|
||||
@@ -176,7 +178,7 @@ export function splitWorkspaceSessionByHost(
|
||||
|
||||
const ctx: SplitContext = {
|
||||
hostIdByWorktreeId,
|
||||
worktreeIdByTabId: buildWorktreeIdByTabId(state),
|
||||
worktreeIdByTabId: options.worktreeIdByTabId ?? buildWorktreeIdByTabId(state),
|
||||
worktreeIdByFileId: buildWorktreeIdByFileId(state)
|
||||
}
|
||||
|
||||
|
||||
@@ -24,6 +24,36 @@ export function buildWorktreeIdByTabId(state: WorkspaceSessionState): Map<string
|
||||
return byTab
|
||||
}
|
||||
|
||||
/** The renderer's live tab catalogs, for routing a payload that carries no tab rows of its own. */
|
||||
export type WorkspaceTabOwnerCatalog = {
|
||||
tabsByWorktree?: Readonly<Record<string, readonly { id: string }[]>>
|
||||
unifiedTabsByWorktree?: Readonly<Record<string, readonly { id: string; worktreeId: string }[]>>
|
||||
}
|
||||
|
||||
/** Fill tabs the payload never mentioned from the live catalogs. Payload rows win: main merges the
|
||||
* payload's own `tabsByWorktree` into whichever partition it lands in, so a tab-keyed row has to
|
||||
* follow the tab row in THIS write, not a newer store state the debounce has not emitted yet. */
|
||||
export function extendWorktreeIdByTabId(
|
||||
byTab: Map<string, string>,
|
||||
catalog: WorkspaceTabOwnerCatalog | undefined
|
||||
): Map<string, string> {
|
||||
for (const [worktreeId, tabs] of Object.entries(catalog?.tabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (!byTab.has(tab.id)) {
|
||||
byTab.set(tab.id, worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const tabs of Object.values(catalog?.unifiedTabsByWorktree ?? {})) {
|
||||
for (const tab of tabs) {
|
||||
if (!byTab.has(tab.id)) {
|
||||
byTab.set(tab.id, tab.worktreeId)
|
||||
}
|
||||
}
|
||||
}
|
||||
return byTab
|
||||
}
|
||||
|
||||
/** The workspace a pane key belongs to. A pane key is `<tabId>:<leafId>`; both the split and the
|
||||
* stranded-partition adoption resolve it here so neither can parse it its own way. */
|
||||
export function worktreeIdForPaneKey(
|
||||
|
||||
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* Does a parked remote pane's scrollback survive a full client restart?
|
||||
*
|
||||
* The within-session case is covered by paired-remote-terminal-parked-scrollback-survives.spec.ts.
|
||||
* This restarts the client on the SAME profile — the shape of an app update — and reports, hop by
|
||||
* hop, where the park capture lands.
|
||||
*
|
||||
* The load-bearing oracle is ON DISK, not the reveal. The paired host's pty stays alive across the
|
||||
* client restart and keeps a retained tail (terminal-multiplex-initial-snapshot:
|
||||
* `data: serialized?.data ?? read.tail…`), so a reveal can be served by the host rather than by the
|
||||
* client's persisted copy — it is kept as an end-to-end check, not the proof. The contradiction-
|
||||
* capable signal for this fix is which partition on disk holds the buffer after a debounced write.
|
||||
*
|
||||
* Two quits, because they exercise different writers:
|
||||
* - clean quit: the beforeunload checkpoint writes a full per-partition snapshot, which carries
|
||||
* tabsByWorktree and so routes the layout correctly even on `main` — a control that the harness
|
||||
* works, not a proof of this fix.
|
||||
* - hard kill: only the debounced patch writer ran. A park capture's patch changes only
|
||||
* terminalLayoutsByTabId, so it carries no tab rows; before the fix every layout fell into the
|
||||
* 'local' partition, where main strips scrollback it cannot attribute to a remote worktree, and
|
||||
* the remote host's partition never received the capture (#21295). This is the test that fails
|
||||
* with the fix reverted.
|
||||
*
|
||||
* The on-disk reader walks the local `workspaceSession` AND every `workspaceSessionsByHostId`
|
||||
* partition, and names the partition each reading came from — the issue's original "onDisk: 0" was a
|
||||
* reader that inspected only the local blob while the capture sat in the runtime partition, a
|
||||
* reading that could not contradict itself. An empty list means no session file at all (a deleted
|
||||
* profile), distinguished from an empty buffer.
|
||||
*
|
||||
* Run:
|
||||
* pnpm exec playwright test \
|
||||
* tests/e2e/paired-remote-terminal-parked-scrollback-restart.spec.ts \
|
||||
* --config tests/playwright.config.ts --project electron-headless --workers=1
|
||||
*/
|
||||
import { globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import type { Page, TestInfo } from '@stablyai/playwright-test'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { resolveLeafScrollbackBuffers } from '../../src/renderer/src/components/terminal-pane/leaf-scrollback-resolution'
|
||||
import {
|
||||
createRuntimeDesktopPairingOffer,
|
||||
launchPairedElectronClient,
|
||||
type PairedElectronClient
|
||||
} from './helpers/paired-electron-client'
|
||||
import { cleanupE2EDaemons, forceQuitElectronAppForE2E } from './helpers/electron-process-shutdown'
|
||||
import {
|
||||
callEnvironment,
|
||||
createPairedHostTerminal,
|
||||
openPairedClientTab,
|
||||
waitForPairedPaneMarker
|
||||
} from './helpers/paired-host-terminal'
|
||||
import { focusActiveTerminalInput } from './helpers/terminal'
|
||||
import { waitForTabParked } from './helpers/terminal-hidden-parking'
|
||||
|
||||
const PARK_DELAY_MS = 2_000
|
||||
const PAINT_BUDGET_MS = 30_000
|
||||
/** Renderer debounce (150 ms) + main's save debounce (1 s, 5 s max wait), with slack. */
|
||||
const DEBOUNCED_WRITE_BUDGET_MS = 20_000
|
||||
const scratch = mkdtempSync(path.join(os.tmpdir(), 'orca-parked-restart-'))
|
||||
const fixturePath = path.join(scratch, 'echo-terminal.mjs')
|
||||
writeFileSync(
|
||||
fixturePath,
|
||||
[
|
||||
"process.stdout.write('READY\\r\\n')",
|
||||
"process.stdin.setEncoding('utf8')",
|
||||
"let pending = ''",
|
||||
"process.stdin.on('data', (data) => {",
|
||||
' pending += data',
|
||||
' const lines = pending.split(/\\r\\n|\\r|\\n/)',
|
||||
" pending = lines.pop() ?? ''",
|
||||
' for (const line of lines) {',
|
||||
' process.stdout.write(`LINE:${line}\\r\\n`)',
|
||||
' }',
|
||||
'})',
|
||||
'process.stdin.resume()'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
test.afterAll(() => {
|
||||
rmSync(scratch, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function fixtureCommand(): string {
|
||||
const command = [process.execPath, fixturePath]
|
||||
return process.platform === 'win32'
|
||||
? command.map((value) => `"${value.replaceAll('"', '""')}"`).join(' ')
|
||||
: command.map((value) => `'${value.replaceAll("'", `'\\''`)}'`).join(' ')
|
||||
}
|
||||
|
||||
type OnDiskPartitionReading = {
|
||||
partition: string
|
||||
hasTabRow: boolean
|
||||
hasLayout: boolean
|
||||
bufferLength: number
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
function readSessionPartition(
|
||||
partition: string,
|
||||
session: unknown,
|
||||
webTabId: string
|
||||
): OnDiskPartitionReading | null {
|
||||
if (!isRecord(session)) {
|
||||
return null
|
||||
}
|
||||
const tabsByWorktree = isRecord(session.tabsByWorktree) ? session.tabsByWorktree : {}
|
||||
const hasTabRow = Object.values(tabsByWorktree).some(
|
||||
(tabs) => Array.isArray(tabs) && tabs.some((tab) => isRecord(tab) && tab.id === webTabId)
|
||||
)
|
||||
const layouts = isRecord(session.terminalLayoutsByTabId) ? session.terminalLayoutsByTabId : {}
|
||||
const layout = layouts[webTabId]
|
||||
const hasLayout = isRecord(layout)
|
||||
const localOnlyHomes = isRecord(session.localOnlyScrollbackByTabId)
|
||||
? session.localOnlyScrollbackByTabId
|
||||
: {}
|
||||
// Why the resolver: an ordinary park writes localOnlyScrollbackByTabId, not buffersByLeafId, so
|
||||
// a reader of one home reports a false zero. Same read production restores through.
|
||||
const buffers = resolveLeafScrollbackBuffers({
|
||||
shared: hasLayout ? { buffersByLeafId: stringRecord(layout.buffersByLeafId) } : undefined,
|
||||
localOnly: stringRecord(localOnlyHomes[webTabId])
|
||||
})
|
||||
const bufferLength = Object.values(buffers ?? {}).join('').length
|
||||
return { partition, hasTabRow, hasLayout, bufferLength }
|
||||
}
|
||||
|
||||
function stringRecord(value: unknown): Record<string, string> | undefined {
|
||||
if (!isRecord(value)) {
|
||||
return undefined
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
||||
)
|
||||
}
|
||||
|
||||
/** Walks the local `workspaceSession` and every `workspaceSessionsByHostId` partition. An empty
|
||||
* list means no session file was found at all — a reader problem, not an empty buffer. */
|
||||
function readOnDiskPartitions(userDataDir: string, webTabId: string): OnDiskPartitionReading[] {
|
||||
const readings: OnDiskPartitionReading[] = []
|
||||
for (const file of globSync(path.join(userDataDir, '**', 'orca-data.json'))) {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(readFileSync(file, 'utf8'))
|
||||
if (!isRecord(parsed)) {
|
||||
continue
|
||||
}
|
||||
const local = readSessionPartition('local', parsed.workspaceSession, webTabId)
|
||||
if (local) {
|
||||
readings.push(local)
|
||||
}
|
||||
const partitions = isRecord(parsed.workspaceSessionsByHostId)
|
||||
? parsed.workspaceSessionsByHostId
|
||||
: {}
|
||||
for (const [hostId, session] of Object.entries(partitions)) {
|
||||
const reading = readSessionPartition(hostId, session, webTabId)
|
||||
if (reading) {
|
||||
readings.push(reading)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// A partially written profile is itself a datapoint; keep scanning the rest.
|
||||
}
|
||||
}
|
||||
return readings
|
||||
}
|
||||
|
||||
/** Bytes the remote host's own partition holds for the tab; -1 when no partition names it. */
|
||||
function runtimePartitionBufferLength(readings: OnDiskPartitionReading[]): number {
|
||||
const runtime = readings.filter((reading) => reading.partition.startsWith('runtime:'))
|
||||
return runtime.length === 0 ? -1 : Math.max(...runtime.map((reading) => reading.bufferLength))
|
||||
}
|
||||
|
||||
/** Bytes any 'local' partition reading holds for the tab; the pre-fix bug parked a stripped (0) or
|
||||
* buffered copy here instead of in the remote host's partition. */
|
||||
function localPartitionBufferLength(readings: OnDiskPartitionReading[]): number {
|
||||
const local = readings.filter((reading) => reading.partition === 'local')
|
||||
return local.length === 0 ? -1 : Math.max(...local.map((reading) => reading.bufferLength))
|
||||
}
|
||||
|
||||
async function waitForRuntimePartitionCapture(
|
||||
userDataDir: string,
|
||||
webTabId: string
|
||||
): Promise<OnDiskPartitionReading[]> {
|
||||
const deadline = Date.now() + DEBOUNCED_WRITE_BUDGET_MS
|
||||
let readings = readOnDiskPartitions(userDataDir, webTabId)
|
||||
while (runtimePartitionBufferLength(readings) <= 0 && Date.now() < deadline) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500))
|
||||
readings = readOnDiskPartitions(userDataDir, webTabId)
|
||||
}
|
||||
return readings
|
||||
}
|
||||
|
||||
async function readStoreBufferLength(page: Page, webTabId: string): Promise<number> {
|
||||
// Why the debug handle: this body runs in the renderer, so it reaches the resolver through the
|
||||
// e2e handle rather than reading either store home directly.
|
||||
return page.evaluate((id) => {
|
||||
const buffers = window.__terminalParkingDebug?.resolveLeafScrollback(id)
|
||||
return Object.values(buffers ?? {}).join('').length
|
||||
}, webTabId)
|
||||
}
|
||||
|
||||
async function activateWorktree(page: Page, worktreeId: string): Promise<void> {
|
||||
await page.evaluate((id) => {
|
||||
const state = window.__store?.getState()
|
||||
state?.setActiveView('terminal')
|
||||
state?.setActiveWorktree(id)
|
||||
}, worktreeId)
|
||||
}
|
||||
|
||||
type ParkedRemoteTerminal = {
|
||||
client: PairedElectronClient
|
||||
worktreeId: string
|
||||
webTabId: string
|
||||
token: string
|
||||
tokenBeforePark: boolean
|
||||
storeAtPark: number
|
||||
}
|
||||
|
||||
/** Open a remote-runtime terminal on the paired client, type a token into it, and cold-park it
|
||||
* behind two decoy tabs. The token exists only in that pane's buffer — nothing replays stdin.
|
||||
* Every host terminal it creates is pushed into `createdTerminals` as it is created, so the
|
||||
* caller's `finally` can close them even if this throws partway. */
|
||||
async function parkRemoteTerminalWithToken(
|
||||
orcaPage: Page,
|
||||
client: PairedElectronClient,
|
||||
createdTerminals: string[]
|
||||
): Promise<ParkedRemoteTerminal> {
|
||||
const worktreeId = await orcaPage.evaluate(() => {
|
||||
const id = window.__store?.getState().activeWorktreeId
|
||||
if (!id) {
|
||||
throw new Error('headed host has no active worktree')
|
||||
}
|
||||
return id
|
||||
})
|
||||
await expect
|
||||
.poll(
|
||||
() =>
|
||||
client.page.evaluate(
|
||||
(id) =>
|
||||
window.__store
|
||||
?.getState()
|
||||
.allWorktrees()
|
||||
.some((worktree) => worktree.id === id) ?? false,
|
||||
worktreeId
|
||||
),
|
||||
{ timeout: 60_000, message: 'paired client never saw the host worktree' }
|
||||
)
|
||||
.toBe(true)
|
||||
await activateWorktree(client.page, worktreeId)
|
||||
|
||||
const target = await createPairedHostTerminal(
|
||||
client.page,
|
||||
client.environmentId,
|
||||
worktreeId,
|
||||
fixtureCommand()
|
||||
)
|
||||
createdTerminals.push(target.terminal)
|
||||
const decoys = []
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const decoy = await createPairedHostTerminal(
|
||||
client.page,
|
||||
client.environmentId,
|
||||
worktreeId,
|
||||
fixtureCommand()
|
||||
)
|
||||
createdTerminals.push(decoy.terminal)
|
||||
decoys.push(decoy)
|
||||
}
|
||||
|
||||
await openPairedClientTab(client.page, worktreeId, target.webTabId)
|
||||
await waitForPairedPaneMarker(client.page, target.webTabId, 'READY', PAINT_BUDGET_MS)
|
||||
const token = `LINE:token-${randomUUID()}`
|
||||
await focusActiveTerminalInput(client.page)
|
||||
await client.page.keyboard.type(token.slice('LINE:'.length))
|
||||
await client.page.keyboard.press('Enter')
|
||||
const tokenBeforePark = await waitForPairedPaneMarker(
|
||||
client.page,
|
||||
target.webTabId,
|
||||
token,
|
||||
PAINT_BUDGET_MS
|
||||
)
|
||||
|
||||
for (const decoy of decoys) {
|
||||
await openPairedClientTab(client.page, worktreeId, decoy.webTabId)
|
||||
}
|
||||
await waitForTabParked(client.page, target.webTabId, { parkDelayMs: PARK_DELAY_MS })
|
||||
const storeAtPark = await readStoreBufferLength(client.page, target.webTabId)
|
||||
return { client, worktreeId, webTabId: target.webTabId, token, tokenBeforePark, storeAtPark }
|
||||
}
|
||||
|
||||
async function relaunchAndReveal(
|
||||
offer: Awaited<ReturnType<typeof createRuntimeDesktopPairingOffer>>,
|
||||
testInfo: TestInfo,
|
||||
parked: ParkedRemoteTerminal,
|
||||
extraEnv: Record<string, string>
|
||||
): Promise<{
|
||||
relaunched: PairedElectronClient
|
||||
storeAfterRelaunch: number
|
||||
tokenAfterReveal: boolean
|
||||
}> {
|
||||
const relaunched = await launchPairedElectronClient(offer, testInfo, 'parked-restart-relaunch', {
|
||||
extraEnv,
|
||||
reuseUserDataDir: parked.client.userDataDir
|
||||
})
|
||||
await activateWorktree(relaunched.page, parked.worktreeId)
|
||||
const storeAfterRelaunch = await readStoreBufferLength(relaunched.page, parked.webTabId)
|
||||
await openPairedClientTab(relaunched.page, parked.worktreeId, parked.webTabId)
|
||||
const tokenAfterReveal = await waitForPairedPaneMarker(
|
||||
relaunched.page,
|
||||
parked.webTabId,
|
||||
parked.token,
|
||||
PAINT_BUDGET_MS
|
||||
)
|
||||
return { relaunched, storeAfterRelaunch, tokenAfterReveal }
|
||||
}
|
||||
|
||||
async function closeCreatedTerminals(
|
||||
client: PairedElectronClient,
|
||||
createdTerminals: readonly string[]
|
||||
): Promise<void> {
|
||||
for (const terminal of createdTerminals) {
|
||||
await callEnvironment(client.page, client.environmentId, 'terminal.closeTab', {
|
||||
terminal
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('host retains nothing', () => {
|
||||
test.use({
|
||||
orcaAppExtraEnv: { ORCA_E2E_FORCE_REMOTE_TERMINAL_SNAPSHOT_UNAVAILABLE: '1' }
|
||||
})
|
||||
|
||||
test('the debounced write alone lands the parked scrollback in the remote host’s partition, so a hard kill loses nothing', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(600_000)
|
||||
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
|
||||
const extraEnv = { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) }
|
||||
const first = await launchPairedElectronClient(offer, testInfo, 'parked-kill', { extraEnv })
|
||||
const userDataDir = first.userDataDir
|
||||
const createdTerminals: string[] = []
|
||||
let relaunched: PairedElectronClient | null = null
|
||||
try {
|
||||
const parked = await parkRemoteTerminalWithToken(orcaPage, first, createdTerminals)
|
||||
|
||||
// Why no beforeunload and no ordinary close: both run the shutdown checkpoint, whose full
|
||||
// snapshot routes the layout correctly and would mask a misrouted debounced patch. Only the
|
||||
// debounced patch writer runs between the park and this kill.
|
||||
const onDiskAfterPark = await waitForRuntimePartitionCapture(userDataDir, parked.webTabId)
|
||||
await forceQuitElectronAppForE2E(first.app)
|
||||
await cleanupE2EDaemons(userDataDir)
|
||||
|
||||
const reveal = await relaunchAndReveal(offer, testInfo, parked, extraEnv)
|
||||
relaunched = reveal.relaunched
|
||||
|
||||
console.log(
|
||||
`[parked-restart] hard-kill ${JSON.stringify({
|
||||
tokenBeforePark: parked.tokenBeforePark,
|
||||
storeAtPark: parked.storeAtPark,
|
||||
onDiskAfterPark,
|
||||
storeAfterRelaunch: reveal.storeAfterRelaunch,
|
||||
tokenAfterReveal: reveal.tokenAfterReveal
|
||||
})}`
|
||||
)
|
||||
expect({
|
||||
tokenBeforePark: parked.tokenBeforePark,
|
||||
capturedAtPark: parked.storeAtPark > 0,
|
||||
profileSurvived: onDiskAfterPark.length > 0,
|
||||
// The load-bearing assertion: the debounced writer routed the capture to the remote host's
|
||||
// own partition, and did not leave it stripped in 'local'. This is what fails on `main`.
|
||||
runtimePartitionHoldsCapture: runtimePartitionBufferLength(onDiskAfterPark) > 0,
|
||||
localPartitionDidNotKeepCapture: localPartitionBufferLength(onDiskAfterPark) <= 0,
|
||||
// Secondary: the reveal may be served by the live host's retained tail rather than the
|
||||
// disk copy, so it is not the fix's oracle — it confirms relaunch and a visible pane.
|
||||
tokenAfterReveal: reveal.tokenAfterReveal
|
||||
}).toEqual({
|
||||
tokenBeforePark: true,
|
||||
capturedAtPark: true,
|
||||
profileSurvived: true,
|
||||
runtimePartitionHoldsCapture: true,
|
||||
localPartitionDidNotKeepCapture: true,
|
||||
tokenAfterReveal: true
|
||||
})
|
||||
} finally {
|
||||
const live = relaunched ?? first
|
||||
await closeCreatedTerminals(live, createdTerminals)
|
||||
await live.dispose().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('a clean quit also lands the parked scrollback in the remote host’s partition (control)', async ({
|
||||
orcaPage
|
||||
}, testInfo) => {
|
||||
test.setTimeout(600_000)
|
||||
const offer = await createRuntimeDesktopPairingOffer(orcaPage)
|
||||
const extraEnv = { ORCA_E2E_TERMINAL_PARKING_DELAY_MS: String(PARK_DELAY_MS) }
|
||||
const first = await launchPairedElectronClient(offer, testInfo, 'parked-restart', { extraEnv })
|
||||
const userDataDir = first.userDataDir
|
||||
const createdTerminals: string[] = []
|
||||
let relaunched: PairedElectronClient | null = null
|
||||
try {
|
||||
const parked = await parkRemoteTerminalWithToken(orcaPage, first, createdTerminals)
|
||||
|
||||
await first.page.evaluate(() => window.dispatchEvent(new Event('beforeunload')))
|
||||
await first.quitPreservingProfile()
|
||||
const onDiskAfterQuit = readOnDiskPartitions(userDataDir, parked.webTabId)
|
||||
|
||||
const reveal = await relaunchAndReveal(offer, testInfo, parked, extraEnv)
|
||||
relaunched = reveal.relaunched
|
||||
|
||||
console.log(
|
||||
`[parked-restart] clean-quit ${JSON.stringify({
|
||||
tokenBeforePark: parked.tokenBeforePark,
|
||||
storeAtPark: parked.storeAtPark,
|
||||
onDiskAfterQuit,
|
||||
storeAfterRelaunch: reveal.storeAfterRelaunch,
|
||||
tokenAfterReveal: reveal.tokenAfterReveal
|
||||
})}`
|
||||
)
|
||||
// Why tokenAfterReveal is logged and not asserted here: this control proves the harness
|
||||
// (park, quit, profile, partition routing), not the reveal. On identical product code the
|
||||
// clean-quit reveal measured true, true, false across three runs — the live host may serve
|
||||
// it from its own tail — so it cannot carry an assertion. The hard-kill test keeps it,
|
||||
// because there the host is forced unavailable and the reveal must come from the client copy.
|
||||
expect({
|
||||
tokenBeforePark: parked.tokenBeforePark,
|
||||
capturedAtPark: parked.storeAtPark > 0,
|
||||
// Distinguishes a deleted profile (no partitions) from an empty buffer.
|
||||
profileSurvived: onDiskAfterQuit.length > 0,
|
||||
runtimePartitionHoldsCapture: runtimePartitionBufferLength(onDiskAfterQuit) > 0,
|
||||
localPartitionDidNotKeepCapture: localPartitionBufferLength(onDiskAfterQuit) <= 0
|
||||
}).toEqual({
|
||||
tokenBeforePark: true,
|
||||
capturedAtPark: true,
|
||||
profileSurvived: true,
|
||||
runtimePartitionHoldsCapture: true,
|
||||
localPartitionDidNotKeepCapture: true
|
||||
})
|
||||
} finally {
|
||||
const live = relaunched ?? first
|
||||
await closeCreatedTerminals(live, createdTerminals)
|
||||
await live.dispose().catch(() => undefined)
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user