diff --git a/src/main/ipc/pty-absent-pane-respawn.test.ts b/src/main/ipc/pty-absent-pane-respawn.test.ts new file mode 100644 index 00000000000..99dfb508040 --- /dev/null +++ b/src/main/ipc/pty-absent-pane-respawn.test.ts @@ -0,0 +1,217 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { SessionNotFoundError } from '../daemon/daemon-errors' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { + installRestartHarness, + leafId, + paneKey, + registerWithFakes, + restartSpawnArgs, + tabId +} from './pty-pane-owner-swap.test-fixture' +import { WorkspaceLayoutStream } from '../runtime/workspace-layout-stream' +import { keepAbsentBindingForSwap } from './pty/ipc/spawn-absent-binding-swap' +import type { WorkspaceLayoutStreamEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames' + +const layoutOwner = vi.hoisted(() => ({ isMain: false })) +vi.mock('../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return layoutOwner.isMain + } +})) +vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) +vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) +vi.mock('node-pty', () => import('./pty-ipc-mock-registry').then((m) => m.nodePtyModuleMock())) +vi.mock('node:child_process', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).childProcessModuleMock(await importOriginal()) +) +vi.mock('../opencode/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.openCodeHookServiceModuleMock()) +) +vi.mock('../mimo/hook-service', () => + import('./pty-ipc-mock-registry').then((m) => m.mimoHookServiceModuleMock()) +) +vi.mock('../agent-hooks/server', () => + import('./pty-ipc-mock-registry').then((m) => m.agentHookServerModuleMock()) +) +vi.mock('../pi/titlebar-extension-service', () => + import('./pty-ipc-mock-registry').then((m) => m.piTitlebarExtensionModuleMock()) +) +vi.mock('../pwsh', () => import('./pty-ipc-mock-registry').then((m) => m.pwshModuleMock())) +vi.mock('../wsl', async (importOriginal) => + (await import('./pty-ipc-mock-registry')).wslModuleMock(await importOriginal()) +) +vi.mock('../telemetry/client', () => + import('./pty-ipc-mock-registry').then((m) => m.telemetryClientModuleMock()) +) +vi.mock('../telemetry/classify-error', () => + import('./pty-ipc-mock-registry').then((m) => m.classifyErrorModuleMock()) +) +vi.mock('../cli/linux-terminal-orca-cli-shim', () => + import('./pty-ipc-mock-registry').then((m) => m.linuxCliShimModuleMock()) +) +vi.mock('../memory/pty-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.ptyRegistryModuleMock()) +) +vi.mock('../agent-hooks/migration-unsupported-pty-state', () => + import('./pty-ipc-mock-registry').then((m) => m.migrationUnsupportedPtyModuleMock()) +) +vi.mock('../codex/codex-pane-account-registry', () => + import('./pty-ipc-mock-registry').then((m) => m.codexPaneAccountRegistryModuleMock()) +) +vi.mock('../codex/codex-state-db-backfill-recovery', () => + import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) +) + +type Harness = ReturnType +type StreamStore = ReturnType[0]['store']> + +/** Main's layout stream over the harness Store, as a window subscribes to it. */ +function watchLayout(store: Harness['store']): { + events: WorkspaceLayoutStreamEvent[] + shownPtyId: string | undefined +} { + const stream = new WorkspaceLayoutStream({ + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the stream reads only the session getters and the write listener this fake defines. + store: () => store as unknown as StreamStore, + homeHostId: () => 'local', + liveTitle: () => null + }) + const events: WorkspaceLayoutStreamEvent[] = [] + const { snapshot } = stream.subscribe((event) => events.push(event)) + const pane = snapshot + .flatMap(({ layout }) => layout.tabs) + .find((tab) => tab.entityId === tabId) + ?.terminal?.panes.find((candidate) => candidate.leafId === leafId) + return { events, shownPtyId: pane?.ptyId } +} + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + +/** The frames as records, keeping only what each workspace change carries. */ +const recordsOf = (events: WorkspaceLayoutStreamEvent[]) => + events.map((event) => + event.type === 'records' + ? event.workspaces.map(({ key: _key, ...change }) => change) + : `${event.type} frame` + ) + +describe('pty:spawn for a restored pane whose process is gone', () => { + const { handlers, mainWindow } = setupPtyIpcSuite() + + async function respawn( + options: Parameters[0], + prepare: (harness: Harness) => void = () => {} + ) { + const harness = installRestartHarness({ oldSessionAbsent: true, ...options }) + prepare(harness) + const retire = vi.spyOn(harness.store, 'retirePtyBinding') + registerWithFakes(mainWindow, harness.runtime, harness.store) + const layout = watchLayout(harness.store) + const spawned = handlers.get('pty:spawn')!(null, restartSpawnArgs()) + return { ...harness, retire, layout, spawned } + } + + describe('in a layout-owner build', () => { + beforeEach(() => { + layoutOwner.isMain = true + }) + afterEach(() => { + layoutOwner.isMain = false + }) + + it('rebinds the same id in one write and publishes its one binding record', async () => { + const { spawned, durableBindings, boundAtFreshLaunch, retire, layout } = await respawn({ + freshId: 'pty-old' + }) + + await expect(spawned).resolves.toMatchObject({ id: 'pty-old' }) + await settle() + expect(layout.shownPtyId).toBe('pty-old') + expect(boundAtFreshLaunch).toEqual(['pty-old']) + expect(durableBindings).toEqual(['pty-old']) + expect(retire).not.toHaveBeenCalled() + // Its generation rises, so a reader tells the fresh process from the dead one. + expect(recordsOf(layout.events)).toEqual([ + [{ change: 'records', bindings: { set: [{ leafId, ptyId: 'pty-old', ptyGeneration: 1 }] } }] + ]) + }) + + it('swaps a new id in one write and publishes one frame', async () => { + const { spawned, durableBindings, store, layout } = await respawn({}) + + await expect(spawned).resolves.toMatchObject({ id: 'pty-new' }) + await settle() + expect(layout.shownPtyId).toBe('pty-old') + expect(durableBindings).toEqual(['pty-new']) + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey).toEqual({ + [paneKey]: 'inc-new' + }) + expect(recordsOf(layout.events)).toEqual([ + [{ change: 'records', bindings: { set: [{ leafId, ptyId: 'pty-new', ptyGeneration: 1 }] } }] + ]) + }) + + it('drops the dead incarnation when the fresh process reports none, as the retire did', async () => { + const { spawned, durableBindings, store } = await respawn({ + freshId: 'pty-old', + freshIncarnationId: null + }) + + await expect(spawned).resolves.toMatchObject({ id: 'pty-old' }) + expect(durableBindings).toEqual(['pty-old']) + expect(store.getWorkspaceSession().terminalPtyIncarnationsByPaneKey).toEqual({}) + }) + + it('keeps the swapped same-id binding when a later commit step fails', async () => { + const { spawned, leafBinding, retire } = await respawn( + { freshId: 'pty-old' }, + ({ runtime }) => { + runtime.registerPty.mockImplementation(() => { + throw new Error('register failed') + }) + } + ) + + await expect(spawned).rejects.toThrow('register failed') + expect(retire).not.toHaveBeenCalled() + expect(leafBinding()).toBe('pty-old') + }) + + it('keeps the early retire for a pane on an SSH host', () => { + const spawnOn = (connectionId?: string) => + keepAbsentBindingForSwap( + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the swap gate reads only args, the reservation and the replaced owner. + { + args: { connectionId }, + paneSpawnReservation: {}, + replacedPaneOwner: null + } as unknown as Parameters[0] + ) + expect(spawnOn()).toBeTypeOf('function') + // Its dead owner lives on that host, which the swap does not reach. + expect(spawnOn('ssh-conn-1')).toBeUndefined() + }) + + it('still retires the dead binding when the fresh spawn fails', async () => { + const { spawned, leafBinding } = await respawn({}, ({ providerSpawn }) => { + providerSpawn.mockRejectedValueOnce(new SessionNotFoundError('pty-old')) + providerSpawn.mockRejectedValueOnce(new Error('spawn failed')) + }) + + await expect(spawned).rejects.toThrow('spawn failed') + expect(leafBinding()).toBeUndefined() + }) + }) + + it('keeps the early retire in a window-owned build', async () => { + const { spawned, durableBindings, boundAtFreshLaunch, retire } = await respawn({ + freshId: 'pty-old' + }) + + await expect(spawned).resolves.toMatchObject({ id: 'pty-old' }) + expect(boundAtFreshLaunch).toEqual([undefined]) + expect(durableBindings).toEqual([undefined, 'pty-old']) + expect(retire).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/ipc/pty-dead-owner-respawn.test.ts b/src/main/ipc/pty-dead-owner-respawn.test.ts index a4490c7bea5..2465d2378bb 100644 --- a/src/main/ipc/pty-dead-owner-respawn.test.ts +++ b/src/main/ipc/pty-dead-owner-respawn.test.ts @@ -3,6 +3,7 @@ import { withDurableRuntimeStore } from '../runtime/runtime-durable-store-fixtur import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SessionNotFoundError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import { registerPtyHandlers } from './pty/register-handlers' import { setLocalPtyProvider } from './pty/provider/registry' import { isHiddenRendererPty } from './pty-hidden-delivery-gate' @@ -177,8 +178,17 @@ describe('registerPtyHandlers', () => { 'inc-proven-absent-owner', { hostExitConfirmed: true } ) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() - expect(store.flushOrThrow).toHaveBeenCalledOnce() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The dead binding stays until the fresh spawn's one bind swaps it. + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + const [swap] = vi.mocked(store.persistPtyBinding).mock.calls[0] ?? [] + expect(typeof swap === 'function' ? swap() : swap).toMatchObject({ + ptyId: 'pty-proven-absent-owner' + }) + } else { + expect(store.setWorkspaceSession).toHaveBeenCalledOnce() + expect(store.flushOrThrow).toHaveBeenCalledOnce() + } }) it('does not poll after the routed provider confirms absence', async () => { const worktreeId = 'repo-1::/tmp/probe-blip-owner' diff --git a/src/main/ipc/pty-pane-owner-swap.test-fixture.ts b/src/main/ipc/pty-pane-owner-swap.test-fixture.ts new file mode 100644 index 00000000000..1121b2b69cc --- /dev/null +++ b/src/main/ipc/pty-pane-owner-swap.test-fixture.ts @@ -0,0 +1,261 @@ +// The pane-owner swap harness: a real binding store over a fake session, a fake provider, and the +// real IPC spawn pipeline. The caller's test file declares the module mocks (see the restart test). +import { vi, type Mock } from 'vitest' +import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence' +import { ptyBindingGenerationOf } from '../persistence/loading-store/pty-binding-generations' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { PtyIpcSuiteFixtures } from './pty-ipc-test-harness' +import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors' +import { SessionNotFoundError } from '../daemon/daemon-errors' +import { makePaneKey } from '../../shared/stable-pane-id' +import { registerPtyHandlers } from './pty/register-handlers' +import { setLocalPtyProvider } from './pty/provider/registry' +import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' + +/** A named type, so the exported harness's inferred type stays portable. */ +function stub(): Mock<(...args: unknown[]) => unknown> { + return vi.fn() +} + +export const worktreeId = 'repo-1::/tmp/restart' +export const cwd = '/tmp/restart' +export const tabId = 'tab-restart' +export const leafId = '12121212-1212-4212-8212-121212121212' +export const paneKey = makePaneKey(tabId, leafId) + +export type RestartHarness = ReturnType +type FakeSession = { + tabsByWorktree: Record + terminalLayoutsByTabId: Record< + string, + { + root: { type: 'leaf'; leafId: string } + activeLeafId: string + expandedLeafId: null + ptyIdsByLeafId: Record + } + > + terminalPtyIncarnationsByPaneKey: Record +} + +export function seedSession(ptyId: string): FakeSession { + return { + tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] }, + terminalLayoutsByTabId: { + [tabId]: { + root: { type: 'leaf', leafId }, + activeLeafId: leafId, + expandedLeafId: null, + ptyIdsByLeafId: { [leafId]: ptyId } + } + }, + terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } + } +} + +export function registerWithFakes( + mainWindow: PtyIpcSuiteFixtures['mainWindow'], + runtime: RestartHarness['runtime'], + store: RestartHarness['store'] +): void { + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn and kill read only the window, runtime and store members these fakes define. + const args = [ + mainWindow, + runtime, + undefined, + undefined, + undefined, + store + ] as unknown as Parameters + registerPtyHandlers(...args) +} + +export function installRestartHarness( + options: { + shutdownFails?: boolean + shutdownGate?: Promise + /** The owning host answers that the old process is gone, as after a relaunch. */ + oldSessionAbsent?: boolean + freshId?: string + /** Null: the fresh process reports no incarnation (a provider without one). */ + freshIncarnationId?: string | null + } = {} +) { + let oldSessionAlive = true + const writeListeners = new Set<(hostId: null) => void>() + const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false } + const boundAtFreshLaunch: (string | undefined)[] = [] + const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => { + if (!spawnOptions.attachOnly) { + boundAtFreshLaunch.push(leafBinding()) + const incarnationId = + options.freshIncarnationId === undefined ? 'inc-new' : options.freshIncarnationId + return { id: options.freshId ?? 'pty-new', ...(incarnationId ? { incarnationId } : {}) } + } + if (options.oldSessionAbsent) { + throw new SessionNotFoundError('pty-old') + } + if (!oldSessionAlive) { + throw new TerminalKilledError('pty-old') + } + return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true } + }) + const shutdown = vi.fn(async () => { + await options.shutdownGate + if (control.shutdownFails) { + throw new Error('daemon unreachable') + } + oldSessionAlive = false + }) + const provider = { + spawn: providerSpawn, + write: stub(), + resize: stub(), + kill: stub(), + shutdown, + sendSignal: stub(), + getCwd: stub(), + getInitialCwd: stub(), + clearBuffer: stub(), + acknowledgeDataEvent: stub(), + hasChildProcesses: stub(), + getForegroundProcess: stub(), + serialize: stub(), + revive: stub(), + onData: vi.fn(() => () => {}), + onReplay: vi.fn(() => () => {}), + onExit: vi.fn(() => () => {}), + listProcesses: vi.fn(async () => []), + attach: stub(), + getDefaultShell: stub(), + getProfiles: stub() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines. + setLocalPtyProvider(provider as unknown as Parameters[0]) + let session = seedSession('pty-old') + const store = { + getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session), + setWorkspaceSession: vi.fn((next) => { + session = next + }), + flushOrThrow: stub(), + onWorkspaceSessionWritten: (listener: (hostId: null) => void) => { + writeListeners.add(listener) + return () => writeListeners.delete(listener) + }, + runDurableMutation: vi.fn( + async ( + mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void } + ) => { + const mutation = mutate() + if (mutation.persist !== false) { + if (control.failNextWrite) { + control.failNextWrite = false + mutation.rollback?.() + throw new Error('save failed') + } + durableBindings.push(leafBinding()) + for (const listener of writeListeners) { + listener(null) + } + } + return mutation.value + } + ), + getWorkspaceSessionHostIds: vi.fn(() => [ + 'local', + ...Object.keys(state.workspaceSessionsByHostId) + ]), + getFolderWorkspace: vi.fn(() => undefined), + getFolderWorkspaces: vi.fn(() => []), + getProjectGroups: vi.fn(() => []), + getRepos: vi.fn(() => []) + } + const state: { + workspaceSession: FakeSession + workspaceSessionsByHostId: Record + } = { workspaceSession: session, workspaceSessionsByHostId: {} } + const durableBindings: (string | undefined)[] = [] + function leafBinding(hostId?: string): string | undefined { + return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] + } + // Like the store, a missing host partition reads as an empty session, never the local one. + store.getWorkspaceSession.mockImplementation((hostId?: string) => + hostId && hostId !== 'local' + ? (state.workspaceSessionsByHostId[hostId] ?? { + tabsByWorktree: {}, + terminalLayoutsByTabId: {}, + terminalPtyIncarnationsByPaneKey: {} + }) + : state.workspaceSession + ) + const bindingRuntime = { + state, + dirtyProfileStateDomains: new Set(), + runDurableMutation: store.runDurableMutation, + writeTimer: null, + pendingWrite: null, + quitFlushStarted: false, + writeGeneration: 0, + lastDurableWriteGeneration: 0, + ptyBindingGenerations: new Map() + } + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes. + const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters< + typeof PtyBindingPersistenceOperations + > + const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs) + const storeWithRetirement = Object.assign(store, { + persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)), + retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations), + getPtyBindingGeneration: (hostId: ExecutionHostId, leafId: string) => + ptyBindingGenerationOf(bindingRuntime, hostId, leafId) + }) + const runtime = { + setPtyController: stub(), + resolveTerminalPane: vi.fn(() => { + throw new Error('terminal_not_found') + }), + markPtyStopRequested: stub(), + createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'), + preAllocateHandleForPty: vi.fn(() => 'term-restart'), + registerPreAllocatedHandleForPty: stub(), + beginPtyRegistration: stub(), + cancelPendingPtyRegistration: stub(), + assertPtyRegistrationAllowed: stub(), + registerPty: stub(), + noteTerminalSpawnCommand: stub(), + seedHeadlessTerminal: stub(), + onPtySpawned: stub(), + onPtyExit: stub(), + onPtyData: stub(), + intentionalPtyStops: new TerminalIntentionalStops() + } + return { + providerSpawn, + shutdown, + store: storeWithRetirement, + runtime, + control, + provider, + leafBinding, + durableBindings, + boundAtFreshLaunch, + partitions: state.workspaceSessionsByHostId + } +} + +export function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) { + return { + cols: 80, + rows: 24, + cwd, + command: 'codex', + launchAgent: 'codex', + worktreeId, + tabId, + leafId, + env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId }, + ...extra + } +} diff --git a/src/main/ipc/pty-pane-restart-replace.test.ts b/src/main/ipc/pty-pane-restart-replace.test.ts index 0b83bae43ee..11dfea26dd7 100644 --- a/src/main/ipc/pty-pane-restart-replace.test.ts +++ b/src/main/ipc/pty-pane-restart-replace.test.ts @@ -1,11 +1,14 @@ -import { PtyBindingPersistenceOperations } from '../persistence/loading-store/pty-binding-persistence' import { describe, expect, it, vi } from 'vitest' -import { setupPtyIpcSuite, type PtyIpcSuiteFixtures } from './pty-ipc-test-harness' -import { TerminalKilledError } from '../daemon/daemon-pty-lifecycle-errors' -import { makePaneKey } from '../../shared/stable-pane-id' -import { registerPtyHandlers } from './pty/register-handlers' -import { setLocalPtyProvider } from './pty/provider/registry' -import { TerminalIntentionalStops } from '../runtime/terminal-intentional-stops' +import { setupPtyIpcSuite } from './pty-ipc-test-harness' +import { + installRestartHarness, + leafId, + paneKey, + registerWithFakes, + restartSpawnArgs, + tabId, + worktreeId +} from './pty-pane-owner-swap.test-fixture' vi.mock('electron', () => import('./pty-ipc-mock-registry').then((m) => m.electronModuleMock())) vi.mock('fs', () => import('./pty-ipc-mock-registry').then((m) => m.fsModuleMock())) @@ -51,225 +54,6 @@ vi.mock('../codex/codex-state-db-backfill-recovery', () => import('./pty-ipc-mock-registry').then((m) => m.codexBackfillRecoveryModuleMock()) ) -const worktreeId = 'repo-1::/tmp/restart' -const cwd = '/tmp/restart' -const tabId = 'tab-restart' -const leafId = '12121212-1212-4212-8212-121212121212' -const paneKey = makePaneKey(tabId, leafId) - -type RestartHarness = ReturnType -type FakeSession = { - tabsByWorktree: Record - terminalLayoutsByTabId: Record< - string, - { - root: { type: 'leaf'; leafId: string } - activeLeafId: string - expandedLeafId: null - ptyIdsByLeafId: Record - } - > - terminalPtyIncarnationsByPaneKey: Record -} - -function seedSession(ptyId: string): FakeSession { - return { - tabsByWorktree: { [worktreeId]: [{ id: tabId, worktreeId, ptyId }] }, - terminalLayoutsByTabId: { - [tabId]: { - root: { type: 'leaf', leafId }, - activeLeafId: leafId, - expandedLeafId: null, - ptyIdsByLeafId: { [leafId]: ptyId } - } - }, - terminalPtyIncarnationsByPaneKey: { [paneKey]: 'inc-old' } - } -} - -function registerWithFakes( - mainWindow: PtyIpcSuiteFixtures['mainWindow'], - runtime: RestartHarness['runtime'], - store: RestartHarness['store'] -): void { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: spawn and kill read only the window, runtime and store members these fakes define. - const args = [ - mainWindow, - runtime, - undefined, - undefined, - undefined, - store - ] as unknown as Parameters - registerPtyHandlers(...args) -} - -function installRestartHarness( - options: { shutdownFails?: boolean; shutdownGate?: Promise } = {} -) { - let oldSessionAlive = true - const control = { shutdownFails: options.shutdownFails ?? false, failNextWrite: false } - const boundAtFreshLaunch: (string | undefined)[] = [] - const providerSpawn = vi.fn(async (spawnOptions: { attachOnly?: boolean }) => { - if (!spawnOptions.attachOnly) { - boundAtFreshLaunch.push(leafBinding()) - return { id: 'pty-new', incarnationId: 'inc-new' } - } - if (!oldSessionAlive) { - throw new TerminalKilledError('pty-old') - } - return { id: 'pty-old', incarnationId: 'inc-old', isReattach: true } - }) - const shutdown = vi.fn(async () => { - await options.shutdownGate - if (control.shutdownFails) { - throw new Error('daemon unreachable') - } - oldSessionAlive = false - }) - const provider = { - spawn: providerSpawn, - write: vi.fn(), - resize: vi.fn(), - kill: vi.fn(), - shutdown, - sendSignal: vi.fn(), - getCwd: vi.fn(), - getInitialCwd: vi.fn(), - clearBuffer: vi.fn(), - acknowledgeDataEvent: vi.fn(), - hasChildProcesses: vi.fn(), - getForegroundProcess: vi.fn(), - serialize: vi.fn(), - revive: vi.fn(), - onData: vi.fn(() => () => {}), - onReplay: vi.fn(() => () => {}), - onExit: vi.fn(() => () => {}), - listProcesses: vi.fn(async () => []), - attach: vi.fn(), - getDefaultShell: vi.fn(), - getProfiles: vi.fn() - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the restart path calls only the provider members this fake defines. - setLocalPtyProvider(provider as unknown as Parameters[0]) - let session = seedSession('pty-old') - const store = { - getWorkspaceSession: vi.fn((_hostId?: string): FakeSession => session), - setWorkspaceSession: vi.fn((next) => { - session = next - }), - flushOrThrow: vi.fn(), - runDurableMutation: vi.fn( - async ( - mutate: () => { value: T; persist?: boolean | 'if-dirty'; rollback?: () => void } - ) => { - const mutation = mutate() - if (mutation.persist !== false) { - if (control.failNextWrite) { - control.failNextWrite = false - mutation.rollback?.() - throw new Error('save failed') - } - durableBindings.push(leafBinding()) - } - return mutation.value - } - ), - getWorkspaceSessionHostIds: vi.fn(() => [ - 'local', - ...Object.keys(state.workspaceSessionsByHostId) - ]), - getFolderWorkspace: vi.fn(() => undefined), - getFolderWorkspaces: vi.fn(() => []), - getProjectGroups: vi.fn(() => []), - getRepos: vi.fn(() => []) - } - const state: { - workspaceSession: FakeSession - workspaceSessionsByHostId: Record - } = { workspaceSession: session, workspaceSessionsByHostId: {} } - const durableBindings: (string | undefined)[] = [] - function leafBinding(hostId?: string): string | undefined { - return store.getWorkspaceSession(hostId).terminalLayoutsByTabId[tabId]?.ptyIdsByLeafId?.[leafId] - } - // Like the store, a missing host partition reads as an empty session, never the local one. - store.getWorkspaceSession.mockImplementation((hostId?: string) => - hostId && hostId !== 'local' - ? (state.workspaceSessionsByHostId[hostId] ?? { - tabsByWorktree: {}, - terminalLayoutsByTabId: {}, - terminalPtyIncarnationsByPaneKey: {} - }) - : state.workspaceSession - ) - const bindingRuntime = { - state, - dirtyProfileStateDomains: new Set(), - runDurableMutation: store.runDurableMutation, - writeTimer: null, - pendingWrite: null, - quitFlushStarted: false, - writeGeneration: 0, - lastDurableWriteGeneration: 0 - } - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: binding writes read only state, write bookkeeping, durable mutation and the session partitions from these fakes. - const bindingArgs = [bindingRuntime, store] as unknown as ConstructorParameters< - typeof PtyBindingPersistenceOperations - > - const bindingOperations = new PtyBindingPersistenceOperations(...bindingArgs) - const storeWithRetirement = Object.assign(store, { - persistPtyBinding: vi.fn(bindingOperations.persistPtyBinding.bind(bindingOperations)), - retirePtyBinding: bindingOperations.retirePtyBinding.bind(bindingOperations) - }) - const runtime = { - setPtyController: vi.fn(), - resolveTerminalPane: vi.fn(() => { - throw new Error('terminal_not_found') - }), - markPtyStopRequested: vi.fn(), - createPreAllocatedTerminalHandle: vi.fn(() => 'term-restart'), - preAllocateHandleForPty: vi.fn(() => 'term-restart'), - registerPreAllocatedHandleForPty: vi.fn(), - beginPtyRegistration: vi.fn(), - cancelPendingPtyRegistration: vi.fn(), - assertPtyRegistrationAllowed: vi.fn(), - registerPty: vi.fn(), - noteTerminalSpawnCommand: vi.fn(), - seedHeadlessTerminal: vi.fn(), - onPtySpawned: vi.fn(), - onPtyExit: vi.fn(), - onPtyData: vi.fn(), - intentionalPtyStops: new TerminalIntentionalStops() - } - return { - providerSpawn, - shutdown, - store: storeWithRetirement, - runtime, - control, - provider, - leafBinding, - durableBindings, - boundAtFreshLaunch, - partitions: state.workspaceSessionsByHostId - } -} - -function restartSpawnArgs(extra: { replacesPtyId?: string } = {}) { - return { - cols: 80, - rows: 24, - cwd, - command: 'codex', - launchAgent: 'codex', - worktreeId, - tabId, - leafId, - env: { ORCA_PANE_KEY: paneKey, ORCA_TAB_ID: tabId, ORCA_WORKTREE_ID: worktreeId }, - ...extra - } -} - describe('pty:spawn replacing a pane owner', () => { const { handlers, mainWindow } = setupPtyIpcSuite() diff --git a/src/main/ipc/pty-persisted-incarnation-repair.test.ts b/src/main/ipc/pty-persisted-incarnation-repair.test.ts index 13766af39c2..2f4910addda 100644 --- a/src/main/ipc/pty-persisted-incarnation-repair.test.ts +++ b/src/main/ipc/pty-persisted-incarnation-repair.test.ts @@ -4,6 +4,7 @@ import { statSyncMock } from './pty-ipc-mock-registry' import { setupPtyIpcSuite } from './pty-ipc-test-harness' import { SessionNotFoundError, TerminalSessionOwnerUnverifiedError } from '../daemon/daemon-errors' import { makePaneKey } from '../../shared/stable-pane-id' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' import { registerPtyHandlers } from './pty/register-handlers' import { clearProviderPtyState } from './pty/provider/state-cleanup' import { setLocalPtyProvider } from './pty/provider/registry' @@ -375,8 +376,17 @@ describe('registerPtyHandlers', () => { expect(providerSpawn.mock.calls[1]?.[0]).toMatchObject({ command: 'codex resume exact-dead-provider-session' }) - expect(store.setWorkspaceSession).toHaveBeenCalledOnce() - expect(store.flushOrThrow).toHaveBeenCalledOnce() + if (ORCA_LAYOUT_OWNER_IS_MAIN) { + // The dead binding stays until the fresh spawn's one bind swaps it. + expect(store.setWorkspaceSession).not.toHaveBeenCalled() + const [swap] = vi.mocked(store.persistPtyBinding).mock.calls[0] ?? [] + expect(typeof swap === 'function' ? swap() : swap).toMatchObject({ + ptyId: 'pty-fresh-recovery' + }) + } else { + expect(store.setWorkspaceSession).toHaveBeenCalledOnce() + expect(store.flushOrThrow).toHaveBeenCalledOnce() + } expect(runtime.onPtyExit).toHaveBeenCalledWith( 'pty-dead-persisted-owner', -1, diff --git a/src/main/ipc/pty/ipc/spawn-absent-binding-swap.ts b/src/main/ipc/pty/ipc/spawn-absent-binding-swap.ts new file mode 100644 index 00000000000..69ab266e18f --- /dev/null +++ b/src/main/ipc/pty/ipc/spawn-absent-binding-swap.ts @@ -0,0 +1,24 @@ +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../../shared/layout-owner-switch' +import type { ReplacedPaneOwner } from '../pane/replaced-pane-owner' +import type { PtyIpcSpawnState } from './spawn-state' + +/** + * Switch point: a gone process's binding stays until this spawn's bind swaps it, one write, so the + * pane is never published unbound. The held reservation keeps any other spawn off the pane meanwhile. + * A pane on an SSH host keeps the early retire: its dead owner lives on that host. + */ +export function keepAbsentBindingForSwap( + ctx: PtyIpcSpawnState +): ((dead: ReplacedPaneOwner) => void) | undefined { + if ( + !ORCA_LAYOUT_OWNER_IS_MAIN || + !ctx.paneSpawnReservation || + ctx.replacedPaneOwner || + ctx.args.connectionId + ) { + return undefined + } + return (dead) => { + ctx.replacedPaneOwner = dead + } +} diff --git a/src/main/ipc/pty/ipc/spawn-begin.ts b/src/main/ipc/pty/ipc/spawn-begin.ts index 355813c1c89..716ef585215 100644 --- a/src/main/ipc/pty/ipc/spawn-begin.ts +++ b/src/main/ipc/pty/ipc/spawn-begin.ts @@ -16,7 +16,7 @@ import { pendingRuntimePaneCreatesByOwnerKey } from '../pane/spawn-reservation' import { resolveStablePaneOwner } from '../pane/stable-owner' -import { excludeReplacedPaneOwner } from '../pane/pane-owner-replacement' +import { excludeReplacedPaneOwner } from '../pane/replaced-pane-owner' import type { PtyIpcSpawnState } from './spawn-state' import type { PtySpawnIpcArgs } from './spawn-types' diff --git a/src/main/ipc/pty/ipc/spawn-commit-persist.ts b/src/main/ipc/pty/ipc/spawn-commit-persist.ts index bca9bb7086d..226a55b11a4 100644 --- a/src/main/ipc/pty/ipc/spawn-commit-persist.ts +++ b/src/main/ipc/pty/ipc/spawn-commit-persist.ts @@ -66,6 +66,8 @@ export async function persistPtyIpcSpawnCommit(ctx: PtyIpcSpawnState): Promise

{ const args = ctx.args @@ -56,7 +57,8 @@ export async function executePtyIpcSpawn(ctx: PtyIpcSpawnState): Promise { owner: stablePaneOwnerCandidate, worktreeId: args.worktreeId, connectionId: args.connectionId, - resolveOwner + resolveOwner, + keepAbsentBinding: keepAbsentBindingForSwap(ctx) }) ctx.result = stablePaneSpawn.result ctx.stablePaneOwner = stablePaneSpawn.owner diff --git a/src/main/ipc/pty/ipc/spawn-preflight.ts b/src/main/ipc/pty/ipc/spawn-preflight.ts index f791190a074..d318d175ec7 100644 --- a/src/main/ipc/pty/ipc/spawn-preflight.ts +++ b/src/main/ipc/pty/ipc/spawn-preflight.ts @@ -9,6 +9,7 @@ import { import { getProvider } from '../provider/registry' import { resolvePtySpawnStartupTarget } from '../spawn-startup-target' import type { PtyIpcSpawnState } from './spawn-state' +import { keepAbsentBindingForSwap } from './spawn-absent-binding-swap' import { getConnectionExecutionHostId } from '../../../../shared/execution-host' export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promise { @@ -47,7 +48,8 @@ export async function preparePtyIpcSpawnPreflight(ctx: PtyIpcSpawnState): Promis worktreeId: ctx.earlyWorktreeId, tabId: ctx.earlyStablePaneOwner.tabId, leafId: ctx.earlyStablePaneOwner.leafId, - ownsPaneSpawnReservation: true + ownsPaneSpawnReservation: true, + keepAbsentBinding: keepAbsentBindingForSwap(ctx) }) : null ctx.spawnTiming.mark('stable_adoption') diff --git a/src/main/ipc/pty/ipc/spawn-state.ts b/src/main/ipc/pty/ipc/spawn-state.ts index 6b24c4d24b4..b6abdf5b980 100644 --- a/src/main/ipc/pty/ipc/spawn-state.ts +++ b/src/main/ipc/pty/ipc/spawn-state.ts @@ -12,7 +12,7 @@ import { createPtySpawnTiming } from '../../pty-spawn-timing' import { noCodexResumeLaunch, type CodexResumeLaunch } from '../host-env/codex-resume' import type { StablePaneOwner } from '../pane/stable-owner' import type { PaneSpawnReservation } from '../pane/spawn-reservation' -import type { ReplacedPaneOwner } from '../pane/pane-owner-replacement' +import type { ReplacedPaneOwner } from '../pane/replaced-pane-owner' import { localProvider } from '../provider/registry' import type { AdoptStablePaneResult, PtySpawnIpcArgs, PtySpawnIpcDeps } from './spawn-types' import type { PaneAgentLaunch } from './spawn-agent-launch-end' diff --git a/src/main/ipc/pty/ipc/spawn-types.ts b/src/main/ipc/pty/ipc/spawn-types.ts index 4166cbd5399..2185d2a00d2 100644 --- a/src/main/ipc/pty/ipc/spawn-types.ts +++ b/src/main/ipc/pty/ipc/spawn-types.ts @@ -17,6 +17,7 @@ import type { PrepareCodexSessionResume } from '../host-env/types' import type { StablePaneOwner } from '../pane/stable-owner' +import type { ReplacedPaneOwner } from '../pane/replaced-pane-owner' import type { ResolvedPtyHost } from '../provider/registry' import type { PtySpawnCodexResumeDeps } from '../spawn-host-env' @@ -76,6 +77,8 @@ export type AdoptStablePaneArgs = { tabId: string leafId: string ownsPaneSpawnReservation?: true + /** See StablePaneSpawnContext; only with `ownsPaneSpawnReservation`. */ + keepAbsentBinding?: (dead: ReplacedPaneOwner) => void } export type AdoptStablePaneResult = { diff --git a/src/main/ipc/pty/pane/absent-pane-owner.ts b/src/main/ipc/pty/pane/absent-pane-owner.ts index afaa2aa2dc2..262dd14d953 100644 --- a/src/main/ipc/pty/pane/absent-pane-owner.ts +++ b/src/main/ipc/pty/pane/absent-pane-owner.ts @@ -5,22 +5,21 @@ import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' import { ptyOwnership } from '../provider/ownership-state' import { clearProviderPtyState } from '../provider/state-cleanup' import { retireReplacedPaneBinding } from './pane-owner-replacement' +import type { ReplacedPaneOwner } from './replaced-pane-owner' -/** - * The owning host answered that the pane's process is gone. The pane outlives it: a fresh spawn - * replaces the process, so only the dead id's binding is cleared. False when another owner took - * the pane meanwhile. - */ -export async function releaseAbsentPaneOwner( - context: { - runtime: OrcaRuntimeService | undefined - store?: Store - worktreeId?: string - connectionId?: string | null - }, - owner: { tabId: string; leafId: string; ptyId: string; incarnationId?: string } -): Promise { - const { runtime, store, worktreeId, connectionId } = context +type AbsentPaneContext = { + runtime: OrcaRuntimeService | undefined + store?: Store + worktreeId?: string + connectionId?: string | null +} +type AbsentPaneOwner = { tabId: string; leafId: string; ptyId: string; incarnationId?: string } + +/** Ends the dead process's in-memory state; its durable binding is the caller's to clear. */ +function releaseAbsentPaneProcess( + runtime: OrcaRuntimeService | undefined, + owner: AbsentPaneOwner +): void { const settleStop = runtime?.intentionalPtyStops?.mark( owner.ptyId, 'replaced', @@ -32,14 +31,14 @@ export async function releaseAbsentPaneOwner( settleStop?.(true) clearProviderPtyState(owner.ptyId) ptyOwnership.delete(owner.ptyId) - if (!worktreeId) { - return true - } - // Without the Store the binding cannot be cleared, so the pane is not free for a fresh spawn. - if (!store) { - return false - } - return retireReplacedPaneBinding(store, { +} + +function absentPaneReplacement( + worktreeId: string, + connectionId: string | null | undefined, + owner: AbsentPaneOwner +): ReplacedPaneOwner & { pane: NonNullable } { + return { ptyId: owner.ptyId, pane: { worktreeId, @@ -47,5 +46,42 @@ export async function releaseAbsentPaneOwner( leafId: owner.leafId, hostId: connectionId ? toSshExecutionHostId(connectionId) : undefined } - }) + } +} + +/** + * The owning host answered that the pane's process is gone. The pane outlives it: a fresh spawn + * replaces the process, so only the dead id's binding is cleared. False when another owner took + * the pane meanwhile. + */ +export async function releaseAbsentPaneOwner( + context: AbsentPaneContext, + owner: AbsentPaneOwner +): Promise { + const { runtime, store, worktreeId, connectionId } = context + releaseAbsentPaneProcess(runtime, owner) + if (!worktreeId) { + return true + } + // Without the Store the binding cannot be cleared, so the pane is not free for a fresh spawn. + if (!store) { + return false + } + return retireReplacedPaneBinding(store, absentPaneReplacement(worktreeId, connectionId, owner)) +} + +/** + * As releaseAbsentPaneOwner, but the dead binding stays for the fresh spawn's bind to swap in one + * write. Null when that swap cannot happen here, so the caller retires as before. + */ +export function keepAbsentPaneBindingForSwap( + context: AbsentPaneContext, + owner: AbsentPaneOwner +): ReplacedPaneOwner | null { + const { runtime, store, worktreeId, connectionId } = context + if (!worktreeId || !store) { + return null + } + releaseAbsentPaneProcess(runtime, owner) + return { ...absentPaneReplacement(worktreeId, connectionId, owner), dropsIncarnation: true } } diff --git a/src/main/ipc/pty/pane/adopt-stable.ts b/src/main/ipc/pty/pane/adopt-stable.ts index fd42b1c0d72..b1fa6b83285 100644 --- a/src/main/ipc/pty/pane/adopt-stable.ts +++ b/src/main/ipc/pty/pane/adopt-stable.ts @@ -75,7 +75,8 @@ export async function adoptStablePane( worktreeId: args.worktreeId, connectionId: args.connectionId, resolveOwner: () => - resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId) + resolveStablePaneOwner(runtime, store, paneKey, args.worktreeId, args.connectionId), + keepAbsentBinding: args.ownsPaneSpawnReservation ? args.keepAbsentBinding : undefined }) if (!ownerKey) { return await adoption diff --git a/src/main/ipc/pty/pane/pane-owner-replacement.test.ts b/src/main/ipc/pty/pane/pane-owner-replacement.test.ts index 714f0c095ec..8f5de42bbdd 100644 --- a/src/main/ipc/pty/pane/pane-owner-replacement.test.ts +++ b/src/main/ipc/pty/pane/pane-owner-replacement.test.ts @@ -1,7 +1,8 @@ import { expect, it, vi } from 'vitest' import { fixture } from '../../../persistence/loading-store/profile-state-delayed-authority-fixture' import { TEST_LEAF_1, TEST_LEAF_2 } from '../../../persistence-session-fixtures' -import { swapReplacedPaneBinding, type ReplacedPaneOwner } from './pane-owner-replacement' +import { swapReplacedPaneBinding } from './pane-owner-replacement' +import type { ReplacedPaneOwner } from './replaced-pane-owner' vi.mock('../../../telemetry/client', () => ({ track: vi.fn() })) vi.mock('../../../telemetry/cohort-classifier', () => ({ diff --git a/src/main/ipc/pty/pane/pane-owner-replacement.ts b/src/main/ipc/pty/pane/pane-owner-replacement.ts index b3aaef0ea5b..45b4079a722 100644 --- a/src/main/ipc/pty/pane/pane-owner-replacement.ts +++ b/src/main/ipc/pty/pane/pane-owner-replacement.ts @@ -3,13 +3,8 @@ import { parsePaneKey } from '../../../../shared/stable-pane-id' import type { Store } from '../../../persistence' import type { PersistPtyBindingArgs } from '../../../persistence/loading-store/pty-binding-persistence' import type { OrcaRuntimeService } from '../../../runtime/orca-runtime' -import { resolveStablePaneOwner, type StablePaneOwner } from './stable-owner' - -/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */ -export type ReplacedPaneOwner = { - ptyId: string - pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null -} +import type { ReplacedPaneOwner } from './replaced-pane-owner' +import { resolveStablePaneOwner } from './stable-owner' /** Caller holds the pane's spawn reservation, so no other spawn can claim the pane meanwhile. */ export async function stopReplacedPaneOwner( @@ -51,14 +46,6 @@ export async function stopReplacedPaneOwner( } } -/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */ -export function excludeReplacedPaneOwner( - owner: StablePaneOwner | null, - replaced: ReplacedPaneOwner | null -): StablePaneOwner | null { - return owner && owner.ptyId === replaced?.ptyId ? null : owner -} - /** * The replacement's bind is the swap: decided inside the bind's durable mutation, it takes a leaf * still naming the stopped id, or one the renderer already cleared; any other owner wins. @@ -74,7 +61,10 @@ export function swapReplacedPaneBinding( binding.leafId ] // Unbound is ok: the renderer's pre-connect clear can withdraw it (split tab). - return bound === undefined || bound === replaced.ptyId ? binding : null + if (bound !== undefined && bound !== replaced.ptyId) { + return null + } + return replaced.dropsIncarnation ? { ...binding, dropsIncarnation: true } : binding } } diff --git a/src/main/ipc/pty/pane/replaced-pane-owner.ts b/src/main/ipc/pty/pane/replaced-pane-owner.ts new file mode 100644 index 00000000000..59a620f1c4f --- /dev/null +++ b/src/main/ipc/pty/pane/replaced-pane-owner.ts @@ -0,0 +1,15 @@ +/** The owner a replacing spawn stopped. Its binding stays until the replacement's bind swaps it. */ +export type ReplacedPaneOwner = { + ptyId: string + pane: { worktreeId: string; tabId: string; leafId: string; hostId: string | undefined } | null + /** A gone process's: its swap clears the incarnation as the retire it stands in for would. */ + dropsIncarnation?: true +} + +/** The stopped id is dead whatever its incarnation, so a binding naming it must not be reattached. */ +export function excludeReplacedPaneOwner( + owner: Owner | null, + replaced: ReplacedPaneOwner | null +): Owner | null { + return owner && owner.ptyId === replaced?.ptyId ? null : owner +} diff --git a/src/main/ipc/pty/pane/stable-owner.ts b/src/main/ipc/pty/pane/stable-owner.ts index f190e5ab2cd..ff8997181a3 100644 --- a/src/main/ipc/pty/pane/stable-owner.ts +++ b/src/main/ipc/pty/pane/stable-owner.ts @@ -18,7 +18,8 @@ import { TerminalSessionOwnerUnverifiedError } from '../../../daemon/daemon-errors' import { ptyIncarnationById } from '../provider/ownership-state' -import { releaseAbsentPaneOwner } from './absent-pane-owner' +import { keepAbsentPaneBindingForSwap, releaseAbsentPaneOwner } from './absent-pane-owner' +import { excludeReplacedPaneOwner, type ReplacedPaneOwner } from './replaced-pane-owner' import { spawnCommitBindingOrigin } from '../../../persistence/loading-store/pty-binding-span' export type StablePaneOwner = { @@ -179,6 +180,8 @@ export type StablePaneSpawnContext = { connectionId?: string | null resolveOwner?: () => StablePaneOwner | null onFreshSpawn?: (result: PtySpawnResult) => void + /** Only a spawn holding the pane's reservation: it binds next, so its bind swaps the dead id. */ + keepAbsentBinding?: (dead: ReplacedPaneOwner) => void } export function stablePanePersistenceFence( @@ -263,10 +266,13 @@ export async function attachStablePaneOwner( ) { throw new Error('terminal_pane_owner_changed') } - if (!(await releaseAbsentPaneOwner(args, owner))) { + const kept = args.keepAbsentBinding ? keepAbsentPaneBindingForSwap(args, owner) : null + if (kept) { + args.keepAbsentBinding?.(kept) + } else if (!(await releaseAbsentPaneOwner(args, owner))) { throw new Error('terminal_pane_owner_changed') } - if (args.resolveOwner?.()) { + if (excludeReplacedPaneOwner(args.resolveOwner?.() ?? null, kept)) { throw new Error('terminal_pane_owner_changed') } return null diff --git a/src/main/ipc/pty/pane/stable-pane-absence-respawn.test.ts b/src/main/ipc/pty/pane/stable-pane-absence-respawn.test.ts index 3c212a6ea89..d6b8ecc1142 100644 --- a/src/main/ipc/pty/pane/stable-pane-absence-respawn.test.ts +++ b/src/main/ipc/pty/pane/stable-pane-absence-respawn.test.ts @@ -208,6 +208,39 @@ describe('stable pane adoption after the owning host reports the PTY absent', () expect(session.terminalLayoutsByTabId['tab-1']?.ptyIdsByLeafId?.[LEAF]).toBeUndefined() }) + // A spawn holding the pane's reservation binds next: its bind swaps the dead id in one write. + it('keeps the absent binding for the swap when the spawn takes it', async () => { + const { store, read } = sessionStore([LEAF, SIBLING_LEAF]) + const before = JSON.stringify(read()) + const spawn = vi + .fn() + .mockRejectedValueOnce(new SessionNotFoundError(OWNER.ptyId)) + .mockResolvedValueOnce({ id: 'ssh:conn-1@@pty-3', isReattach: false }) + const keepAbsentBinding = vi.fn() + + const result = await spawnForStablePane({ + runtime: undefined, + store, + worktreeId: WORKTREE, + provider: { spawn } as unknown as IPtyProvider, + spawnOptions: { cols: 80, rows: 24 }, + owner: OWNER, + connectionId: 'conn-1', + // The persisted owner is still the dead id until the bind swaps it. + resolveOwner: () => OWNER, + keepAbsentBinding + }) + + expect(result.owner).toBeNull() + expect(spawn).toHaveBeenCalledTimes(2) + expect(keepAbsentBinding).toHaveBeenCalledExactlyOnceWith({ + ptyId: OWNER.ptyId, + pane: { worktreeId: WORKTREE, tabId: 'tab-1', leafId: LEAF, hostId: 'ssh:conn-1' }, + dropsIncarnation: true + }) + expect(JSON.stringify(read())).toBe(before) + }) + it('leaves persistence untouched when the failure is not positive absence', async () => { const { store, read } = sessionStore([LEAF, SIBLING_LEAF]) const before = JSON.stringify(read()) diff --git a/src/main/persistence-invalid-workspace-layout-purge.test.ts b/src/main/persistence-invalid-workspace-layout-purge.test.ts index 84403e35113..f61102d1973 100644 --- a/src/main/persistence-invalid-workspace-layout-purge.test.ts +++ b/src/main/persistence-invalid-workspace-layout-purge.test.ts @@ -14,7 +14,7 @@ import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultWorkspaceSession } from '../sh import { folderWorkspaceKey } from '../shared/workspace-scope' import type { WorkspaceSessionState } from '../shared/workspace-session-state-types' import type { DetectedWorktreeListResult } from '../shared/worktree/types' -import type { WorkspaceLayoutEvent } from '../shared/workspace-layout/workspace-layout-stream-frames' +import { collectLayoutEvents } from './runtime/workspace-layout-stream-events.test-fixture' import { bumpLocalWorktreeScanGeneration, getLocalWorktreeCatalogVersion @@ -165,8 +165,10 @@ describe('main drops the layout of workspaces the catalog no longer validates', homeHostId: () => 'local', liveTitle: () => null }) - const events: WorkspaceLayoutEvent[] = [] - const { snapshot } = stream.subscribe((event) => events.push(event)) + const collector = collectLayoutEvents() + const { events } = collector + const { snapshot } = stream.subscribe(collector.listener) + collector.seed(snapshot) expect(snapshot.map((entry) => entry.key)).toContain(DELETED) dropWorkspacesTheListingInvalidates(store, listing([KEPT])) diff --git a/src/main/persistence/loading-store/primary-state-write-runtime.ts b/src/main/persistence/loading-store/primary-state-write-runtime.ts index ee9a224e2eb..3ab9ed730f4 100644 --- a/src/main/persistence/loading-store/primary-state-write-runtime.ts +++ b/src/main/persistence/loading-store/primary-state-write-runtime.ts @@ -24,6 +24,7 @@ export type PrimaryStateWriteOperationsRuntime = Pick< | 'writeTimer' | 'writesFrozen' | 'workspaceSessionWriteListeners' + | 'workspaceSessionOperationListeners' > export function markPrimaryStateWriteDurable( diff --git a/src/main/persistence/loading-store/primary-state-writes.ts b/src/main/persistence/loading-store/primary-state-writes.ts index 3c9749b094c..9a67353add7 100644 --- a/src/main/persistence/loading-store/primary-state-writes.ts +++ b/src/main/persistence/loading-store/primary-state-writes.ts @@ -13,7 +13,8 @@ import { writeToDiskSync } from './primary-state-write-sync' import { writeProfileStateInWorker } from './primary-state-write-worker' import type { DurableProfileStateMutation } from './store-runtime-state' import { profileStateWriterFailureOutcome } from '../profile-state/profile-state-writer-errors' -import { notifyWorkspaceSessionWritten } from './workspace-session-write-listeners' +import { notifyWorkspaceSessionOperationEnded } from './workspace-session-write-listeners' +import type { ExecutionHostId } from '../../../shared/execution-host' const primaryStateWriteOperationsContext = Symbol('PrimaryStateWriteOperations') export class PrimaryStateWriteOperations { @@ -63,7 +64,11 @@ export class PrimaryStateWriteOperations { } let mutation: DurableProfileStateMutation try { - mutation = this.runAdmittedMutationCallback('mutate', mutate) + mutation = this.runAdmittedMutationCallback( + 'mutate', + mutate, + (result) => result.writtenHostIds + ) } catch (error) { await stopAfterFailedPrimaryStateMutation(runtime, error) throw error @@ -103,16 +108,26 @@ export class PrimaryStateWriteOperations { }) } - private runAdmittedMutationCallback(phase: 'mutate' | 'rollback', callback: () => T): T { + private runAdmittedMutationCallback( + phase: 'mutate' | 'rollback', + callback: () => T, + /** The partitions the callback declared it wrote; a rollback's are any. */ + writtenHostIdsOf: (result: T) => readonly ExecutionHostId[] | undefined = () => undefined + ): T { const { runtime } = this[primaryStateWriteOperationsContext] // Finalization drains admitted mutations; the disk wait must not admit new snapshots. runtime.durableMutationPhase = phase + let result: T | undefined try { - return callback() + result = callback() + return result } finally { runtime.durableMutationPhase = null // Durable writers may edit sessions without scheduling a save; a rollback republishes. - notifyWorkspaceSessionWritten(runtime) + notifyWorkspaceSessionOperationEnded( + runtime, + result === undefined ? null : (writtenHostIdsOf(result) ?? null) + ) } } diff --git a/src/main/persistence/loading-store/pty-binding-generations.ts b/src/main/persistence/loading-store/pty-binding-generations.ts new file mode 100644 index 00000000000..7798eb4d3e1 --- /dev/null +++ b/src/main/persistence/loading-store/pty-binding-generations.ts @@ -0,0 +1,34 @@ +// Each pane's binding generation (`LayoutLeaf.ptyGeneration`): it rises on each pty bind +// (`persistPtyBinding` past its fast lane), retire and rollback of that pane in this process, a +// same-id respawn included. Layout commands that move or drop a pane do not bump it. In memory +// only; layout-owner builds only. + +import type { ExecutionHostId } from '../../../shared/execution-host' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' +import type { StoreRuntimeState } from './store-runtime-state' + +type GenerationRuntime = Pick + +const generationKey = (hostId: ExecutionHostId, leafId: string) => `${hostId}\0${leafId}` + +export function bumpPtyBindingGeneration( + runtime: GenerationRuntime, + hostId: ExecutionHostId, + leafId: string +): void { + // Switch point: a window-owned build publishes no generation, so it counts none. + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + return + } + const key = generationKey(hostId, leafId) + runtime.ptyBindingGenerations.set(key, (runtime.ptyBindingGenerations.get(key) ?? 0) + 1) +} + +/** Undefined for a pane whose binding this process never wrote. */ +export function ptyBindingGenerationOf( + runtime: GenerationRuntime, + hostId: ExecutionHostId, + leafId: string +): number | undefined { + return runtime.ptyBindingGenerations.get(generationKey(hostId, leafId)) +} diff --git a/src/main/persistence/loading-store/pty-binding-persistence.ts b/src/main/persistence/loading-store/pty-binding-persistence.ts index ea835138ae0..46e0a003ce4 100644 --- a/src/main/persistence/loading-store/pty-binding-persistence.ts +++ b/src/main/persistence/loading-store/pty-binding-persistence.ts @@ -10,18 +10,22 @@ import { clearReplacedPaneBinding } from './replaced-pane-binding' import type { PtyBindingSourceExpectation } from './store' import type { StoreRuntimeState } from './store-runtime-state' -import type { SessionHostPartitionOperations } from './session-host-partitions' +import { resolveHostId, type SessionHostPartitionOperations } from './session-host-partitions' import { withdrawUnstartedPane, type UnstartedPaneKept } from '../terminal-topology/unstarted-pane-withdraw' -import { resolveHostId } from './session-host-partitions' import { evaluatePtyBindingFastLane } from './pty-binding-fast-lane' import { ptyBindingIsRefused } from './pty-binding-refusals' -import { startPtyBindingSpan, type PtyBindingOrigin, type PtyBindingSpan } from './pty-binding-span' import { applyPtyBinding } from './pty-binding-session-update' +import { + reportPtyBindingOwnerConflict, + reportPtyBindingPlacement, + startPtyBindingSpan, + type PtyBindingOrigin, + type PtyBindingSpan +} from './pty-binding-span' import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' -import { terminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' import type { TerminalLeafMoveRequest, TerminalLeafMoveResult @@ -41,7 +45,7 @@ import type { CommandOf, LayoutRefusalCode } from '../../../shared/workspace-layout/workspace-layout-command-types' -import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants' +import { bumpPtyBindingGeneration } from './pty-binding-generations' type PtyBindingPersistenceOperationsRuntime = Pick< StoreRuntimeState, @@ -53,6 +57,7 @@ type PtyBindingPersistenceOperationsRuntime = Pick< | 'state' | 'writeGeneration' | 'writeTimer' + | 'ptyBindingGenerations' > export type PersistPtyBindingArgs = { @@ -77,6 +82,8 @@ export type PersistPtyBindingArgs = { origin?: PtyBindingOrigin /** Where a new leaf goes. Report-only for now: the span records whether it names today's tab. */ placement?: TerminalPanePlacement + /** Replaces a gone process: with no `incarnationId`, the pane's recorded one is cleared. */ + dropsIncarnation?: true } const ptyBindingPersistenceOperationsContext = Symbol('PtyBindingPersistenceOperations') @@ -132,9 +139,11 @@ export class PtyBindingPersistenceOperations { const retired = clearReplacedPaneBinding(session, { ...binding, parentTabId: binding.tabId }) // Host retirement must not run renderer snapshot repair, which would put the old binding back. publish(retired) + bumpPtyBindingGeneration(runtime, resolved, binding.leafId) const staged = cloneWorkspaceSessionState(retired) return { value: true, + writtenHostIds: [resolved], rollback: () => { publish( rollbackWorkspaceSessionAfterFailedAsyncWrite( @@ -143,6 +152,7 @@ export class PtyBindingPersistenceOperations { sessions.getWorkspaceSession(resolved) ) ) + bumpPtyBindingGeneration(runtime, resolved, binding.leafId) } } }) @@ -180,20 +190,7 @@ export class PtyBindingPersistenceOperations { outcome = 'refused' return { value: false, persist: false } } - // Report-only: a malformed session must not fail the binding it is reporting on. - try { - span.setPlacement( - terminalPanePlacementAgreement( - args.placement, - session, - bindingWorktreeId, - args.tabId, - args.leafId - ) - ) - } catch { - span.setPlacement('check_threw') - } + reportPtyBindingPlacement(span, args, session, bindingWorktreeId) const verdict = evaluatePtyBindingFastLane( args, session, @@ -205,18 +202,11 @@ export class PtyBindingPersistenceOperations { outcome = 'fast_lane' return { value: true, persist: false } } - // Report-only: the binding is written even when it breaks an invariant, or the check throws. // After the fast lane, so a no-op rebind skips the scan. - try { - const conflict = findTerminalBindingConflict(args, resolvedHostId, partitions) - if (conflict) { - span.setOwnerConflict(conflict.reason) - } - } catch { - span.setOwnerConflict('check_threw') - } + reportPtyBindingOwnerConflict(span, args, resolvedHostId, partitions) return { value: true, + writtenHostIds: [resolvedHostId], rollback: writePtyBinding(this, args, session, resolvedHostId, bindingWorktreeId, paneKey) } }) @@ -295,6 +285,7 @@ function writePtyBinding( } } applyPtyBinding(args, session, bindingWorktreeId, paneKey) + bumpPtyBindingGeneration(runtime, resolvedHostId, args.leafId) runtime.dirtyProfileStateDomains?.add( resolvedHostId === LOCAL_EXECUTION_HOST_ID ? 'workspaceSession' : 'workspaceSessionsByHostId' ) @@ -327,6 +318,7 @@ function writePtyBinding( ) if (rolledBack !== current) { restore(rolledBack) + bumpPtyBindingGeneration(runtime, resolvedHostId, args.leafId) } } } catch (error) { diff --git a/src/main/persistence/loading-store/pty-binding-session-update.ts b/src/main/persistence/loading-store/pty-binding-session-update.ts index 5f53c1e6e21..d8b44c41b26 100644 --- a/src/main/persistence/loading-store/pty-binding-session-update.ts +++ b/src/main/persistence/loading-store/pty-binding-session-update.ts @@ -38,17 +38,18 @@ export function applyPtyBinding( [repoId]: currentRevision + 1 } } - if (args.incarnationId) { - session.terminalPtyIncarnationsByPaneKey = { - ...session.terminalPtyIncarnationsByPaneKey, - [paneKey]: args.incarnationId - } - if (session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { - session.terminalSurfaceTombstonesByPaneKey = { - ...session.terminalSurfaceTombstonesByPaneKey - } - delete session.terminalSurfaceTombstonesByPaneKey[paneKey] + const { [paneKey]: recordedIncarnation, ...otherIncarnations } = + session.terminalPtyIncarnationsByPaneKey ?? {} + if (args.incarnationId || (args.dropsIncarnation && recordedIncarnation)) { + session.terminalPtyIncarnationsByPaneKey = args.incarnationId + ? { ...session.terminalPtyIncarnationsByPaneKey, [paneKey]: args.incarnationId } + : otherIncarnations + } + if (args.incarnationId && session.terminalSurfaceTombstonesByPaneKey?.[paneKey]) { + session.terminalSurfaceTombstonesByPaneKey = { + ...session.terminalSurfaceTombstonesByPaneKey } + delete session.terminalSurfaceTombstonesByPaneKey[paneKey] } const tabs = session.tabsByWorktree?.[bindingWorktreeId] const tab = tabs?.find((t) => t.id === args.tabId) diff --git a/src/main/persistence/loading-store/pty-binding-span.ts b/src/main/persistence/loading-store/pty-binding-span.ts index db2656ae7bc..d5df332b50e 100644 --- a/src/main/persistence/loading-store/pty-binding-span.ts +++ b/src/main/persistence/loading-store/pty-binding-span.ts @@ -1,7 +1,16 @@ import { startSpan } from '../../observability/tracer' import type { PtyBindingFastLaneMiss } from './pty-binding-fast-lane' -import type { TerminalPanePlacementAgreement } from '../terminal-topology/terminal-pane-placement-agreement' -import type { TerminalOwnerConflictReason } from '../../../shared/workspace-layout/terminal-owner-invariants' +import { + terminalPanePlacementAgreement, + type TerminalPanePlacementAgreement +} from '../terminal-topology/terminal-pane-placement-agreement' +import { + findTerminalBindingConflict, + type TerminalOwnerConflictReason +} from '../../../shared/workspace-layout/terminal-owner-invariants' +import type { ExecutionHostId } from '../../../shared/execution-host' +import type { TerminalPanePlacement } from '../../../shared/terminal-pane-placement' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' export type PtyBindingSpanOutcome = 'fast_lane' | 'flushed' | 'refused' | 'threw' @@ -103,3 +112,36 @@ export function startPtyBindingSpan(entry: { export function _resetPtyBindingSpanSamplingForTests(): void { fastLaneWindow = null } + +/** Report-only: a malformed session must not fail the binding it is reporting on. */ +export function reportPtyBindingPlacement( + span: PtyBindingSpan, + args: { placement?: TerminalPanePlacement; tabId: string; leafId: string }, + session: WorkspaceSessionState, + worktreeId: string +): void { + try { + span.setPlacement( + terminalPanePlacementAgreement(args.placement, session, worktreeId, args.tabId, args.leafId) + ) + } catch { + span.setPlacement('check_threw') + } +} + +/** Report-only: the binding is written even when it breaks an invariant, or the check throws. */ +export function reportPtyBindingOwnerConflict( + span: PtyBindingSpan, + binding: Parameters[0], + hostId: ExecutionHostId, + partitions: Parameters[2] +): void { + try { + const conflict = findTerminalBindingConflict(binding, hostId, partitions) + if (conflict) { + span.setOwnerConflict(conflict.reason) + } + } catch { + span.setOwnerConflict('check_threw') + } +} diff --git a/src/main/persistence/loading-store/store-runtime-state.ts b/src/main/persistence/loading-store/store-runtime-state.ts index 6cddd3d6564..d295944202b 100644 --- a/src/main/persistence/loading-store/store-runtime-state.ts +++ b/src/main/persistence/loading-store/store-runtime-state.ts @@ -1,7 +1,11 @@ import { removeStaleDurableWriteTempFiles } from '../../durable-file-write' import type { GlobalSettings } from '../../../shared/global-settings-types' import type { PersistedState } from '../../../shared/persisted-state-types' -import type { WorkspaceSessionWriteListener } from './workspace-session-write-listeners' +import type { + WorkspaceSessionOperationListener, + WorkspaceSessionWriteListener +} from './workspace-session-write-listeners' +import type { ExecutionHostId } from '../../../shared/execution-host' import type { StateWriteListener } from './write-scheduling' import type { ActiveViewPreference } from '../../active-view-preference' import { @@ -32,6 +36,8 @@ export type DurableProfileStateMutation = { /** 'if-dirty' fences an existing change without rewriting an already durable generation. */ persist?: boolean | 'if-dirty' rollback?: () => void + /** The only session partitions the mutation wrote, so readers re-read just those; absent: any. */ + writtenHostIds?: readonly ExecutionHostId[] } export type StoreRuntimeOptions = { @@ -104,6 +110,10 @@ export class StoreRuntimeState { uiChangeListeners = new Set<(ui: PersistedState['ui']) => void>() /** Observers of persisted workspace-session writes; see notifyWorkspaceSessionWritten. */ readonly workspaceSessionWriteListeners = new Set() + /** Observers of each durable mutation's end; see notifyWorkspaceSessionOperationEnded. */ + readonly workspaceSessionOperationListeners = new Set() + /** `${hostId}\0${leafId}` → its pane's binding generation, in memory only (layout-owner builds). */ + readonly ptyBindingGenerations = new Map() /** Observers of every write; see WriteSchedulingOperations.onStateWritten. */ readonly stateWriteListeners = new Set() projectHostOperations: ProjectHostPersistenceOperations | null = null diff --git a/src/main/persistence/loading-store/workspace-session-write-listeners.spec.ts b/src/main/persistence/loading-store/workspace-session-write-listeners.spec.ts index 43e7fbeb9ef..6cf4f52624d 100644 --- a/src/main/persistence/loading-store/workspace-session-write-listeners.spec.ts +++ b/src/main/persistence/loading-store/workspace-session-write-listeners.spec.ts @@ -3,8 +3,8 @@ import { describe, expect, it, vi } from 'vitest' import type { ExecutionHostId } from '../../../shared/execution-host' import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' import { TEST_LEAF_1, TEST_LEAF_2 } from '../../persistence-session-fixtures' -import type { WorkspaceLayoutEvent } from '../../../shared/workspace-layout/workspace-layout-stream-frames' import { WorkspaceLayoutStream } from '../../runtime/workspace-layout-stream' +import { collectLayoutEvents } from '../../runtime/workspace-layout-stream-events.test-fixture' import { ProfileStateWriterError } from '../profile-state/profile-state-writer-errors' import { fixture } from './profile-state-delayed-authority-fixture' import type { Store } from './store' @@ -66,20 +66,22 @@ export function registerWorkspaceSessionWriteListenersTests(): void { vi.spyOn(console, 'error').mockImplementation(() => {}) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) const { store, authority, readState } = await fixture() - const pushes: WorkspaceLayoutEvent[] = [] + const collector = collectLayoutEvents() + const pushes = collector.events if (mode !== 'none') { const stream = new WorkspaceLayoutStream({ store: () => store, homeHostId: (key) => homeOf(store, key), liveTitle: () => null }) - stream.subscribe( + const { snapshot } = stream.subscribe( mode === 'publisher' - ? (event) => pushes.push(event) + ? collector.listener : () => { throw new Error('listener failed') } ) + collector.seed(snapshot) } const snapshots: unknown[] = [] const step = async (run: () => unknown) => { diff --git a/src/main/persistence/loading-store/workspace-session-write-listeners.ts b/src/main/persistence/loading-store/workspace-session-write-listeners.ts index 9f8d4880012..676d7addbd8 100644 --- a/src/main/persistence/loading-store/workspace-session-write-listeners.ts +++ b/src/main/persistence/loading-store/workspace-session-write-listeners.ts @@ -1,9 +1,13 @@ import type { ExecutionHostId } from '../../../shared/execution-host' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import type { StoreRuntimeState } from './store-runtime-state' /** Gets the one partition a write changed, or null when any partition may have changed. */ export type WorkspaceSessionWriteListener = (hostId: ExecutionHostId | null) => void +/** Gets the partitions one durable mutation may have written, or null when any may have changed. */ +export type WorkspaceSessionOperationListener = (hostIds: readonly ExecutionHostId[] | null) => void + const WORKSPACE_SESSION_DOMAINS: readonly string[] = [ 'workspaceSession', 'workspaceSessionsByHostId' @@ -25,3 +29,34 @@ export function notifyWorkspaceSessionWritten( listener(hostId ?? null) } } + +/** + * A durable mutation's end. Switch point: a layout-owner build names the partitions the mutation + * declared, then tells operation listeners its writes are all in; others notify as any write. + */ +export function notifyWorkspaceSessionOperationEnded( + runtime: Pick< + StoreRuntimeState, + 'workspaceSessionWriteListeners' | 'workspaceSessionOperationListeners' + >, + hostIds: readonly ExecutionHostId[] | null +): void { + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + notifyWorkspaceSessionWritten(runtime) + return + } + if (hostIds === null) { + notifyWorkspaceSessionWritten(runtime) + } + for (const hostId of hostIds ?? []) { + notifyWorkspaceSessionWritten(runtime, undefined, hostId) + } + for (const listener of runtime.workspaceSessionOperationListeners) { + // The operation already committed: a reader's throw must not fail it or the next reader. + try { + listener(hostIds) + } catch (error) { + console.warn('[persistence] workspace-session operation listener failed:', error) + } + } +} diff --git a/src/main/persistence/loading-store/write-scheduling.ts b/src/main/persistence/loading-store/write-scheduling.ts index 636ad1314f1..07984029e07 100644 --- a/src/main/persistence/loading-store/write-scheduling.ts +++ b/src/main/persistence/loading-store/write-scheduling.ts @@ -4,8 +4,10 @@ import { enqueueWrite } from './primary-state-writes' import type { ExecutionHostId } from '../../../shared/execution-host' import { notifyWorkspaceSessionWritten, + type WorkspaceSessionOperationListener, type WorkspaceSessionWriteListener } from './workspace-session-write-listeners' +import { ptyBindingGenerationOf } from './pty-binding-generations' /** Gets the domains a write changed; undefined when it may have changed any of them. */ export type StateWriteListener = (dirtyDomains: readonly string[] | undefined) => void @@ -26,7 +28,9 @@ type WriteSchedulingOperationsRuntime = Pick< | 'writeTimer' | 'writesFrozen' | 'workspaceSessionWriteListeners' + | 'workspaceSessionOperationListeners' | 'stateWriteListeners' + | 'ptyBindingGenerations' > const writeSchedulingOperationsContext = Symbol('WriteSchedulingOperations') @@ -56,6 +60,23 @@ export class WriteSchedulingOperations { } } + /** + * Layout-owner builds: called once per durable mutation, after its partitions' write notices, + * with those partitions; listeners must not throw or block. + */ + onWorkspaceSessionOperationEnded(listener: WorkspaceSessionOperationListener): () => void { + const { runtime } = this[writeSchedulingOperationsContext] + runtime.workspaceSessionOperationListeners.add(listener) + return () => { + runtime.workspaceSessionOperationListeners.delete(listener) + } + } + + /** A pane's binding generation in this process, for readers of its writes; see pty-binding-generations. */ + getPtyBindingGeneration(hostId: ExecutionHostId, leafId: string): number | undefined { + return ptyBindingGenerationOf(this[writeSchedulingOperationsContext].runtime, hostId, leafId) + } + /** Called after each in-memory write that schedules a save; listeners must not throw or block. */ onStateWritten(listener: StateWriteListener): () => void { const { runtime } = this[writeSchedulingOperationsContext] diff --git a/src/main/persistence/terminal-topology/layout-command-persistence.ts b/src/main/persistence/terminal-topology/layout-command-persistence.ts index 8149252af56..710c2a701ee 100644 --- a/src/main/persistence/terminal-topology/layout-command-persistence.ts +++ b/src/main/persistence/terminal-topology/layout-command-persistence.ts @@ -8,7 +8,11 @@ import { resolveHostId, type SessionHostPartitionOperations } from '../loading-store/session-host-partitions' -import type { StoreRuntimeState } from '../loading-store/store-runtime-state' +import type { + DurableProfileStateMutation, + StoreRuntimeState +} from '../loading-store/store-runtime-state' +import type { ExecutionHostId } from '../../../shared/execution-host' import { applyLayout, keepFrozen, topologyCommitContext } from './terminal-topology-commit' import { applyLayoutRenames } from './terminal-layout-renames-commit' import { moveTabAcrossPartitions } from './cross-partition-tab-move' @@ -19,6 +23,14 @@ import { type DuplicatePtyPaneTransfer } from './duplicate-pty-pane-repair' +/** A commit that writes only these partitions says so, so readers re-read just those. */ +function writingOnly( + hostIds: readonly ExecutionHostId[], + mutate: () => DurableProfileStateMutation +): () => DurableProfileStateMutation { + return () => ({ ...mutate(), writtenHostIds: hostIds }) +} + type LayoutCommandRuntime = Pick< StoreRuntimeState, 'runDurableMutation' | 'state' | 'dirtyProfileStateDomains' | 'writesFrozen' @@ -46,7 +58,7 @@ export class LayoutCommandPersistence { const context = this[layoutCommandPersistenceContext] const resolved = hostIds.length > 0 ? hostIds.map(resolveHostId) : [resolveHostId()] return context.runtime.runDurableMutation( - applyLayout(resolved, command, topologyCommitContext(context)) + writingOnly(resolved, applyLayout(resolved, command, topologyCommitContext(context))) ) } @@ -55,10 +67,14 @@ export class LayoutCommandPersistence { batches: readonly { hostId: string; renames: readonly CommandOf<'renameTab'>[] }[] ): Promise<{ rename: CommandOf<'renameTab'>; code: LayoutRefusalCode }[]> { const context = this[layoutCommandPersistenceContext] + const resolved = batches.map(({ hostId, renames }) => ({ + hostId: resolveHostId(hostId), + renames + })) return context.runtime.runDurableMutation( - applyLayoutRenames( - batches.map(({ hostId, renames }) => ({ hostId: resolveHostId(hostId), renames })), - topologyCommitContext(context) + writingOnly( + resolved.map(({ hostId }) => hostId), + applyLayoutRenames(resolved, topologyCommitContext(context)) ) ) } diff --git a/src/main/persistence/terminal-topology/terminal-layout-rename-tabs.test.ts b/src/main/persistence/terminal-topology/terminal-layout-rename-tabs.test.ts index 55724911fe3..daf4052e5ac 100644 --- a/src/main/persistence/terminal-topology/terminal-layout-rename-tabs.test.ts +++ b/src/main/persistence/terminal-topology/terminal-layout-rename-tabs.test.ts @@ -2,6 +2,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { getDefaultWorkspaceSession } from '../../../shared/constants' import type { CommandOf } from '../../../shared/workspace-layout/workspace-layout-command-types' import { WorkspaceLayoutStream } from '../../runtime/workspace-layout-stream' @@ -84,8 +85,8 @@ describe('Store.applyTabRenames (layout.apply renameTabs)', () => { expect(refused).toEqual([ { rename: rename(FIXTURE_GIT_WORKTREE_ID, 'missing', 'Gone'), code: 'tab_not_found' } ]) - // Both partitions in one durable mutation: one write notice. - expect(written).toHaveLength(1) + // Both partitions in one durable mutation: one notice for any partition, or one per partition. + expect(written).toEqual(ORCA_LAYOUT_OWNER_IS_MAIN ? ['local', SSH] : [null]) const reopened = await reopenTopologyStore(store, directory) expect(generatedTitles(reopened)).toEqual([ ['tab-0', 'Fix the login page'], @@ -103,7 +104,13 @@ describe('Store.applyTabRenames (layout.apply renameTabs)', () => { liveTitle: () => null }) const events: string[] = [] - stream.subscribe((event) => events.push(event.type === 'workspace' ? event.key : event.type)) + stream.subscribe((event) => + events.push( + ...(event.type === 'records' + ? event.workspaces.map((change) => change.key) + : [event.type === 'workspace' ? event.key : event.type]) + ) + ) await store.applyTabRenames([ { diff --git a/src/main/runtime/folder-workspace-mixed-host-routing.test.ts b/src/main/runtime/folder-workspace-mixed-host-routing.test.ts index 4ffe153c764..132892f91ca 100644 --- a/src/main/runtime/folder-workspace-mixed-host-routing.test.ts +++ b/src/main/runtime/folder-workspace-mixed-host-routing.test.ts @@ -17,6 +17,7 @@ import { } from '../../shared/workspace-layout/workspace-layout-session.test-fixture' import { OrcaRuntimeService } from './orca-runtime' import { WorkspaceLayoutStream } from './workspace-layout-stream' +import { collectLayoutEvents } from './workspace-layout-stream-events.test-fixture' const mocks = vi.hoisted(() => ({ layoutOwnerIsMain: true })) vi.mock('../../shared/layout-owner-switch', () => ({ @@ -134,8 +135,10 @@ describe('a workspace the router throws on', () => { vi.spyOn(console, 'warn').mockImplementation(() => {}) const { stream, route, write } = streamThrowingOn(FOLDER_KEY) route.throws = false - const events: string[] = [] - const first = stream.subscribe((event) => events.push(`${event.type} ${event.key}`)) + const collector = collectLayoutEvents() + const first = stream.subscribe(collector.listener) + collector.seed(first.snapshot) + const events = collector.events expect(first.snapshot.map((entry) => entry.key)).toEqual([GIT_KEY, FOLDER_KEY]) route.throws = true write() diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 2a827d976a3..6fc7c78409c 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -21,7 +21,7 @@ import { registerWorktreeChangeInvalidator } from '../ipc/worktree-change-invali import type { AgentSessionRecordStore } from './agent-session-record-store' import { peekOpenedAgentSessionRecordStore } from './agent-session-record-store-slot' import { createAgentLaunchRecordWarmupGate } from './agent-launch-record-warmup-gate' -import { WorkspaceLayoutStream } from './workspace-layout-stream' +import { WorkspaceLayoutStream, type WorkspaceLayoutReads } from './workspace-layout-stream' import type { RemoteLayoutSnapshot } from '../../shared/remote-layout-snapshot' import { registerDetectedWorktreeScanInvalidation } from '../ipc/worktrees/listing/register-detected-worktree-scan-invalidation' import { @@ -263,6 +263,11 @@ class OrcaRuntimeService extends OrcaRuntimeSidebarBase { return this.workspaceLayoutStream.subscribe(listener) } + /** Main's one layout projection for readers beside the stream, with or without a window. */ + get workspaceLayoutReads(): WorkspaceLayoutReads { + return this.workspaceLayoutStream + } + /** A window's layout command (`layout.apply`), applied to the partitions that hold its tab. */ async applyLayoutCommand(command: WindowLayoutCommand): Promise { const apply = (): Promise => diff --git a/src/main/runtime/published-pane-live-titles.ts b/src/main/runtime/published-pane-live-titles.ts new file mode 100644 index 00000000000..f873caff5c4 --- /dev/null +++ b/src/main/runtime/published-pane-live-titles.ts @@ -0,0 +1,31 @@ +// Each pane's live terminal title laid over a published layout: live state, never saved. + +import type { PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published' + +/** `titles` in tab and pane order, null for a pane with none; the same layout when all are null. */ +export function withLiveTitles( + layout: PublishedWorkspaceLayout, + titles: (string | null)[] +): PublishedWorkspaceLayout { + if (titles.every((title) => title === null)) { + return layout + } + let index = 0 + return { + ...layout, + tabs: layout.tabs.map((tab) => + tab.terminal + ? { + ...tab, + terminal: { + ...tab.terminal, + panes: tab.terminal.panes.map((pane) => { + const liveTitle = titles[index++] + return liveTitle === null ? pane : { ...pane, liveTitle } + }) + } + } + : tab + ) + } +} diff --git a/src/main/runtime/rpc/methods/workspace-layout.test.ts b/src/main/runtime/rpc/methods/workspace-layout.test.ts index a3644648238..36e538a4118 100644 --- a/src/main/runtime/rpc/methods/workspace-layout.test.ts +++ b/src/main/runtime/rpc/methods/workspace-layout.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' import type { OrcaRuntimeService } from '../../orca-runtime' -import type { WorkspaceLayoutEvent } from '../../../../shared/workspace-layout/workspace-layout-stream-frames' +import type { WorkspaceLayoutStreamEvent } from '../../../../shared/workspace-layout/workspace-layout-stream-frames' import { eraseRpcMethods, isStreamingMethod, type RpcContext } from '../core' import { ALL_RPC_METHODS } from './index' import { RUNTIME_CAPABILITIES } from '../../../../shared/protocol-version' @@ -11,12 +11,12 @@ const subscribe = methods.find((method) => method.name === 'layout.subscribe') const unsubscribeMethod = methods.find((method) => method.name === 'layout.unsubscribe') const getRemoteSnapshots = methods.find((method) => method.name === 'layout.getRemoteSnapshots') -function harness() { +function harness(epoch?: string) { const cleanups = new Map void>() - let publish: (event: WorkspaceLayoutEvent) => void = () => {} + let publish: (event: WorkspaceLayoutStreamEvent) => void = () => {} const unsubscribe = vi.fn() const fake = { - subscribeWorkspaceLayouts: (listener: (event: WorkspaceLayoutEvent) => void) => { + subscribeWorkspaceLayouts: (listener: (event: WorkspaceLayoutStreamEvent) => void) => { publish = listener return { snapshot: [ @@ -24,6 +24,7 @@ function harness() { { key: 'b', layout: { worktreeId: 'b' } } ], seq: 4, + ...(epoch ? { epoch } : {}), unsubscribe } }, @@ -36,7 +37,7 @@ function harness() { runtime, cleanups, unsubscribe, - publish: (event: WorkspaceLayoutEvent) => publish(event) + publish: (event: WorkspaceLayoutStreamEvent) => publish(event) } } @@ -76,6 +77,32 @@ describe('layout.subscribe', () => { expect(unsubscribe).toHaveBeenCalledTimes(1) }) + it('sends each records frame with only the named workspaces, an empty one too', async () => { + const { runtime, cleanups, publish } = harness('epoch-1') + const emitted: unknown[] = [] + if (!subscribe || !isStreamingMethod(subscribe)) { + throw new Error('layout.subscribe is not a streaming method') + } + const done = subscribe.handler( + { workspaces: ['b'] }, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the handler reads only runtime and connectionId from its context. + { runtime, connectionId: 'conn-1' } as RpcContext, + (event) => emitted.push(event) + ) + const removed = (key: string) => ({ key, change: 'removed' as const }) + publish({ type: 'records', epoch: 'epoch-1', seq: 5, workspaces: [removed('a'), removed('b')] }) + // Sent though it names none of them, so the subscriber's positions stay gapless. + publish({ type: 'records', epoch: 'epoch-1', seq: 6, workspaces: [removed('a')] }) + runtime.cleanupSubscription([...cleanups.keys()][0]!) + await done + expect(emitted).toEqual([ + expect.objectContaining({ type: 'snapshot', seq: 4, epoch: 'epoch-1' }), + { type: 'records', epoch: 'epoch-1', seq: 5, workspaces: [removed('b')] }, + { type: 'records', epoch: 'epoch-1', seq: 6, workspaces: [] }, + { type: 'end' } + ]) + }) + it('ends a subscription only for the connection that opened it', async () => { const { runtime, unsubscribe } = harness() const emitted: unknown[] = [] diff --git a/src/main/runtime/rpc/methods/workspace-layout.ts b/src/main/runtime/rpc/methods/workspace-layout.ts index d53385660c6..86958acbad1 100644 --- a/src/main/runtime/rpc/methods/workspace-layout.ts +++ b/src/main/runtime/rpc/methods/workspace-layout.ts @@ -30,8 +30,11 @@ export const WORKSPACE_LAYOUT_METHODS = [ const subscriptionId = `layout-${connectionId ?? 'inproc'}-${++layoutSubscriptionSeq}` const cleanupKey = layoutCleanupKey(connectionId, subscriptionId) const release = (): void => runtime.cleanupSubscription(cleanupKey) - const { snapshot, seq, unsubscribe } = runtime.subscribeWorkspaceLayouts((event) => { - if (wanted(event.key)) { + const { snapshot, seq, epoch, unsubscribe } = runtime.subscribeWorkspaceLayouts((event) => { + if (event.type === 'records') { + // Sent even when it names none of them, so the subscriber's positions stay gapless. + emit({ ...event, workspaces: event.workspaces.filter((change) => wanted(change.key)) }) + } else if (wanted(event.key)) { emit(event) } }) @@ -50,7 +53,8 @@ export const WORKSPACE_LAYOUT_METHODS = [ type: 'snapshot', subscriptionId, workspaces: snapshot.filter((entry) => wanted(entry.key)), - seq + seq, + ...(epoch ? { epoch } : {}) }) if (signal?.aborted) { release() diff --git a/src/main/runtime/runtime-store-contract.ts b/src/main/runtime/runtime-store-contract.ts index 801dadf81f8..79e01621f98 100644 --- a/src/main/runtime/runtime-store-contract.ts +++ b/src/main/runtime/runtime-store-contract.ts @@ -50,6 +50,7 @@ export type RuntimeStore = { getWorkspaceSession?: Store['getWorkspaceSession'] getWorkspaceSessionHostIds?: Store['getWorkspaceSessionHostIds'] onWorkspaceSessionWritten?: Store['onWorkspaceSessionWritten'] + onWorkspaceSessionOperationEnded?: Store['onWorkspaceSessionOperationEnded'] onStateWritten?: Store['onStateWritten'] setWorkspaceSession?: Store['setWorkspaceSession'] getRemoteLayoutSnapshots?: Store['getRemoteLayoutSnapshots'] @@ -70,6 +71,7 @@ export type RuntimeStore = { peekOrcadMigrationTerminalRestoreHint?: Store['peekOrcadMigrationTerminalRestoreHint'] consumeOrcadMigrationTerminalRestoreHint?: Store['consumeOrcadMigrationTerminalRestoreHint'] persistPtyBinding?: Store['persistPtyBinding'] + getPtyBindingGeneration?: Store['getPtyBindingGeneration'] admitTerminalPane?: Store['admitTerminalPane'] withdrawTerminalPane?: Store['withdrawTerminalPane'] applyLayoutCommand?: Store['applyLayoutCommand'] diff --git a/src/main/runtime/workspace-layout-frame-model-check.test-fixture.ts b/src/main/runtime/workspace-layout-frame-model-check.test-fixture.ts new file mode 100644 index 00000000000..721836b7bf3 --- /dev/null +++ b/src/main/runtime/workspace-layout-frame-model-check.test-fixture.ts @@ -0,0 +1,42 @@ +// Installs main's model check on every layout records frame for one test's life, and freezes each +// partition model handed to a reader so a reader that mutates the shared model throws. + +import { layoutFrameModelErrors } from './workspace-layout-frame-model-check' +import { + observeWorkspaceLayoutFrames, + observeWorkspaceLayoutPartitionReads +} from './workspace-layout-frame-observer' + +export function installLayoutFrameModelCheck(): { + /** Every mismatch found, frame by frame. */ + failures: string[] + /** How many frames were checked. */ + checked: () => number + uninstall: () => void +} { + const failures: string[] = [] + let checked = 0 + observeWorkspaceLayoutFrames((observation) => { + checked += 1 + failures.push(...layoutFrameModelErrors(observation)) + }) + observeWorkspaceLayoutPartitionReads(deepFreeze) + return { + failures, + checked: () => checked, + uninstall: () => { + observeWorkspaceLayoutFrames(null) + observeWorkspaceLayoutPartitionReads(null) + } + } +} + +function deepFreeze(value: unknown): void { + if (typeof value !== 'object' || value === null || Object.isFrozen(value)) { + return + } + Object.freeze(value) + for (const inner of Object.values(value)) { + deepFreeze(inner) + } +} diff --git a/src/main/runtime/workspace-layout-frame-model-check.ts b/src/main/runtime/workspace-layout-frame-model-check.ts new file mode 100644 index 00000000000..84b28096682 --- /dev/null +++ b/src/main/runtime/workspace-layout-frame-model-check.ts @@ -0,0 +1,52 @@ +// Main's model check on a records frame: the frame applied to what subscribers held must equal a +// fresh projection of every partition. Tests that install it check every frame; dev builds log a +// mismatch on frames a Store write caused (see workspace-layout-frame-observer.ts). + +import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report' +import { applyWorkspaceLayoutRecordChange } from '../../shared/workspace-layout/workspace-layout-records' +import type { LayoutFrameObservation } from './workspace-layout-frame-observer' + +/** Empty when the frame holds exactly what changed. */ +export function layoutFrameModelErrors({ frame, before, fresh }: LayoutFrameObservation): string[] { + const errors: string[] = [] + const applied = new Map(before) + for (const change of frame.workspaces) { + try { + const next = applyWorkspaceLayoutRecordChange(applied.get(change.key), change) + if (next) { + applied.set(change.key, next) + } else { + applied.delete(change.key) + } + } catch (error) { + errors.push(`frame ${frame.seq}: ${change.key} does not apply: ${String(error)}`) + } + } + const expected = fresh() + for (const key of new Set([...applied.keys(), ...expected.keys()])) { + const shown = applied.get(key) + const published = expected.get(key) + if (!shown || !published) { + errors.push( + `frame ${frame.seq}: ${key} is ${shown ? 'shown but not published' : 'published but not shown'}` + ) + } else if (stableJson(shown) !== stableJson(published)) { + errors.push(`frame ${frame.seq}: ${key} differs from a fresh projection`) + } + } + return errors +} + +let mismatchLogged = false + +/** The dev build's check: logs the first mismatching frame only; a title-only frame is skipped. */ +export function logLayoutFrameModelErrors(observation: LayoutFrameObservation): void { + if (mismatchLogged || observation.titlesOnly) { + return + } + const errors = layoutFrameModelErrors(observation) + if (errors.length > 0) { + mismatchLogged = true + console.warn('[workspace-layout] a layout frame does not match main’s model:', errors) + } +} diff --git a/src/main/runtime/workspace-layout-frame-observer.ts b/src/main/runtime/workspace-layout-frame-observer.ts new file mode 100644 index 00000000000..5ea9cac90d6 --- /dev/null +++ b/src/main/runtime/workspace-layout-frame-observer.ts @@ -0,0 +1,50 @@ +// Test hooks on main's layout stream. `installLayoutFrameModelCheck` (installed by the operation-frame +// tests) checks every records frame against a fresh projection and freezes each partition read. +// Dev builds log the frame check on structural frames; release builds run neither. + +import { logLayoutFrameModelErrors } from './workspace-layout-frame-model-check' +import type { LoadedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load-types' +import type { PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published' +import type { WorkspaceLayoutRecordsFrame } from '../../shared/workspace-layout/workspace-layout-stream-frames' + +export type LayoutFrameObservation = { + frame: WorkspaceLayoutRecordsFrame + /** What subscribers held before the frame. */ + before: ReadonlyMap + /** Every workspace as a projection from scratch publishes it now. */ + fresh: () => ReadonlyMap + /** Only live titles changed: no Store write, so no projection to check. */ + titlesOnly: boolean +} + +export type LayoutFrameObserver = (observation: LayoutFrameObservation) => void + +let observer: LayoutFrameObserver | null = null + +// Why NODE_ENV: electron-vite sets it to 'development' only for a dev build; tests set 'test'. +const DEV_BUILD_CHECK: LayoutFrameObserver | null = + process.env.NODE_ENV === 'development' ? logLayoutFrameModelErrors : null + +/** Null removes the observer. */ +export function observeWorkspaceLayoutFrames(next: LayoutFrameObserver | null): void { + observer = next +} + +export function workspaceLayoutFrameObserver(): LayoutFrameObserver | null { + return observer ?? DEV_BUILD_CHECK +} + +let partitionReadObserver: ((loaded: LoadedWorkspaceLayout) => void) | null = null + +/** Tests: sees each partition model handed to a reader, so a mutating reader fails. Null removes it. */ +export function observeWorkspaceLayoutPartitionReads( + next: ((loaded: LoadedWorkspaceLayout) => void) | null +): void { + partitionReadObserver = next +} + +export function workspaceLayoutPartitionReadObserver(): + | ((loaded: LoadedWorkspaceLayout) => void) + | null { + return partitionReadObserver +} diff --git a/src/main/runtime/workspace-layout-live-titles.test.ts b/src/main/runtime/workspace-layout-live-titles.test.ts index d8aa86f4fbe..e6130cba4bf 100644 --- a/src/main/runtime/workspace-layout-live-titles.test.ts +++ b/src/main/runtime/workspace-layout-live-titles.test.ts @@ -5,6 +5,7 @@ import type { PublishedPane } from '../../shared/workspace-layout/workspace-layo import type { WorkspaceLayoutEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames' import type { WorkspaceSessionState } from '../../shared/workspace-session-state-types' import { OrcaRuntimeService } from './orca-runtime' +import { collectLayoutEvents } from './workspace-layout-stream-events.test-fixture' const WORKTREE_ID = 'repo::/worktree' const REPO = { id: 'repo', path: '/worktree', displayName: 'repo', badgeColor: 'blue', addedAt: 1 } @@ -68,8 +69,10 @@ function paneOf(event: WorkspaceLayoutEvent): PublishedPane | undefined { describe('live pane titles on the layout stream', () => { it('publishes the title a pane terminal sets, once per change', async () => { const runtime = runtimeWithPane() - const events: WorkspaceLayoutEvent[] = [] - const { snapshot } = runtime.subscribeWorkspaceLayouts((event) => events.push(event)) + const collector = collectLayoutEvents() + const { events } = collector + const { snapshot } = runtime.subscribeWorkspaceLayouts(collector.listener) + collector.seed(snapshot) expect(snapshot.map((entry) => entry.key)).toEqual([WORKTREE_ID]) expect(snapshot[0].layout.tabs[0]?.terminal?.panes[0]).toEqual({ leafId: LEAF, ptyId: PTY_ID }) @@ -86,8 +89,9 @@ describe('live pane titles on the layout stream', () => { it('sends no frame per spinner glyph while an agent works', async () => { const runtime = runtimeWithPane() - const events: WorkspaceLayoutEvent[] = [] - runtime.subscribeWorkspaceLayouts((event) => events.push(event)) + const collector = collectLayoutEvents() + const { events } = collector + collector.seed(runtime.subscribeWorkspaceLayouts(collector.listener).snapshot) const spinner = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'] for (let frame = 0; frame < 100; frame++) { runtime.onPtyData( diff --git a/src/main/runtime/workspace-layout-operation-frames.test.ts b/src/main/runtime/workspace-layout-operation-frames.test.ts new file mode 100644 index 00000000000..127223def1e --- /dev/null +++ b/src/main/runtime/workspace-layout-operation-frames.test.ts @@ -0,0 +1,297 @@ +// Main publishes one records frame per operation on a real Store (design E1), checked against a +// fresh projection on every frame, with or without a window. + +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { getDefaultWorkspaceSession } from '../../shared/constants' +import type { ExecutionHostId } from '../../shared/execution-host' +import type { GeneratedTabTitle } from '../../shared/workspace-layout/workspace-layout-command-types' +import type { WorkspaceLayoutStreamEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames' +import type { Store } from '../persistence/loading-store/store' +import { WindowSession } from '../persistence/terminal-topology/terminal-topology-window-session-fixture' +import { + emptyTerminalSessionProfile, + FIXTURE_FOLDER_WORKTREE_ID as FOLDER, + FIXTURE_GIT_WORKTREE_ID as GIT, + openTopologyStore +} from '../persistence/terminal-topology/terminal-topology-profile-fixture' +import { applyWindowLayoutCommand } from './runtime-layout-apply' +import { + layoutFrameModelErrors, + logLayoutFrameModelErrors +} from './workspace-layout-frame-model-check' +import { installLayoutFrameModelCheck } from './workspace-layout-frame-model-check.test-fixture' +import { + observeWorkspaceLayoutFrames, + type LayoutFrameObservation +} from './workspace-layout-frame-observer' +import { WorkspaceLayoutStream, type LayoutOperationCommit } from './workspace-layout-stream' + +const layoutOwner = vi.hoisted(() => ({ isMain: true })) +vi.mock('../../shared/layout-owner-switch', () => ({ + get ORCA_LAYOUT_OWNER_IS_MAIN() { + return layoutOwner.isMain + } +})) +vi.mock('../telemetry/client', () => ({ track: vi.fn() })) +vi.mock('../ssh/config/ssh-config-parser', () => ({ + loadUserSshConfig: () => ({ hosts: [] }), + sshConfigHostsToTargets: () => [] +})) + +const SSH: ExecutionHostId = 'ssh:target-1' +const SSH_KEY = 'repo-ssh::/srv/api' +const homeOf = (key: string): ExecutionHostId => (key === SSH_KEY ? SSH : 'local') +const leafOf = (index: number) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` +const directories: string[] = [] + +/** Tabs `tab--` in each workspace, its panes unbound until a test binds one. */ +async function storeWith(tabs: Record): Promise { + const directory = mkdtempSync(join(tmpdir(), 'orca-operation-frames-')) + directories.push(directory) + const store = await openTopologyStore(directory, emptyTerminalSessionProfile()) + const sessions = new Map() + let leaf = 0 + for (const [workspace, count] of Object.entries(tabs)) { + const hostId = homeOf(workspace) + const session = sessions.get(hostId) ?? new WindowSession(getDefaultWorkspaceSession()) + sessions.set(hostId, session) + for (let index = 0; index < count; index += 1) { + session.addTab(workspace, tabIdOf(workspace, index), leafOf(leaf++)) + } + } + for (const [hostId, session] of sessions) { + store.setWorkspaceSession(session.snapshot(), hostId) + } + await store.flushPendingOrThrowAsync() + return store +} + +const tabIdOf = (workspace: string, index: number) => + `tab-${workspace.replace(/\W/g, '-')}-${index}` + +function streamOver(store: Store): WorkspaceLayoutStream { + return new WorkspaceLayoutStream({ + store: () => store, + homeHostId: homeOf, + liveTitle: () => null + }) +} + +function titles(tabs: Record): GeneratedTabTitle[] { + return Object.entries(tabs).flatMap(([workspace, count]) => + Array.from({ length: count }, (_, index) => ({ + workspace, + tabId: tabIdOf(workspace, index), + title: `Title ${index}` + })) + ) +} + +const renameTabs = (store: Store, renames: GeneratedTabTitle[]) => + applyWindowLayoutCommand(store, (workspace) => [homeOf(workspace)], { + type: 'renameTabs', + kind: 'generated', + renames + }) + +let check: ReturnType + +beforeEach(() => { + check = installLayoutFrameModelCheck() +}) + +afterEach(() => { + check.uninstall() + for (const directory of directories.splice(0)) { + rmSync(directory, { recursive: true, force: true }) + } +}) + +describe('one records frame per main operation', () => { + const SPREAD = { [GIT]: 50, [FOLDER]: 49, [SSH_KEY]: 1 } + + it('publishes 100 generated titles in three workspaces and two partitions as one frame', async () => { + const store = await storeWith(SPREAD) + const frames: WorkspaceLayoutStreamEvent[] = [] + const { seq } = streamOver(store).subscribe((frame) => frames.push(frame)) + + await expect(renameTabs(store, titles(SPREAD))).resolves.toEqual({ ok: true, result: {} }) + + expect(frames).toHaveLength(1) + const frame = frames[0] + if (frame?.type !== 'records') { + throw new Error('expected one records frame') + } + expect(frame.seq).toBe(seq + 1) + // Only the renamed tab rows: no tree, binding or workspace record. + expect( + frame.workspaces.map(({ key, change, ...records }) => [key, change, Object.keys(records)]) + ).toEqual([ + [GIT, 'records', ['tabs']], + [FOLDER, 'records', ['tabs']], + [SSH_KEY, 'records', ['tabs']] + ]) + expect( + frame.workspaces.flatMap((change) => ('tabs' in change ? change.tabs!.set! : [])) + ).toHaveLength(100) + expect(check.failures).toEqual([]) + expect(check.checked()).toBe(1) + }) + + it('publishes a pane bind as one binding record, and a same-id respawn as one more', async () => { + const store = await storeWith({ [GIT]: 2 }) + const frames: WorkspaceLayoutStreamEvent[] = [] + streamOver(store).subscribe((frame) => frames.push(frame)) + const bind = (incarnationId: string) => + store.persistPtyBinding({ + worktreeId: GIT, + tabId: tabIdOf(GIT, 1), + leafId: leafOf(1), + ptyId: 'pty-1', + incarnationId + }) + + await bind('inc-1') + await bind('inc-2') + + const binding = (ptyGeneration: number) => [ + { + key: GIT, + change: 'records', + bindings: { set: [{ leafId: leafOf(1), ptyId: 'pty-1', ptyGeneration }] } + } + ] + expect(frames.map((frame) => frame.type === 'records' && frame.workspaces)).toEqual([ + binding(1), + binding(2) + ]) + expect(check.failures).toEqual([]) + }) + + it('sends nothing for an operation that changes no record', async () => { + const store = await storeWith({ [GIT]: 1 }) + const frames: WorkspaceLayoutStreamEvent[] = [] + streamOver(store).subscribe((frame) => frames.push(frame)) + + await renameTabs(store, [{ workspace: GIT, tabId: 'missing', title: 'Gone' }]) + + expect(frames).toEqual([]) + }) +}) + +describe('a throwing operation listener', () => { + it('fails neither the committed operation nor the listeners after it', async () => { + const store = await storeWith({ [GIT]: 1 }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const heard: (readonly string[] | null)[] = [] + store.onWorkspaceSessionOperationEnded(() => { + throw new Error('listener failed') + }) + store.onWorkspaceSessionOperationEnded((hostIds) => heard.push(hostIds)) + + await expect(renameTabs(store, titles({ [GIT]: 1 }))).resolves.toEqual({ ok: true, result: {} }) + + expect(heard).toEqual([['local']]) + expect(store.getWorkspaceSession().tabsByWorktree[GIT]![0]!.generatedTitle).toBe('Title 0') + warn.mockRestore() + }) +}) + +describe("main's model check on every frame", () => { + it('passes the frame main sent and catches a broken copy of it', async () => { + check.uninstall() + const store = await storeWith({ [GIT]: 3 }) + const observed: LayoutFrameObservation[] = [] + observeWorkspaceLayoutFrames((observation) => observed.push(observation)) + streamOver(store).subscribe(() => {}) + + await renameTabs(store, titles({ [GIT]: 3 })) + + expect(observed).toHaveLength(1) + const observation = observed[0]! + expect(layoutFrameModelErrors(observation)).toEqual([]) + const broken = structuredClone(observation.frame) + const change = broken.workspaces[0]! + if (change.change !== 'records') { + throw new Error('expected a records change') + } + change.tabs!.set!.pop() + expect(layoutFrameModelErrors({ ...observation, frame: broken })).toEqual([ + `frame ${broken.seq}: ${GIT} differs from a fresh projection` + ]) + observeWorkspaceLayoutFrames(null) + }) +}) + +describe("the dev build's model check", () => { + it('skips a frame that only changed live titles, and checks a structural one', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) + const fresh = vi.fn(() => new Map()) + const frame = { + type: 'records' as const, + epoch: 'epoch-1', + seq: 1, + workspaces: [{ key: GIT, change: 'removed' as const }] + } + logLayoutFrameModelErrors({ frame, before: new Map(), fresh, titlesOnly: true }) + expect(fresh).not.toHaveBeenCalled() + logLayoutFrameModelErrors({ frame, before: new Map(), fresh, titlesOnly: false }) + expect(fresh).toHaveBeenCalledTimes(1) + warn.mockRestore() + }) +}) + +describe('a headless main', () => { + it('hears each operation with no layout subscriber, from one projection it shares', async () => { + const store = await storeWith({ [GIT]: 2, [SSH_KEY]: 1 }) + const getSession = vi.spyOn(store, 'getWorkspaceSession') + const stream = streamOver(store) + const commits: LayoutOperationCommit[] = [] + stream.onLayoutOperationCommitted((commit) => commits.push(commit)) + const first = stream.readPartition('local') + + await renameTabs(store, titles({ [SSH_KEY]: 1 })) + + expect(commits).toEqual([{ hostIds: [SSH] }]) + // The untouched partition is the same loaded model, read once. + getSession.mockClear() + expect(stream.readPartition('local')).toBe(first) + expect(getSession).not.toHaveBeenCalled() + const ssh = stream.readPartition(SSH)!.layout.workspaces[SSH_KEY]! + expect(ssh.tabs.map((tab) => tab.generatedTitle)).toEqual(['Title 0']) + // The model is the stream's own: a reader that edits it fails. + expect(() => { + ssh.tabs[0]!.generatedTitle = 'edited' + }).toThrow(TypeError) + await renameTabs(store, [{ workspace: GIT, tabId: tabIdOf(GIT, 0), title: 'Next' }]) + expect(stream.readPartition('local')!.layout.workspaces[GIT]!.tabs[0]!.generatedTitle).toBe( + 'Next' + ) + }) + + it('sends a window-style and a phone-style subscriber the same frames', async () => { + const store = await storeWith({ [GIT]: 2, [FOLDER]: 1 }) + const stream = streamOver(store) + const window: WorkspaceLayoutStreamEvent[] = [] + const phone: WorkspaceLayoutStreamEvent[] = [] + const windowSub = stream.subscribe((frame) => window.push(frame)) + const phoneSub = stream.subscribe((frame) => phone.push(frame)) + + await renameTabs(store, titles({ [GIT]: 2, [FOLDER]: 1 })) + await store.persistPtyBinding({ + worktreeId: GIT, + tabId: tabIdOf(GIT, 0), + leafId: leafOf(0), + ptyId: 'pty-0' + }) + + expect(window).toHaveLength(2) + expect(phone).toEqual(window) + expect(phoneSub.snapshot).toEqual(windowSub.snapshot) + expect(check.failures).toEqual([]) + expect(check.checked()).toBe(2) + }) +}) diff --git a/src/main/runtime/workspace-layout-projection.ts b/src/main/runtime/workspace-layout-projection.ts new file mode 100644 index 00000000000..2e234c170ce --- /dev/null +++ b/src/main/runtime/workspace-layout-projection.ts @@ -0,0 +1,197 @@ +// Each workspace's published layout, projected from the Store's session partitions: a partition +// is re-run through the Loader only when a write may have changed it, and each workspace is +// published from the partition that owns it. + +import type { ExecutionHostId } from '../../shared/execution-host' +import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' +import { loadWorkspaceLayoutWithoutReport } from '../../shared/workspace-layout/workspace-layout-load' +import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report' +import type { + LoadedWorkspaceLayout, + WorkspaceLayoutLoadContext +} from '../../shared/workspace-layout/workspace-layout-load-types' +import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids' +import { + publishWorkspaceLayout, + type PublishedWorkspaceLayout +} from '../../shared/workspace-layout/workspace-layout-published' +import type { RuntimeStore } from './runtime-store-contract' + +export type ProjectedLayout = { layout: PublishedWorkspaceLayout; json: string } + +export type ProjectionStore = Pick< + RuntimeStore, + 'getWorkspaceSession' | 'getWorkspaceSessionHostIds' | 'getPtyBindingGeneration' +> + +/** Partitions to re-project; 'all' when a write did not name its partition. */ +export type DirtyPartitions = Set | 'all' + +/** The context main loads a partition with: its panes' binding generations ride the leaves. */ +export function runtimeLayoutLoadContext( + store: Pick | null, + hostId: ExecutionHostId +): WorkspaceLayoutLoadContext { + const generationOf = store?.getPtyBindingGeneration + // Switch point: a window-owned build publishes no generation. + return ORCA_LAYOUT_OWNER_IS_MAIN && generationOf + ? { + ...nameBasedLoadContext(), + ptyGenerationOf: (leafId) => generationOf.call(store, hostId, leafId) + } + : nameBasedLoadContext() +} + +/** One partition as loaded once: its model, and each workspace published from it on first ask. */ +class ProjectedPartition { + private published: Map | null = null + + constructor( + private readonly hostId: ExecutionHostId, + readonly loaded: LoadedWorkspaceLayout | null + ) {} + + /** Every workspace it holds, its own copy of a workspace it does not own included. */ + workspaces(): Map { + if (!this.published) { + this.published = new Map() + for (const [key, workspace] of Object.entries(this.loaded?.layout.workspaces ?? {})) { + const published = publishWorkspaceLayout(workspace, this.hostId) + this.published.set(key, { layout: published, json: stableJson(published) }) + } + } + return this.published + } +} + +export class WorkspaceLayoutProjection { + /** Workspace key → its published layout, with its JSON for the by-value comparison. */ + published = new Map() + private partitions = new Map() + /** Partitions a write may have changed since they were loaded. */ + private stale: DirtyPartitions = 'all' + /** Its own flag, so a routing warning never hides a later projection or listener failure. */ + private routingFailureLogged = false + + constructor( + private readonly deps: { + store: () => ProjectionStore | null + /** The partition that owns a workspace key; null when none does. */ + homeHostId: (key: string) => ExecutionHostId | null + } + ) {} + + /** Forgets every projection: the next read re-loads every partition. */ + clear(): void { + this.published.clear() + this.forgetPartitions() + } + + /** After a failed projection too: the next read re-loads every partition. */ + forgetPartitions(): void { + this.partitions.clear() + this.stale = 'all' + } + + /** A write may have changed `hostId`'s partition; null: any partition. */ + invalidate(hostId: ExecutionHostId | null): void { + if (hostId === null || this.stale === 'all') { + this.stale = 'all' + } else { + this.stale.add(hostId) + } + } + + /** + * One partition's loaded model, re-loaded only if a write may have changed it; null with no + * Store. Only a reader that hears every write (`invalidate`) may `keep` it for the next read. + */ + loaded(hostId: ExecutionHostId, keep: boolean): LoadedWorkspaceLayout | null { + return keep ? this.cachedPartition(hostId).loaded : this.loadPartition(hostId).loaded + } + + /** Every workspace as published, each stale partition re-loaded; becomes `published`. */ + project(): Map { + const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? [] + const partitions = new Map(hostIds.map((hostId) => [hostId, this.cachedPartition(hostId)])) + this.partitions = partitions + this.published = this.route(partitions) + return this.published + } + + /** What a projection of every partition from scratch publishes now; changes no cache. */ + projectFresh(): Map { + const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? [] + return this.route(new Map(hostIds.map((hostId) => [hostId, this.loadPartition(hostId)]))) + } + + private cachedPartition(hostId: ExecutionHostId): ProjectedPartition { + if (this.stale === 'all') { + this.stale = new Set(this.deps.store()?.getWorkspaceSessionHostIds?.() ?? []) + this.partitions.clear() + } + const cached = this.stale.has(hostId) ? undefined : this.partitions.get(hostId) + if (cached) { + return cached + } + const loaded = this.loadPartition(hostId) + this.partitions.set(hostId, loaded) + this.stale.delete(hostId) + return loaded + } + + private loadPartition(hostId: ExecutionHostId): ProjectedPartition { + const store = this.deps.store() + const session = store?.getWorkspaceSession?.(hostId) + return new ProjectedPartition( + hostId, + session + ? loadWorkspaceLayoutWithoutReport(hostId, session, runtimeLayoutLoadContext(store, hostId)) + : null + ) + } + + private route( + partitions: Map + ): Map { + const projected = new Map() + const unroutable = new Set() + for (const [hostId, partition] of partitions) { + for (const [key, workspace] of partition.workspaces()) { + // Only the owning partition's copy is published; a stray copy elsewhere is not shown. + if (this.homeOf(key, unroutable) === hostId) { + projected.set(key, workspace) + } + } + } + for (const key of unroutable) { + const kept = this.published.get(key) + // Kept as last published: a 'removed' frame would make the window drop its tabs. + if (kept && !projected.has(key)) { + projected.set(key, kept) + } + } + return projected + } + + /** + * A workspace the router throws on is not guessed (`repoId::path` repeats across hosts, so a + * default could publish another host's copy); it is noted in `unroutable`. It never stops the rest. + */ + private homeOf(key: string, unroutable: Set): ExecutionHostId | null { + try { + return this.deps.homeHostId(key) + } catch (error) { + // Switch point: a window-owned build keeps failing the whole projection, as today. + if (!ORCA_LAYOUT_OWNER_IS_MAIN) { + throw error + } + unroutable.add(key) + if (!this.routingFailureLogged) { + this.routingFailureLogged = true + console.warn('[workspace-layout] layout stream routing failed:', error) + } + return null + } + } +} diff --git a/src/main/runtime/workspace-layout-stream-events.test-fixture.ts b/src/main/runtime/workspace-layout-stream-events.test-fixture.ts new file mode 100644 index 00000000000..2c87d0bef8b --- /dev/null +++ b/src/main/runtime/workspace-layout-stream-events.test-fixture.ts @@ -0,0 +1,55 @@ +// Reads a layout stream the same way in either build: a records frame becomes one event per +// workspace it changed, carrying that workspace's whole layout as a window-owned build sends it. + +import type { PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published' +import { applyWorkspaceLayoutRecordChange } from '../../shared/workspace-layout/workspace-layout-records' +import type { + WorkspaceLayoutEvent, + WorkspaceLayoutStreamEvent +} from '../../shared/workspace-layout/workspace-layout-stream-frames' + +export type LayoutEventCollector = { + /** One per workspace changed, in frame order. */ + events: WorkspaceLayoutEvent[] + /** As the stream sent them. */ + frames: WorkspaceLayoutStreamEvent[] + /** What a reader applying every frame to the snapshot holds now. */ + layouts: Map + listener: (event: WorkspaceLayoutStreamEvent) => void + /** The subscription's snapshot: records frames apply to it. */ + seed: (snapshot: { key: string; layout: PublishedWorkspaceLayout }[]) => void +} + +export function collectLayoutEvents(): LayoutEventCollector { + const collector: LayoutEventCollector = { + events: [], + frames: [], + layouts: new Map(), + seed: (snapshot) => { + collector.layouts = new Map(snapshot.map(({ key, layout }) => [key, layout])) + }, + listener: (event) => { + collector.frames.push(event) + if (event.type !== 'records') { + collector.events.push(event) + if (event.type === 'removed') { + collector.layouts.delete(event.key) + } else { + collector.layouts.set(event.key, event.layout) + } + return + } + for (const change of event.workspaces) { + const layout = applyWorkspaceLayoutRecordChange(collector.layouts.get(change.key), change) + if (layout) { + collector.layouts.set(change.key, layout) + collector.events.push({ type: 'workspace', key: change.key, layout, seq: event.seq }) + } else { + collector.layouts.delete(change.key) + collector.events.push({ type: 'removed', key: change.key, seq: event.seq }) + } + } + } + } + return collector +} diff --git a/src/main/runtime/workspace-layout-stream.test.ts b/src/main/runtime/workspace-layout-stream.test.ts index aa52f4b846b..49eeca4c264 100644 --- a/src/main/runtime/workspace-layout-stream.test.ts +++ b/src/main/runtime/workspace-layout-stream.test.ts @@ -13,7 +13,6 @@ import { SERVER_KEY, SSH_KEY } from '../../shared/workspace-layout/workspace-layout-session.test-fixture' -import type { WorkspaceLayoutEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames' import { loadWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load' import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids' import { @@ -21,6 +20,7 @@ import { type PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published' import { WorkspaceLayoutStream } from './workspace-layout-stream' +import { collectLayoutEvents } from './workspace-layout-stream-events.test-fixture' const SSH_HOST: ExecutionHostId = 'ssh:target-1' const SERVER_HOST: ExecutionHostId = 'runtime:env-1' @@ -98,6 +98,14 @@ function setup() { const flush = () => new Promise((resolve) => queueMicrotask(resolve)) +/** A subscriber reading each change per workspace, in either build. */ +function subscribeEvents(stream: WorkspaceLayoutStream) { + const collector = collectLayoutEvents() + const subscription = stream.subscribe(collector.listener) + collector.seed(subscription.snapshot) + return { events: collector.events, collector, subscription } +} + describe('WorkspaceLayoutStream', () => { it('does nothing until someone subscribes, and stops when the last one leaves', () => { const { stream, store, listeners } = setup() @@ -133,8 +141,7 @@ describe('WorkspaceLayoutStream', () => { it('publishes a workspace when a write changes it by value, and its removal', async () => { const { stream, write } = setup() - const events: WorkspaceLayoutEvent[] = [] - stream.subscribe((event) => events.push(event)) + const { events } = subscribeEvents(stream) const session = localDesktopSession() write(LOCAL_EXECUTION_HOST_ID, session) await flush() @@ -159,8 +166,7 @@ describe('WorkspaceLayoutStream', () => { it('numbers each change frame, so a reply can name the frame holding its write', async () => { const { stream, write } = setup() - const events: WorkspaceLayoutEvent[] = [] - const first = stream.subscribe((event) => events.push(event)) + const { events, subscription: first } = subscribeEvents(stream) const session = localDesktopSession() write(LOCAL_EXECUTION_HOST_ID, session) await flush() @@ -203,11 +209,10 @@ describe('WorkspaceLayoutStream', () => { it('keeps delivering to every listener when one throws', async () => { const { stream, write } = setup() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const events: WorkspaceLayoutEvent[] = [] stream.subscribe(() => { throw new Error('listener failed') }) - stream.subscribe((event) => events.push(event)) + const { events } = subscribeEvents(stream) const session = localDesktopSession() session.tabGroups![GIT_KEY]![0]!.tabOrder.reverse() write(LOCAL_EXECUTION_HOST_ID, session) @@ -244,11 +249,9 @@ describe('WorkspaceLayoutStream', () => { homeHostId: () => LOCAL_EXECUTION_HOST_ID, liveTitle: () => null }) - const events: WorkspaceLayoutEvent[] = [] + const { events, subscription } = subscribeEvents(stream) const before = new Map( - stream - .subscribe((event) => events.push(event)) - .snapshot.map((entry) => [entry.key, JSON.stringify(entry.layout)]) + subscription.snapshot.map((entry) => [entry.key, JSON.stringify(entry.layout)]) ) fake.write(LOCAL_EXECUTION_HOST_ID, rowsOnly(keys.slice(1), ['t1', 't2'])) await flush() @@ -286,19 +289,11 @@ describe('WorkspaceLayoutStream', () => { it('publishes what a full re-projection with the load report would, write after write', async () => { const { stream, write, writeUnnamed, removePartition, homes, partitions } = setup() const homeHostId = (key: string) => homes[key] ?? null - let cache = new Map() - const { snapshot } = stream.subscribe((event) => { - if (event.type === 'removed') { - cache.delete(event.key) - } else { - cache.set(event.key, event.layout) - } - }) - cache = new Map(snapshot.map((entry) => [entry.key, entry.layout])) + const { collector } = subscribeEvents(stream) const expectFullProjection = async (): Promise => { await flush() const expected = fullProjection(partitions, homeHostId) - expect(Object.fromEntries(cache)).toEqual(Object.fromEntries(expected)) + expect(Object.fromEntries(collector.layouts)).toEqual(Object.fromEntries(expected)) // A late subscriber's snapshot keeps today's order too. const late = stream.subscribe(() => {}) expect(late.snapshot.map((entry) => [entry.key, entry.layout])).toEqual(expected) @@ -334,8 +329,7 @@ describe('WorkspaceLayoutStream', () => { it('re-projects every partition after a failed projection', async () => { const { stream, store, write } = setup() const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) - const events: WorkspaceLayoutEvent[] = [] - stream.subscribe((event) => events.push(event)) + const { events } = subscribeEvents(stream) store.getWorkspaceSession.mockImplementationOnce(() => { throw new Error('read failed') }) @@ -384,8 +378,10 @@ describe('WorkspaceLayoutStream', () => { it('publishes a title change without reading the Store, one frame per burst', async () => { const { stream, store, liveTitles, write } = setup() - const events: WorkspaceLayoutEvent[] = [] - const { snapshot } = stream.subscribe((event) => events.push(event)) + const { + events, + subscription: { snapshot } + } = subscribeEvents(stream) const [ptyId] = ptyIdsOf(snapshot.find((entry) => entry.key === GIT_KEY)!.layout) store.getWorkspaceSession.mockClear() for (let frame = 0; frame < 50; frame++) { diff --git a/src/main/runtime/workspace-layout-stream.ts b/src/main/runtime/workspace-layout-stream.ts index ec396f67544..8aac83fc762 100644 --- a/src/main/runtime/workspace-layout-stream.ts +++ b/src/main/runtime/workspace-layout-stream.ts @@ -1,50 +1,90 @@ // The read-only layout stream behind `layout.subscribe`: each workspace's published layout, -// re-projected from the Store's sessions after every write while anyone subscribes. A write that -// names its partition re-projects only that partition. Each pane's live terminal title is laid -// over the cached projection, so a title change costs no projection. Nothing reads it until a -// client opts in; with no subscriber it does no work. +// re-projected from the Store's sessions after writes while anyone subscribes. A write that names +// its partition re-projects only that partition. Each pane's live terminal title is laid over the +// cached projection, so a title change costs no projection. With no subscriber it does no work. +// +// A layout-owner build publishes once per main operation (one durable mutation: a `layout.apply`, +// a binding write, a repair), as one records frame of what changed; writes no operation scopes +// (a window save) publish when their task ends. A window-owned build sends a frame per workspace. +import { randomUUID } from 'node:crypto' import type { ExecutionHostId } from '../../shared/execution-host' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../shared/layout-owner-switch' -import { loadWorkspaceLayoutWithoutReport } from '../../shared/workspace-layout/workspace-layout-load' -import { stableJson } from '../../shared/workspace-layout/workspace-layout-load-report' +import type { LoadedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-load-types' +import type { PublishedWorkspaceLayout } from '../../shared/workspace-layout/workspace-layout-published' import { - publishWorkspaceLayout, - type PublishedWorkspaceLayout -} from '../../shared/workspace-layout/workspace-layout-published' -import { nameBasedLoadContext } from '../../shared/workspace-layout/workspace-layout-minted-ids' -import type { WorkspaceLayoutEvent } from '../../shared/workspace-layout/workspace-layout-stream-frames' + diffPublishedLayouts, + indexPublishedLayout, + type IndexedLayoutRecords, + type WorkspaceLayoutRecordChange +} from '../../shared/workspace-layout/workspace-layout-records' +import type { + WorkspaceLayoutEvent, + WorkspaceLayoutStreamEvent +} from '../../shared/workspace-layout/workspace-layout-stream-frames' import type { RuntimeStore } from './runtime-store-contract' +import { withLiveTitles } from './published-pane-live-titles' +import { + workspaceLayoutFrameObserver, + workspaceLayoutPartitionReadObserver +} from './workspace-layout-frame-observer' +import { + WorkspaceLayoutProjection, + type DirtyPartitions, + type ProjectionStore +} from './workspace-layout-projection' -type LayoutListener = (event: WorkspaceLayoutEvent) => void +type LayoutListener = (event: WorkspaceLayoutStreamEvent) => void -type Published = { layout: PublishedWorkspaceLayout; json: string } +/** A main operation's end, or the end of a task's unscoped writes: the partitions it may have + * written; null when any may have changed. */ +export type LayoutOperationCommit = { hostIds: readonly ExecutionHostId[] | null } /** What a workspace was last sent as: its projection's JSON and its panes' live titles. */ -type Shown = { json: string; liveTitles: string; layout: PublishedWorkspaceLayout } +type Shown = { + json: string + liveTitles: string + layout: PublishedWorkspaceLayout + /** Its records, indexed when a later layout is first diffed against it; null: it goes whole. */ + records?: IndexedLayoutRecords | null +} -type StreamStore = Pick< - RuntimeStore, - 'getWorkspaceSession' | 'getWorkspaceSessionHostIds' | 'onWorkspaceSessionWritten' +/** What a reader beside the stream uses instead of a Loader pass of its own. */ +export type WorkspaceLayoutReads = Pick< + WorkspaceLayoutStream, + 'onLayoutOperationCommitted' | 'readPartition' > +type StreamStore = ProjectionStore & + Pick + +function addDirty(dirty: DirtyPartitions | null, hostId: ExecutionHostId | null): DirtyPartitions { + if (hostId === null || dirty === 'all') { + return 'all' + } + return new Set([...(dirty ?? []), hostId]) +} + export class WorkspaceLayoutStream { private readonly listeners = new Set() - /** Workspace key → its published layout, with its JSON for the by-value comparison. */ - private published = new Map() + private readonly commitListeners = new Set<(commit: LayoutOperationCommit) => void>() + private readonly projection: WorkspaceLayoutProjection /** Workspace key → what listeners last received for it, live titles included. */ private shown = new Map() - /** Partition → every workspace it holds, its own copy of a workspace it does not own included. */ - private projectedPartitions = new Map>() private stopWatching: (() => void) | null = null - /** Partitions to re-project at the next flush; 'all' when a write did not name its partition. */ - private dirty: Set | 'all' | null = null + /** From the first subscriber to the last: only then is anything projected. */ + private projecting = false + /** A write may have changed what subscribers hold: the next flush re-projects stale partitions. */ + private dirty = false + /** Partitions written since commit listeners last heard. */ + private touched: DirtyPartitions | null = null private liveTitlesDirty = false + private flushQueued = false private failureLogged = false - /** Its own flag, so a routing warning never hides a later projection or listener failure. */ - private routingFailureLogged = false /** The last change frame's position; never reset, so a reply's position outlives resubscribes. */ private seq = 0 + /** Names this stream for the process's life: a reader seeing another one missed frames. */ + private readonly epoch = ORCA_LAYOUT_OWNER_IS_MAIN ? randomUUID() : undefined constructor( private readonly deps: { @@ -54,53 +94,82 @@ export class WorkspaceLayoutStream { /** The title a terminal shows now; null when it has none. */ liveTitle: (ptyId: string) => string | null } - ) {} + ) { + this.projection = new WorkspaceLayoutProjection(deps) + } /** Every workspace's layout now, then each change until `unsubscribe`. */ subscribe(listener: LayoutListener): { snapshot: { key: string; layout: PublishedWorkspaceLayout }[] /** The position the snapshot holds. */ seq: number + /** A layout-owner build's stream epoch; its records frames carry it. */ + epoch?: string unsubscribe: () => void } { - if (this.listeners.size === 0) { - this.stopWatching = - this.deps.store()?.onWorkspaceSessionWritten?.((hostId) => this.markDirty(hostId)) ?? null - this.published = new Map() + if (!this.projecting) { + this.projecting = true + this.projection.clear() this.shown = new Map() - this.projectedPartitions = new Map() - this.dirty = 'all' + this.dirty = true } // A change not yet published reaches existing listeners first, so the snapshot is current. this.flush() this.listeners.add(listener) + this.watch() const snapshot = [...this.shown].map(([key, { layout }]) => ({ key, layout })) return { snapshot, seq: this.seq, + ...(this.epoch ? { epoch: this.epoch } : {}), unsubscribe: () => { if (!this.listeners.delete(listener) || this.listeners.size > 0) { return } - this.stopWatching?.() - this.stopWatching = null - // A flush already queued finds nothing to do. - this.dirty = null + this.projecting = false + // A flush already queued finds nothing to project. + this.dirty = false this.liveTitlesDirty = false - this.published.clear() this.shown.clear() - this.projectedPartitions.clear() + this.watch() } } } + /** + * Each main operation's end once its writes are in, with the partitions it may have written, + * and each task's unscoped writes. Heard with or without a layout subscriber. + */ + onLayoutOperationCommitted(listener: (commit: LayoutOperationCommit) => void): () => void { + this.commitListeners.add(listener) + this.watch() + return () => { + this.commitListeners.delete(listener) + this.watch() + } + } + + /** + * One session partition's loaded model (layout, view and facts), the one the stream publishes + * from: shared while anyone listens (cached until a write may change it), else loaded per call. + * READ-ONLY: the stream and every other reader see the same object; tests freeze it. Null with + * no Store. + */ + readPartition(hostId: ExecutionHostId): LoadedWorkspaceLayout | null { + const loaded = this.projection.loaded(hostId, this.stopWatching !== null) + if (loaded) { + workspaceLayoutPartitionReadObserver()?.(loaded) + } + return loaded + } + /** Some terminal's title may have changed; republishes only the panes whose title did. */ markLiveTitlesDirty(): void { - if (this.listeners.size === 0) { + if (!this.projecting) { return } - this.schedule() this.liveTitlesDirty = true + this.schedule() } /** The position holding every write made so far: a pending flush is sent first. */ @@ -109,38 +178,74 @@ export class WorkspaceLayoutStream { return this.seq } + private watch(): void { + const wanted = this.listeners.size > 0 || this.commitListeners.size > 0 + if (wanted && !this.stopWatching) { + const store = this.deps.store() + const stopWrites = store?.onWorkspaceSessionWritten?.((hostId) => this.markDirty(hostId)) + // Switch point: a window-owned build publishes when the writes' task ends, as before. + const stopOperations = ORCA_LAYOUT_OWNER_IS_MAIN + ? store?.onWorkspaceSessionOperationEnded?.(() => this.flush()) + : undefined + this.stopWatching = + stopWrites || stopOperations + ? () => { + stopWrites?.() + stopOperations?.() + } + : null + } else if (!wanted && this.stopWatching) { + this.stopWatching() + this.stopWatching = null + // Unwatched writes would go unseen: nothing projected may be read again. + this.projection.clear() + } + } + private schedule(): void { - if (this.dirty === null && !this.liveTitlesDirty) { - // Coalesces a burst of writes and title changes in one task into one flush. - queueMicrotask(() => this.flush()) + if (!this.flushQueued) { + this.flushQueued = true + // Coalesces a burst of unscoped writes and title changes in one task into one flush. + queueMicrotask(() => { + this.flushQueued = false + this.flush() + }) } } private markDirty(hostId: ExecutionHostId | null): void { - this.schedule() - if (hostId === null) { - this.dirty = 'all' - } else if (this.dirty === null) { - this.dirty = new Set([hostId]) - } else if (this.dirty !== 'all') { - this.dirty.add(hostId) + this.projection.invalidate(hostId) + this.dirty ||= this.projecting + if (this.commitListeners.size > 0) { + this.touched = addDirty(this.touched, hostId) } + this.schedule() } private flush(): void { - const dirty = this.dirty - if (dirty === null && !this.liveTitlesDirty) { - return + const { dirty, touched } = this + this.touched = null + if (dirty || this.liveTitlesDirty) { + this.dirty = false + this.liveTitlesDirty = false + // A failed projection is logged once; it never reaches the write that notified it. + try { + this.reconcile(dirty) + } catch (error) { + // The cache may be part-updated; the next write re-projects every partition. + this.projection.forgetPartitions() + this.logFailure('projection', error) + } } - this.dirty = null - this.liveTitlesDirty = false - // A failed projection is logged once; it never reaches the write that notified it. - try { - this.reconcile(dirty) - } catch (error) { - // The cache may be part-updated; the next write re-projects every partition. - this.projectedPartitions.clear() - this.logFailure('projection', error) + if (touched !== null) { + const commit = { hostIds: touched === 'all' ? null : [...touched] } + for (const listener of this.commitListeners) { + try { + listener(commit) + } catch (error) { + this.logFailure('commit listener', error) + } + } } } @@ -151,70 +256,6 @@ export class WorkspaceLayoutStream { } } - private projectPartition(hostId: ExecutionHostId): Map { - const projected = new Map() - const session = this.deps.store()?.getWorkspaceSession?.(hostId) - if (!session) { - return projected - } - const { layout } = loadWorkspaceLayoutWithoutReport(hostId, session, nameBasedLoadContext()) - for (const [key, workspace] of Object.entries(layout.workspaces)) { - const published = publishWorkspaceLayout(workspace, hostId) - projected.set(key, { layout: published, json: stableJson(published) }) - } - return projected - } - - private project(dirty: Set | 'all'): Map { - const hostIds = this.deps.store()?.getWorkspaceSessionHostIds?.() ?? [] - const partitions = new Map>() - for (const hostId of hostIds) { - const cached = - dirty === 'all' || dirty.has(hostId) ? undefined : this.projectedPartitions.get(hostId) - partitions.set(hostId, cached ?? this.projectPartition(hostId)) - } - this.projectedPartitions = partitions - const projected = new Map() - const unroutable = new Set() - for (const [hostId, workspaces] of partitions) { - for (const [key, workspace] of workspaces) { - // Only the owning partition's copy is published; a stray copy elsewhere is not shown. - if (this.homeOf(key, unroutable) === hostId) { - projected.set(key, workspace) - } - } - } - for (const key of unroutable) { - const kept = this.published.get(key) - // Kept as last published: a 'removed' frame would make the window drop its tabs. - if (kept && !projected.has(key)) { - projected.set(key, kept) - } - } - return projected - } - - /** - * A workspace the router throws on is not guessed (`repoId::path` repeats across hosts, so a - * default could publish another host's copy); it is noted in `unroutable`. It never stops the rest. - */ - private homeOf(key: string, unroutable: Set): ExecutionHostId | null { - try { - return this.deps.homeHostId(key) - } catch (error) { - // Switch point: a window-owned build keeps failing the whole projection, as today. - if (!ORCA_LAYOUT_OWNER_IS_MAIN) { - throw error - } - unroutable.add(key) - if (!this.routingFailureLogged) { - this.routingFailureLogged = true - console.warn('[workspace-layout] layout stream routing failed:', error) - } - return null - } - } - /** Every pane's live title, in tab and pane order; null for a pane with none. */ private liveTitlesOf(layout: PublishedWorkspaceLayout): (string | null)[] { return layout.tabs.flatMap((tab) => @@ -224,67 +265,102 @@ export class WorkspaceLayoutStream { ) } - private reconcile(dirty: Set | 'all' | null): void { - const next = dirty === null ? this.published : this.project(dirty) - const events: WorkspaceLayoutEvent[] = [] + private reconcile(reproject: boolean): void { + const next = reproject ? this.projection.project() : this.projection.published + const removed: string[] = [] for (const key of this.shown.keys()) { if (!next.has(key)) { - events.push({ type: 'removed', key }) + removed.push(key) } } + const before = this.shown const shown = new Map() + const changed: string[] = [] for (const [key, { layout, json }] of next) { const titles = this.liveTitlesOf(layout) const liveTitles = JSON.stringify(titles) - const previous = this.shown.get(key) + const previous = before.get(key) if (previous?.json === json && previous.liveTitles === liveTitles) { shown.set(key, previous) continue } - const withTitles = withLiveTitles(layout, titles) - shown.set(key, { json, liveTitles, layout: withTitles }) - events.push({ type: 'workspace', key, layout: withTitles }) + shown.set(key, { json, liveTitles, layout: withLiveTitles(layout, titles) }) + changed.push(key) } - this.published = next this.shown = shown + // Switch point: a window-owned build sends each workspace whole, one frame each. + if (!ORCA_LAYOUT_OWNER_IS_MAIN || !this.epoch) { + this.deliverEach([ + ...removed.map((key): WorkspaceLayoutEvent => ({ type: 'removed', key })), + ...changed.map((key): WorkspaceLayoutEvent => ({ + type: 'workspace', + key, + layout: shown.get(key)!.layout + })) + ]) + return + } + // The first subscriber's own projection reaches no one: its snapshot holds it. + if (this.listeners.size === 0) { + return + } + const workspaces: WorkspaceLayoutRecordChange[] = removed.map((key) => ({ + key, + change: 'removed' + })) + for (const key of changed) { + const previous = before.get(key) + const current = shown.get(key)! + const change = previous + ? diffPublishedLayouts(key, recordsOf(previous), recordsOf(current), current.layout) + : { key, change: 'layout' as const, layout: current.layout } + if (change) { + workspaces.push(change) + } + } + if (workspaces.length === 0) { + return + } + const frame = { type: 'records' as const, epoch: this.epoch, seq: ++this.seq, workspaces } + this.deliver(frame) + workspaceLayoutFrameObserver()?.({ + frame, + before: new Map([...before].map(([key, { layout }]) => [key, layout])), + fresh: () => this.freshLayouts(), + titlesOnly: !reproject + }) + } + + private freshLayouts(): Map { + const fresh = new Map() + for (const [key, { layout }] of this.projection.projectFresh()) { + fresh.set(key, withLiveTitles(layout, this.liveTitlesOf(layout))) + } + return fresh + } + + private deliverEach(events: WorkspaceLayoutEvent[]): void { for (const unsequenced of events) { // Numbered as sent, so a failed projection never skips a position a reply could name. - const event = { ...unsequenced, seq: ++this.seq } - for (const listener of this.listeners) { - // One failing listener must not cost the others an event the stream has now published. - try { - listener(event) - } catch (error) { - this.logFailure('listener', error) - } + this.deliver({ ...unsequenced, seq: ++this.seq }) + } + } + + private deliver(event: WorkspaceLayoutStreamEvent): void { + for (const listener of this.listeners) { + // One failing listener must not cost the others an event the stream has now published. + try { + listener(event) + } catch (error) { + this.logFailure('listener', error) } } } } -function withLiveTitles( - layout: PublishedWorkspaceLayout, - titles: (string | null)[] -): PublishedWorkspaceLayout { - if (titles.every((title) => title === null)) { - return layout - } - let index = 0 - return { - ...layout, - tabs: layout.tabs.map((tab) => - tab.terminal - ? { - ...tab, - terminal: { - ...tab.terminal, - panes: tab.terminal.panes.map((pane) => { - const liveTitle = titles[index++] - return liveTitle === null ? pane : { ...pane, liveTitle } - }) - } - } - : tab - ) +function recordsOf(shown: Shown): IndexedLayoutRecords | null { + if (shown.records === undefined) { + shown.records = indexPublishedLayout(shown.layout) } + return shown.records } diff --git a/src/renderer/src/runtime/runtime-layout-client.test.ts b/src/renderer/src/runtime/runtime-layout-client.test.ts index 5ddc9b01a59..6ca85cca462 100644 --- a/src/renderer/src/runtime/runtime-layout-client.test.ts +++ b/src/renderer/src/runtime/runtime-layout-client.test.ts @@ -166,13 +166,12 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta expect(stream.unsubscribed).toBe(false) }) - it('ignores a change before the snapshot, unknown frame types and malformed frames', async () => { + it('ignores a change before the snapshot and unknown frame types', async () => { subscribe() const stream = await opened(1) stream.send({ type: 'workspace', key: 'a', layout: layout('a') }) stream.send(snapshot()) stream.send({ type: 'navigate', request: { focusTab: 't1' } }) - stream.send({ type: 'workspace', key: 'a', layout: { worktreeId: 'a' } }) expect(cache).toEqual({}) expect(stream.unsubscribed).toBe(false) }) @@ -194,13 +193,17 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta const stream = await opened(1) stream.send({ ...snapshot(), seq: 3 }) stream.send({ type: 'navigate', request: {}, seq: 4 }) - stream.send({ type: 'workspace', key: 'a', layout: { worktreeId: 'a' }, seq: 5 }) throwOnFrame = true - expect(() => stream.send({ type: 'removed', key: 'a', seq: 6 })).toThrow('reader failed') + expect(() => stream.send({ type: 'removed', key: 'a', seq: 5 })).toThrow('reader failed') // A frame with no position, as an older host sends, reports none. throwOnFrame = false stream.send({ type: 'removed', key: 'a' }) - expect(positions).toEqual([3, 4, 5, 6]) + expect(positions).toEqual([3, 4, 5]) + // A malformed frame sends the stream back for a snapshot, whose position covers it. + stream.send({ type: 'workspace', key: 'a', layout: { worktreeId: 'a' }, seq: 6 }) + expect(positions).toEqual([3, 4, 5]) + ;(await opened(2)).send({ ...snapshot(), seq: 7 }) + expect(positions).toEqual([3, 4, 5, 7]) }) it.skipIf(target.kind === 'local')( @@ -296,6 +299,174 @@ describe.each(targets)('runtime layout client over the %s transport', (_name, ta }) }) +describe.each(targets)( + 'a records stream over the %s transport that misses a frame', + (_name, target) => { + let host: FakeHost + let cache: WorkspaceLayoutCache + let statuses: RuntimeLayoutStreamStatus[] + + beforeEach(() => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + host = installFakeHost(target) + cache = EMPTY_WORKSPACE_LAYOUT_CACHE + statuses = [] + subscribeRuntimeLayout( + target, + {}, + (frame) => { + cache = applyLayoutFrame(cache, frame) + }, + (next) => statuses.push(next) + ) + }) + + afterEach(() => { + vi.useRealTimers() + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) + + async function opened(count: number): Promise { + await vi.waitFor(() => expect(host.streams).toHaveLength(count)) + await vi.advanceTimersByTimeAsync(0) + return host.streams[count - 1]! + } + + const snapshot = (epoch: string, seq: number, worktreeIds: string[]) => ({ + type: 'snapshot', + subscriptionId: 'layout-1', + epoch, + seq, + workspaces: worktreeIds.map((id) => ({ key: id, layout: layout(id) })) + }) + + /** One operation's frame: workspace `key`'s groups now hold `tabIds`. */ + const regrouped = (epoch: string, seq: number, key: string, tabIds: string[]) => ({ + type: 'records', + epoch, + seq, + workspaces: [ + { + key, + change: 'records', + workspace: { worktreeId: key, groups: [{ id: `group-${key}`, tabIds }] } + } + ] + }) + + it('applies each records frame in sequence to the snapshot', async () => { + const stream = await opened(1) + stream.send(snapshot('epoch-1', 3, ['a', 'b'])) + stream.send(regrouped('epoch-1', 4, 'a', ['t1'])) + stream.send(regrouped('epoch-1', 5, 'b', ['t2'])) + expect(cache).toEqual({ a: layout('a', ['t1']), b: layout('b', ['t2']) }) + expect(stream.unsubscribed).toBe(false) + expect(host.streams).toHaveLength(1) + }) + + it('counts a skipped frame of a newer type as a position, so the next frame is no gap', async () => { + const stream = await opened(1) + stream.send(snapshot('epoch-1', 3, ['a'])) + stream.send({ type: 'navigate', request: {}, seq: 4 }) + stream.send(regrouped('epoch-1', 5, 'a', ['t1'])) + expect(cache).toEqual({ a: layout('a', ['t1']) }) + expect(host.streams).toHaveLength(1) + }) + + it('stops resubscribing at once when every fresh stream fails, and backs off', async () => { + const bad = { type: 'records', epoch: 'epoch-1', seq: 4, workspaces: 'a' } + for (let count = 1; count <= 4; count += 1) { + const stream = await opened(count) + stream.send(snapshot('epoch-1', 3, ['a'])) + stream.send(bad) + } + // Three immediate resubscribes; the fourth failure takes the loss path. + expect(statuses.at(-1)).toEqual({ state: 'lost' }) + expect(host.streams).toHaveLength(4) + await vi.advanceTimersByTimeAsync(999) + expect(host.streams).toHaveLength(4) + await vi.advanceTimersByTimeAsync(1) + expect(host.streams).toHaveLength(target.kind === 'local' ? 5 : 4) + if (target.kind !== 'local') { + return + } + // Still failing: the reopened stream's snapshot keeps the backoff growing. + const fifth = await opened(5) + fifth.send(snapshot('epoch-1', 3, ['a'])) + fifth.send(bad) + await vi.advanceTimersByTimeAsync(1999) + expect(host.streams).toHaveLength(5) + await vi.advanceTimersByTimeAsync(1) + expect(host.streams).toHaveLength(6) + // A clean change ends the storm: the next drop reopens after 1 s again. + const sixth = await opened(6) + sixth.send(snapshot('epoch-1', 3, ['a'])) + sixth.send(regrouped('epoch-1', 4, 'a', ['t1'])) + sixth.drop() + await vi.advanceTimersByTimeAsync(999) + expect(host.streams).toHaveLength(6) + await vi.advanceTimersByTimeAsync(1) + expect(host.streams).toHaveLength(7) + }) + + it('starts the backoff over after one resync, once the stream is healthy again', async () => { + if (target.kind !== 'local') { + return + } + const first = await opened(1) + first.send(snapshot('epoch-1', 3, ['a'])) + first.send(regrouped('epoch-1', 5, 'a', ['t1'])) + const second = await opened(2) + second.send(snapshot('epoch-1', 9, ['a'])) + // Two unrelated drops, each followed by a healthy snapshot, each reopen after 1 s. + for (let count = 3; count <= 4; count += 1) { + host.streams.at(-1)!.drop() + await vi.advanceTimersByTimeAsync(999) + expect(host.streams).toHaveLength(count - 1) + await vi.advanceTimersByTimeAsync(1) + expect(host.streams).toHaveLength(count) + ;(await opened(count)).send(snapshot('epoch-1', 9, ['a'])) + } + }) + + it.each([ + ['an epoch change', regrouped('epoch-2', 4, 'a', ['t1'])], + ['a position that skips one', regrouped('epoch-1', 5, 'a', ['t1'])], + [ + 'an unreadable records frame', + { type: 'records', epoch: 'epoch-1', seq: 4, workspaces: 'a' } + ], + ['records for a workspace never shown', regrouped('epoch-1', 4, 'z', ['t1'])] + ])('resubscribes exactly once after %s and takes the fresh snapshot', async (_case, frame) => { + const first = await opened(1) + first.send(snapshot('epoch-1', 3, ['a', 'b'])) + + first.send(frame) + expect(first.unsubscribed).toBe(true) + const second = await opened(2) + // A frame the dropped stream still delivers changes nothing. + first.send(regrouped('epoch-1', 4, 'a', ['late'])) + second.send(snapshot('epoch-2', 9, ['a'])) + + expect(cache).toEqual({ a: layout('a') }) + second.send(regrouped('epoch-2', 10, 'a', ['t1'])) + expect(cache).toEqual({ a: layout('a', ['t1']) }) + await vi.advanceTimersByTimeAsync(60_000) + expect(host.streams).toHaveLength(2) + expect(second.unsubscribed).toBe(false) + // The window never sees the stream as lost: it resubscribed at once. + expect(statuses.map((status) => status.state)).toEqual([ + 'connecting', + 'live', + 'connecting', + 'live' + ]) + }) + } +) + describe('the local layout stream after a loss', () => { let host: FakeHost let cache: WorkspaceLayoutCache diff --git a/src/renderer/src/runtime/runtime-layout-client.ts b/src/renderer/src/runtime/runtime-layout-client.ts index bc134d7e9ec..62dfaedca3c 100644 --- a/src/renderer/src/runtime/runtime-layout-client.ts +++ b/src/renderer/src/runtime/runtime-layout-client.ts @@ -4,6 +4,7 @@ import { hasRuntimeRpcErrorCode } from '../../../shared/runtime-rpc-error-code' import { + isKnownWorkspaceLayoutFrameType, readWorkspaceLayoutFramePosition, readWorkspaceLayoutStreamFrame, type WorkspaceLayoutChangeFrame, @@ -25,18 +26,24 @@ const REFUSAL_CODES = ['method_not_found', 'forbidden'] as const /** First wait before a lost local stream reopens; it doubles to the cap until a snapshot comes. */ const LOCAL_REOPEN_DELAY_MS = 1_000 const LOCAL_REOPEN_DELAY_CAP_MS = 30_000 +/** Immediate resubscribes allowed before a frame applies cleanly; then the loss path's backoff. */ +const RESYNC_BUDGET = 3 /** * Opens the stream; `onFrame` gets a snapshot first, then that stream's changes. A lost local * stream reports `lost`, then reopens, and its snapshot replaces what the caller holds. A remote * stream that drops reports `lost`; reopening it belongs to the connection's reconnect. + * + * A live stream that misses a frame resubscribes at once for a fresh snapshot: a records frame of + * another epoch or out of sequence, an unreadable frame, or one `onFrame` cannot apply. */ export function subscribeRuntimeLayout( target: RuntimeClientTarget, params: { workspaces?: 'all' | string[] }, onFrame: (frame: WorkspaceLayoutChangeFrame) => void, onStatus: (status: RuntimeLayoutStreamStatus) => void, - /** Each frame's stream position, an unreadable frame's too: a reply may name any frame. */ + /** Each frame's stream position, a skipped frame's too: a reply may name any frame. A frame + * that sends the stream back for a snapshot reports none; the snapshot's position covers it. */ onPosition?: (seq: number) => void ): { close: () => void } { // Bumped whenever a stream is given up, so its late callbacks change nothing. @@ -45,6 +52,13 @@ export function subscribeRuntimeLayout( let state: RuntimeLayoutStreamStatus['state'] = 'connecting' let reopenTimer: ReturnType | null = null let reopenDelay = LOCAL_REOPEN_DELAY_MS + /** The snapshot's epoch and the last position this stream applied, once a host sends epochs. */ + let epoch: string | undefined + let lastSeq: number | undefined + /** Resubscribes since a records frame last applied cleanly. */ + let resyncs = 0 + /** The budget ran out: until a records frame applies cleanly, snapshots keep the backoff. */ + let storm = false const setStatus = (status: RuntimeLayoutStreamStatus): void => { state = status.state @@ -82,6 +96,23 @@ export function subscribeRuntimeLayout( } } + // Before the snapshot the host itself is suspect, so the loss path's backoff applies. + const resync = (streamGeneration: number, reason: unknown): void => { + if (streamGeneration !== generation) { + return + } + // A frame that fails every time must not resubscribe without limit. + if (state !== 'live' || resyncs >= RESYNC_BUDGET) { + storm ||= resyncs >= RESYNC_BUDGET + lose(streamGeneration, reason) + return + } + resyncs += 1 + console.warn('[workspace-layout] layout stream resubscribes:', reason) + release() + void open() + } + const receive = (streamGeneration: number, value: unknown): void => { if (streamGeneration !== generation) { return @@ -91,7 +122,19 @@ export function subscribeRuntimeLayout( } const seq = readWorkspaceLayoutFramePosition(value) try { - deliver(streamGeneration, readWorkspaceLayoutStreamFrame(value)) + const frame = readWorkspaceLayoutStreamFrame(value) + // A newer host's frame type is skipped; a malformed frame of a known type lost a change. + if (frame || isKnownWorkspaceLayoutFrameType(value)) { + deliver(streamGeneration, frame) + } else if ( + state === 'live' && + seq !== undefined && + lastSeq !== undefined && + seq === lastSeq + 1 + ) { + // A skipped frame still holds its position, so the next one is no gap. + lastSeq = seq + } } finally { if (seq !== undefined && streamGeneration === generation) { onPosition?.(seq) @@ -100,8 +143,8 @@ export function subscribeRuntimeLayout( } const deliver = (streamGeneration: number, frame: WorkspaceLayoutStreamFrame | null): void => { - // Null: a newer host's frame type or an unreadable frame; neither may change the cache. if (!frame) { + resync(streamGeneration, new Error('unreadable layout frame')) return } if (frame.type === 'end') { @@ -109,11 +152,34 @@ export function subscribeRuntimeLayout( return } if (frame.type === 'snapshot') { - reopenDelay = LOCAL_REOPEN_DELAY_MS + // During a resync storm the backoff keeps growing; a snapshot alone proves nothing. + if (!storm) { + reopenDelay = LOCAL_REOPEN_DELAY_MS + } + epoch = frame.epoch + lastSeq = frame.seq setStatus({ state: 'live' }) } else if (state !== 'live') { // The host sends the snapshot first; a change before it has nothing to replace. return + } else if (frame.type === 'records') { + if (frame.epoch !== epoch || lastSeq === undefined || frame.seq !== lastSeq + 1) { + resync(streamGeneration, new Error(`layout frame ${frame.seq} after ${lastSeq ?? 'none'}`)) + return + } + lastSeq = frame.seq + try { + onFrame(frame) + } catch (error) { + resync(streamGeneration, error) + return + } + resyncs = 0 + if (storm) { + storm = false + reopenDelay = LOCAL_REOPEN_DELAY_MS + } + return } onFrame(frame) } diff --git a/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts b/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts new file mode 100644 index 00000000000..2eddaa599c1 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-binding.test.ts @@ -0,0 +1,226 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type * as WorkspaceSessionModule from '@/lib/workspace-session' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { localDesktopSession } from '../../../shared/workspace-layout/workspace-layout-profile.test-fixture' +import { + publishWorkspaceLayout, + type PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { + GIT_KEY, + leaf +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' +import type { LayoutFrameSlices } from '../store/workspace-layout-frame-slices' +import { endLayoutFramePositions } from './layout-frame-position' +import { holdTabPaneTree } from './layout-owner-pane-intents' +import { holdDrawnTabs } from './layout-owner-drawn-tab-holds' +import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) +// The rebuild reads the window's session back first: a call means a workspace rebuild ran. +vi.mock('@/lib/workspace-session', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, buildWorkspaceSessionPayload: vi.fn(actual.buildWorkspaceSessionPayload) } +}) + +const stream = vi.hoisted( + (): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null }) +) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: ( + _target: unknown, + _params: unknown, + onFrame: (frame: WorkspaceLayoutChangeFrame) => void + ) => { + stream.onFrame = onFrame + return { close: () => {} } + } +})) + +createStoreSessionMockApi() + +const SLICES: (keyof LayoutFrameSlices)[] = [ + 'tabsByWorktree', + 'terminalLayoutsByTabId', + 'activeTabIdByWorktree', + 'unifiedTabsByWorktree', + 'groupsByWorktree', + 'activeGroupIdByWorktree', + 'layoutByWorktree', + 'openFiles', + 'activeFileIdByWorktree', + 'browserTabsByWorktree', + 'activeBrowserTabIdByWorktree', + 'activeTabTypeByWorktree' +] + +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) + +function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout { + const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext()) + return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local') +} + +/** The frame with one pane's terminal binding changed; absent `ptyId` unbinds it. */ +function rebound( + layout: PublishedWorkspaceLayout, + tabId: string, + leafId: string, + ptyId: string | undefined +): PublishedWorkspaceLayout { + const next = structuredClone(layout) + const pane = next.tabs + .find((tab) => tab.entityId === tabId)! + .terminal!.panes.find((candidate) => candidate.leafId === leafId)! + if (ptyId === undefined) { + delete pane.ptyId + } else { + pane.ptyId = ptyId + } + return next +} + +/** Boots a window shown from the session's frame; each later frame arrives as main sends it. */ +async function boot() { + const store = createTestStore() + const session = localDesktopSession() + await bootWindowFromPartitions(store, [{ hostId: 'local', session }]) + const sync = openWorkspaceLayoutFrameSync(store) + const first = publishedOf(session) + stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: first }] }) + sync.start() + await settle() + vi.mocked(buildWorkspaceSessionPayload).mockClear() + const send = async (layout: PublishedWorkspaceLayout, asSnapshot = false): Promise => { + stream.onFrame!( + asSnapshot + ? { type: 'snapshot', workspaces: [{ key: GIT_KEY, layout }] } + : { type: 'workspace', key: GIT_KEY, layout } + ) + await settle() + } + return { store, sync, first, send } +} + +/** What a rebuild shows for these frames: a second snapshot redraws every workspace. */ +async function rebuiltState(frames: PublishedWorkspaceLayout[]) { + const { store, sync, send } = await boot() + for (const frame of frames) { + await send(frame, true) + } + sync.close() + return store.getState() +} + +const slicesOf = (state: Record) => + Object.fromEntries(SLICES.map((slice) => [slice, state[slice]])) + +afterEach(() => endLayoutFramePositions()) + +describe('a frame that changes only panes’ terminal bindings', () => { + // A respawn of a gone process: its pane is unbound, then bound again. + it.each([ + ['unbinds a pane', [[leaf(1), undefined]]], + [ + 'unbinds then binds the same id again', + [ + [leaf(1), undefined], + [leaf(1), `${GIT_KEY}@@aaaa0001`] + ] + ], + ['binds a new id', [[leaf(1), 'pty-fresh']]], + ['unbinds the focused pane of a split', [[leaf(2), undefined]]] + ] as const)('%s exactly as a rebuild shows it, without one', async (_, steps) => { + const { store, sync, first, send } = await boot() + const frames: PublishedWorkspaceLayout[] = [] + let frame = first + for (const [leafId, ptyId] of steps) { + frame = rebound(frame, 'tab-shell', leafId, ptyId) + frames.push(frame) + await send(frame) + } + const shown = store.getState() + sync.close() + + expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + const rebuilt = await rebuiltState(frames) + expect(buildWorkspaceSessionPayload).toHaveBeenCalled() + expect(slicesOf(shown)).toEqual(slicesOf(rebuilt)) + // Presence: the binding the frame names is the store's. + const [leafId, ptyId] = steps.at(-1)! + expect(shown.terminalLayoutsByTabId['tab-shell']!.ptyIdsByLeafId?.[leafId]).toBe(ptyId) + }) + + it('writes nothing to the store when the store already shows those bindings', async () => { + const { store, sync, first, send } = await boot() + const layout = store.getState().terminalLayoutsByTabId['tab-shell']! + store.setState({ + terminalLayoutsByTabId: { + ...store.getState().terminalLayoutsByTabId, + 'tab-shell': { + ...layout, + ptyIdsByLeafId: { ...layout.ptyIdsByLeafId, [leaf(1)]: 'pty-fresh' } + } + } + }) + const writes = vi.fn() + const stop = store.subscribe(writes) + + await send(rebound(first, 'tab-shell', leaf(1), 'pty-fresh')) + + stop() + expect(writes).not.toHaveBeenCalled() + expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + sync.close() + }) + + it('keeps a tab the window’s own pane command holds as the store has it', async () => { + const { store, sync, first, send } = await boot() + const before = store.getState().terminalLayoutsByTabId['tab-shell'] + const release = holdTabPaneTree('tab-shell') + + await send(rebound(first, 'tab-shell', leaf(1), 'pty-fresh')) + + expect(store.getState().terminalLayoutsByTabId['tab-shell']).toBe(before) + release() + sync.close() + }) + + it('keeps a tab the window drew before main answered as the store has it', async () => { + const { store, sync, first, send } = await boot() + const before = store.getState().terminalLayoutsByTabId['tab-shell'] + const release = holdDrawnTabs([{ workspace: GIT_KEY, ids: ['tab-shell'] }]) + + await send(rebound(first, 'tab-shell', leaf(1), 'pty-fresh')) + + expect(store.getState().terminalLayoutsByTabId['tab-shell']).toBe(before) + release() + sync.close() + }) + + it('still rebuilds a frame that also changes structure', async () => { + const { store, sync, first, send } = await boot() + const renamed = rebound(first, 'tab-shell', leaf(1), 'pty-fresh') + renamed.tabs.find((tab) => tab.entityId === 'tab-shell')!.customTitle = 'renamed' + + await send(renamed) + + expect(buildWorkspaceSessionPayload).toHaveBeenCalled() + expect( + store.getState().tabsByWorktree[GIT_KEY]!.find((tab) => tab.id === 'tab-shell') + ).toMatchObject({ customTitle: 'renamed' }) + sync.close() + }) +}) diff --git a/src/renderer/src/runtime/workspace-layout-frame-bindings.ts b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts new file mode 100644 index 00000000000..5d064da4bf5 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-bindings.ts @@ -0,0 +1,67 @@ +// A frame that changes only its panes' terminal bindings shows without a workspace rebuild: the +// store's pane trees take the frame's bindings, and each tab's focused pane is resolved as the +// rebuild resolves it. Anything only a rebuild shows exactly falls back to one. + +import { resolvePtyBoundActiveLeafId } from '@/components/terminal-pane/terminal-layout-leaf-ids' +import type { AppState } from '@/store/types' +import { layoutContainsLeafId } from '../../../shared/workspace-layout/terminal-pane-tree' +import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { isDrawnTabHeld } from './layout-owner-drawn-tab-holds' +import { isTabPaneTreeHeld } from './layout-owner-pane-intents' + +type TerminalLayouts = AppState['terminalLayoutsByTabId'] + +function sharesATerminal(layout: PublishedWorkspaceLayout): boolean { + const ptyIds = layout.tabs.flatMap((tab) => + (tab.terminal?.panes ?? []).flatMap((pane) => (pane.ptyId ? [pane.ptyId] : [])) + ) + return new Set(ptyIds).size !== ptyIds.length +} + +/** + * `layouts` with the frame's bindings shown, the same object when it already shows them. Null + * when only a rebuild shows them as the rebuild would: a terminal two panes name (restore releases + * one), a pane tree the store lacks or draws otherwise, or a tab the window's own command holds. + */ +export function terminalLayoutsWithFrameBindings( + layouts: TerminalLayouts, + workspace: string, + frame: PublishedWorkspaceLayout +): TerminalLayouts | null { + if (sharesATerminal(frame)) { + return null + } + let next = layouts + for (const tab of frame.tabs) { + const panes = tab.terminal?.panes + if (!panes) { + continue + } + const current = layouts[tab.entityId] + const bound = current?.ptyIdsByLeafId ?? {} + if (panes.every((pane) => bound[pane.leafId] === pane.ptyId)) { + continue + } + const leafIds = new Set(panes.map((pane) => pane.leafId)) + if ( + !current?.root || + panes.some((pane) => !layoutContainsLeafId(current.root, pane.leafId)) || + Object.keys(bound).some((leafId) => !leafIds.has(leafId)) || + isTabPaneTreeHeld(tab.entityId) || + isDrawnTabHeld(workspace, tab.id) || + isDrawnTabHeld(workspace, tab.entityId) + ) { + return null + } + const ptyIdsByLeafId = Object.fromEntries( + panes.flatMap((pane) => (pane.ptyId ? [[pane.leafId, pane.ptyId]] : [])) + ) + const activeLeafId = resolvePtyBoundActiveLeafId({ + root: current.root, + activeLeafId: current.activeLeafId, + ptyIdsByLeafId + }) + next = { ...next, [tab.entityId]: { ...current, ptyIdsByLeafId, activeLeafId } } + } + return next +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-changes.ts b/src/renderer/src/runtime/workspace-layout-frame-changes.ts new file mode 100644 index 00000000000..9c6b124ad1d --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-changes.ts @@ -0,0 +1,77 @@ +// What changed between two frames of one workspace: its structure, only its panes' terminal +// bindings, or only their live titles. Each JSON is kept per frame: title frames arrive at spinner +// rate, and a respawned terminal's binding frames one per restored pane. + +import { shownLiveTitle } from '@/store/workspace-layout-frame-slices' +import type { TerminalTabTitleUpdate } from '@/store/slices/terminal-tab-title-batch' +import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' +import type { + PublishedPane, + PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' + +const structureJsonByLayout = new WeakMap() +const unboundJsonByLayout = new WeakMap() + +function jsonWithoutPaneFields( + layout: PublishedWorkspaceLayout, + strip: (pane: PublishedPane) => object +): string { + return stableJson({ + ...layout, + tabs: layout.tabs.map((tab) => + tab.terminal + ? { ...tab, terminal: { ...tab.terminal, panes: tab.terminal.panes.map(strip) } } + : tab + ) + }) +} + +function cached( + cache: WeakMap, + layout: PublishedWorkspaceLayout, + strip: (pane: PublishedPane) => object +): string { + let json = cache.get(layout) + if (json === undefined) { + json = jsonWithoutPaneFields(layout, strip) + cache.set(layout, json) + } + return json +} + +/** A frame's JSON without live titles. */ +export function structureJson(layout: PublishedWorkspaceLayout): string { + return cached(structureJsonByLayout, layout, ({ liveTitle: _liveTitle, ...pane }) => pane) +} + +/** A frame's JSON without live titles or terminal bindings: a pane's binding is its status. */ +export function unboundStructureJson(layout: PublishedWorkspaceLayout): string { + return cached( + unboundJsonByLayout, + layout, + ({ liveTitle: _liveTitle, ptyId: _ptyId, ptyGeneration: _ptyGeneration, ...pane }) => pane + ) +} + +/** Title changes between two frames of one workspace, for the pane each tab shows. */ +export function liveTitleUpdates( + shown: PublishedWorkspaceLayout, + next: PublishedWorkspaceLayout, + focusedLeafId: (tabId: string) => string | null | undefined +): TerminalTabTitleUpdate[] { + const shownById = new Map(shown.tabs.map((tab) => [tab.id, tab])) + const updates: TerminalTabTitleUpdate[] = [] + for (const tab of next.tabs) { + const shownTab = shownById.get(tab.id) + if (!tab.terminal || !shownTab) { + continue + } + const focused = focusedLeafId(tab.entityId) + const title = shownLiveTitle(tab, focused) + if (title !== undefined && title !== shownLiveTitle(shownTab, focused)) { + updates.push({ tabId: tab.entityId, title }) + } + } + return updates +} diff --git a/src/renderer/src/runtime/workspace-layout-frame-records.test.ts b/src/renderer/src/runtime/workspace-layout-frame-records.test.ts new file mode 100644 index 00000000000..906ca5d4217 --- /dev/null +++ b/src/renderer/src/runtime/workspace-layout-frame-records.test.ts @@ -0,0 +1,156 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import type * as AgentStatusModule from '@/lib/agent-status' +import type * as WorkspaceSessionModule from '@/lib/workspace-session' +import type { WorkspaceSessionState } from '../../../shared/workspace-session-state-types' +import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layout/workspace-layout-load' +import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' +import { + publishWorkspaceLayout, + type PublishedWorkspaceLayout +} from '../../../shared/workspace-layout/workspace-layout-published' +import { + diffPublishedLayouts, + indexPublishedLayout +} from '../../../shared/workspace-layout/workspace-layout-records' +import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' +import { + addWorkspace, + emptySession, + GIT_KEY +} from '../../../shared/workspace-layout/workspace-layout-session.test-fixture' +import { buildWorkspaceSessionPayload } from '@/lib/workspace-session' +import { bootWindowFromPartitions } from '../app-shell/startup-window-view.test-fixture' +import { createStoreSessionMockApi } from '../store/slices/store-session-test-harness' +import { createTestStore } from '../store/slices/store-test-helpers' +import { endLayoutFramePositions } from './layout-frame-position' +import { openWorkspaceLayoutFrameSync } from './workspace-layout-frame-sync' + +vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } })) +vi.mock('@/lib/agent-status', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, detectAgentStatusFromTitle: vi.fn().mockReturnValue(null) } +}) +// The rebuild reads the window's session back first: a call means a workspace rebuild ran. +vi.mock('@/lib/workspace-session', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, buildWorkspaceSessionPayload: vi.fn(actual.buildWorkspaceSessionPayload) } +}) + +const stream = vi.hoisted( + (): { onFrame: ((frame: WorkspaceLayoutChangeFrame) => void) | null } => ({ onFrame: null }) +) +vi.mock('./runtime-layout-client', () => ({ + subscribeRuntimeLayout: ( + _target: unknown, + _params: unknown, + onFrame: (frame: WorkspaceLayoutChangeFrame) => void + ) => { + stream.onFrame = onFrame + return { close: () => {} } + } +})) + +createStoreSessionMockApi() + +const TABS = 100 +const settle = () => new Promise((resolve) => setTimeout(resolve, 0)) +const leafOf = (index: number) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` + +const terminalSpec = (index: number): { id: string; leaves: [string, string][] } => ({ + id: `tab-${index}`, + leaves: [[leafOf(index), `pty-${index}`]] +}) + +function hundredTabs(): WorkspaceSessionState { + return addWorkspace(emptySession(), GIT_KEY, [ + { + id: 'group-a', + tabs: Array.from({ length: TABS }, (_, index) => terminalSpec(index)) + } + ]) +} + +function publishedOf(session: WorkspaceSessionState): PublishedWorkspaceLayout { + const { layout } = loadWorkspaceLayoutWithoutReport('local', session, nameBasedLoadContext()) + return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, 'local') +} + +/** Main's one frame for an operation that turned `before` into `after`. */ +function recordsFrame( + before: PublishedWorkspaceLayout, + after: PublishedWorkspaceLayout +): WorkspaceLayoutChangeFrame { + const change = diffPublishedLayouts( + GIT_KEY, + indexPublishedLayout(before), + indexPublishedLayout(after), + after + ) + return { type: 'records', epoch: 'epoch-1', seq: 1, workspaces: change ? [change] : [] } +} + +async function boot() { + const store = createTestStore() + const session = hundredTabs() + await bootWindowFromPartitions(store, [{ hostId: 'local', session }]) + const sync = openWorkspaceLayoutFrameSync(store) + const first = publishedOf(session) + stream.onFrame!({ type: 'snapshot', workspaces: [{ key: GIT_KEY, layout: first }], seq: 0 }) + sync.start() + await settle() + vi.mocked(buildWorkspaceSessionPayload).mockClear() + const writes = vi.fn() + const stopWrites = store.subscribe(writes) + const send = async (frame: WorkspaceLayoutChangeFrame): Promise => { + stream.onFrame!(frame) + await settle() + } + return { store, first, writes, send, close: () => (stopWrites(), sync.close()) } +} + +afterEach(() => endLayoutFramePositions()) + +describe('a records frame in the window', () => { + it('shows 100 generated titles from one frame in one store update', async () => { + const { store, first, writes, send, close } = await boot() + const titled = structuredClone(first) + titled.tabs.forEach((tab, index) => { + tab.generatedTitle = `Title ${index}` + }) + + await send(recordsFrame(first, titled)) + + expect(writes).toHaveBeenCalledTimes(1) + expect(store.getState().tabsByWorktree[GIT_KEY]!.map((tab) => tab.generatedTitle)).toEqual( + titled.tabs.map((tab) => tab.generatedTitle) + ) + close() + }) + + it('shows a binding record without a rebuild', async () => { + const { store, first, send, close } = await boot() + const rebound = structuredClone(first) + rebound.tabs[7]!.terminal!.panes[0]!.ptyId = 'pty-fresh' + rebound.tabs[7]!.terminal!.panes[0]!.ptyGeneration = 2 + + await send(recordsFrame(first, rebound)) + + expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + expect(store.getState().terminalLayoutsByTabId['tab-7']!.ptyIdsByLeafId).toEqual({ + [leafOf(7)]: 'pty-fresh' + }) + close() + }) + + it('writes nothing for a respawn under the same id: only its generation rose', async () => { + const { first, writes, send, close } = await boot() + const respawned = structuredClone(first) + respawned.tabs[3]!.terminal!.panes[0]!.ptyGeneration = 2 + + await send(recordsFrame(first, respawned)) + + expect(writes).not.toHaveBeenCalled() + expect(buildWorkspaceSessionPayload).not.toHaveBeenCalled() + close() + }) +}) diff --git a/src/renderer/src/runtime/workspace-layout-frame-sync.ts b/src/renderer/src/runtime/workspace-layout-frame-sync.ts index 2a365c811bc..87f038bd86d 100644 --- a/src/renderer/src/runtime/workspace-layout-frame-sync.ts +++ b/src/renderer/src/runtime/workspace-layout-frame-sync.ts @@ -10,7 +10,7 @@ import { useAppStore } from '@/store' import type { AppState } from '@/store/types' import { applyLayoutFrame, EMPTY_WORKSPACE_LAYOUT_CACHE } from '@/store/workspace-layout-cache' import type { WorkspaceLayoutCache } from '@/store/workspace-layout-cache' -import { buildLayoutFrameSlices, shownLiveTitle } from '@/store/workspace-layout-frame-slices' +import { buildLayoutFrameSlices } from '@/store/workspace-layout-frame-slices' import { buildLayoutFrameStorePatch, terminalLayoutsPatch @@ -28,6 +28,12 @@ import { loadWorkspaceLayoutWithoutReport } from '../../../shared/workspace-layo import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' import { nameBasedLoadContext } from '../../../shared/workspace-layout/workspace-layout-minted-ids' import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { + liveTitleUpdates, + structureJson, + unboundStructureJson +} from './workspace-layout-frame-changes' +import { terminalLayoutsWithFrameBindings } from './workspace-layout-frame-bindings' import { beginLayoutFramePositions, endLayoutFramePositions, @@ -64,57 +70,6 @@ export type WorkspaceLayoutFrameSync = LayoutFrameTerminalQueries & { /** A bucket, not a host: the view read-back routes the workspaces a rebuild skips here. */ const OTHER_WORKSPACES: ExecutionHostId = 'runtime:frame-sync-other-workspaces' -const structureJsonByLayout = new WeakMap() - -/** A frame's JSON without live titles, kept per frame: title frames arrive at spinner rate. */ -function structureJson(layout: PublishedWorkspaceLayout): string { - let json = structureJsonByLayout.get(layout) - if (json === undefined) { - json = withoutLiveTitles(layout) - structureJsonByLayout.set(layout, json) - } - return json -} - -function withoutLiveTitles(layout: PublishedWorkspaceLayout): string { - return stableJson({ - ...layout, - tabs: layout.tabs.map((tab) => - tab.terminal - ? { - ...tab, - terminal: { - ...tab.terminal, - panes: tab.terminal.panes.map(({ liveTitle: _liveTitle, ...pane }) => pane) - } - } - : tab - ) - }) -} - -/** Title changes between two frames of one workspace, for the pane each tab shows. */ -function liveTitleUpdates( - shown: PublishedWorkspaceLayout, - next: PublishedWorkspaceLayout, - focusedLeafId: (tabId: string) => string | null | undefined -): TerminalTabTitleUpdate[] { - const shownById = new Map(shown.tabs.map((tab) => [tab.id, tab])) - const updates: TerminalTabTitleUpdate[] = [] - for (const tab of next.tabs) { - const shownTab = shownById.get(tab.id) - if (!tab.terminal || !shownTab) { - continue - } - const focused = focusedLeafId(tab.entityId) - const title = shownLiveTitle(tab, focused) - if (title !== undefined && title !== shownLiveTitle(shownTab, focused)) { - updates.push({ tabId: tab.entityId, title }) - } - } - return updates -} - export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLayoutFrameSync { let cache: WorkspaceLayoutCache = EMPTY_WORKSPACE_LAYOUT_CACHE /** Workspace key → the frame the store shows. */ @@ -188,6 +143,24 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa } } + // Only panes' bindings changed: the pane trees take them, unless only a rebuild shows them so. + const showFrameBindings = (keys: string[]): void => { + const state = store.getState() + let layouts = state.terminalLayoutsByTabId + const unshown = keys.filter((key) => { + const next = terminalLayoutsWithFrameBindings(layouts, key, cache[key]!) + layouts = next ?? layouts + return next === null + }) + const patch = terminalLayoutsPatch(state, layouts) + if (Object.keys(patch).length > 0) { + store.setState(patch) + } + if (unshown.length > 0) { + rebuild(store.getState(), unshown) + } + } + const flush = (): void => { flushQueued = false if (!live) { @@ -197,6 +170,7 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa const drawn = framesDrawWorkspace(state) const nextShown: Record = {} const structural: string[] = [] + const rebound: string[] = [] const titleUpdates: TerminalTabTitleUpdate[] = [] for (const [key, layout] of Object.entries(cache)) { const previous = shown[key] @@ -208,7 +182,11 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa } // Against the store, not `shown`: a held frame leaves `shown` behind the pane trees drawn since. paneTreeKeys.add(key) - if (previous && structureJson(previous) === structureJson(layout)) { + const sameStructure = previous && structureJson(previous) === structureJson(layout) + if ( + sameStructure || + (previous && unboundStructureJson(previous) === unboundStructureJson(layout)) + ) { titleUpdates.push( ...liveTitleUpdates( previous, @@ -216,17 +194,22 @@ export function openWorkspaceLayoutFrameSync(store: FrameSyncStore): WorkspaceLa (tabId) => state.terminalLayoutsByTabId[tabId]?.activeLeafId ) ) - nextShown[key] = layout + if (!sameStructure) { + rebound.push(key) + } } else { structural.push(key) - nextShown[key] = layout } + nextShown[key] = layout } // A workspace main stopped publishing keeps what the window shows; main has nothing to say. shown = nextShown if (structural.length > 0) { rebuild(state, structural) } + if (rebound.length > 0) { + showFrameBindings(rebound) + } if (titleUpdates.length > 0) { store.getState().updateTabTitles(titleUpdates) } diff --git a/src/renderer/src/store/workspace-layout-cache.ts b/src/renderer/src/store/workspace-layout-cache.ts index 00ccee25195..a0808f3f804 100644 --- a/src/renderer/src/store/workspace-layout-cache.ts +++ b/src/renderer/src/store/workspace-layout-cache.ts @@ -4,6 +4,7 @@ import { stableJson } from '../../../shared/workspace-layout/workspace-layout-load-report' import type { PublishedWorkspaceLayout } from '../../../shared/workspace-layout/workspace-layout-published' +import { applyWorkspaceLayoutRecordChange } from '../../../shared/workspace-layout/workspace-layout-records' import type { WorkspaceLayoutChangeFrame } from '../../../shared/workspace-layout/workspace-layout-stream-frames' export type WorkspaceLayoutCache = Readonly> @@ -11,8 +12,10 @@ export type WorkspaceLayoutCache = Readonly = { ...cache } + for (const change of frame.workspaces) { + const layout = applyWorkspaceLayoutRecordChange(next[change.key], change) + if (layout) { + next[change.key] = layout + } else { + delete next[change.key] + } + } + return frame.workspaces.length > 0 ? next : cache + } case 'snapshot': { // Keeps each equal entry, so a reconnect with unchanged data re-renders nothing. const next: Record = {} diff --git a/src/shared/workspace-layout/workspace-layout-disk-fields.ts b/src/shared/workspace-layout/workspace-layout-disk-fields.ts index becc1f773da..e19b4b0de74 100644 --- a/src/shared/workspace-layout/workspace-layout-disk-fields.ts +++ b/src/shared/workspace-layout/workspace-layout-disk-fields.ts @@ -68,7 +68,8 @@ type PartSources = { | Prefixed<'file', keyof LayoutEditorFile> | Prefixed<'browserTab', keyof LayoutBrowserTab> // `$key`: the map key the entry is stored under. - | Prefixed<'leaf', Exclude | '$key'> + // `ptyGeneration` is in memory only: a load starts every pane's count afresh. + | Prefixed<'leaf', Exclude | '$key'> | Prefixed<'leaf.sleeping', keyof LayoutSleepingRecord> | Prefixed<'closedTab', keyof LayoutClosedTab> desktopView: diff --git a/src/shared/workspace-layout/workspace-layout-load-bindings.ts b/src/shared/workspace-layout/workspace-layout-load-bindings.ts index ef75b1cf6d6..d58f7ed5a19 100644 --- a/src/shared/workspace-layout/workspace-layout-load-bindings.ts +++ b/src/shared/workspace-layout/workspace-layout-load-bindings.ts @@ -135,6 +135,11 @@ export function loadLeaves( if (leafId !== storedId) { // A pane given a new id starts unbound: its terminal was started for the old one. delete leaf.ptyId + } else { + const generation = context.ptyGenerationOf?.(leafId) + if (generation !== undefined) { + leaf.ptyGeneration = generation + } } if (Object.keys(leaf).length > 0) { workspace.leaves = { ...workspace.leaves, [leafId]: leaf } diff --git a/src/shared/workspace-layout/workspace-layout-load-types.ts b/src/shared/workspace-layout/workspace-layout-load-types.ts index 740ad8b47cc..229a981ad2a 100644 --- a/src/shared/workspace-layout/workspace-layout-load-types.ts +++ b/src/shared/workspace-layout/workspace-layout-load-types.ts @@ -17,6 +17,8 @@ export type WorkspaceLayoutLoadContext = { mintId: (seed: string) => string /** Mints pane ids (UUIDs) for a repeated pane id or a legacy row's one pane; `seed` as above. */ mintLeafId: (seed: string) => string + /** A live pane's binding generation (main's, in memory); absent when the context has none. */ + ptyGenerationOf?: (leafId: string) => number | undefined } /** A stored session partition, split into the layout and what is kept beside it. */ diff --git a/src/shared/workspace-layout/workspace-layout-model.ts b/src/shared/workspace-layout/workspace-layout-model.ts index 6fdab497e8b..797c6c3864b 100644 --- a/src/shared/workspace-layout/workspace-layout-model.ts +++ b/src/shared/workspace-layout/workspace-layout-model.ts @@ -79,6 +79,8 @@ export type LayoutClosedTab = Omit export type LayoutLeaf = { /** The terminal it shows; absent while unbound. */ ptyId?: string + /** Rises on each pty bind, retire and rollback of this pane in this process. In memory only. */ + ptyGeneration?: number incarnationId?: string /** User pane title. */ title?: string diff --git a/src/shared/workspace-layout/workspace-layout-published.ts b/src/shared/workspace-layout/workspace-layout-published.ts index d4ffdd1d90e..045070a9852 100644 --- a/src/shared/workspace-layout/workspace-layout-published.ts +++ b/src/shared/workspace-layout/workspace-layout-published.ts @@ -24,6 +24,8 @@ export type PublishedPane = { leafId: string /** The terminal the pane shows; absent while unbound. */ ptyId?: string + /** Rises on each pty bind, retire and rollback of the pane in this process (a same-id respawn too). */ + ptyGeneration?: number title?: string /** The title the pane's terminal shows now. Live state, never saved; the tab's title stays each * view's choice, since it follows that view's focused pane. */ @@ -113,7 +115,10 @@ function publishTab( if (tab.kind === 'terminal') { const panes = collectLayoutLeafIdsInOrder(tab.panes.root).map((leafId): PublishedPane => { const leaf = workspace.leaves?.[leafId] - return { leafId, ...(leaf ? pickStoredFields(leaf, ['ptyId', 'title']) : {}) } + return { + leafId, + ...(leaf ? pickStoredFields(leaf, ['ptyId', 'ptyGeneration', 'title']) : {}) + } }) published.terminal = { root: tab.panes.root, diff --git a/src/shared/workspace-layout/workspace-layout-records.test.ts b/src/shared/workspace-layout/workspace-layout-records.test.ts new file mode 100644 index 00000000000..fcc3af8b1ee --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-records.test.ts @@ -0,0 +1,140 @@ +import { describe, expect, it } from 'vitest' +import { LOCAL_EXECUTION_HOST_ID } from '../execution-host' +import { loadWorkspaceLayoutWithoutReport } from './workspace-layout-load' +import { stableJson } from './workspace-layout-load-report' +import { nameBasedLoadContext } from './workspace-layout-minted-ids' +import { localDesktopSession } from './workspace-layout-profile.test-fixture' +import { publishWorkspaceLayout, type PublishedWorkspaceLayout } from './workspace-layout-published' +import { + applyWorkspaceLayoutRecordChange, + diffPublishedLayouts, + indexPublishedLayout, + joinPublishedLayout, + splitPublishedLayout +} from './workspace-layout-records' +import { GIT_KEY, leaf } from './workspace-layout-session.test-fixture' + +function published(): PublishedWorkspaceLayout { + const { layout } = loadWorkspaceLayoutWithoutReport( + LOCAL_EXECUTION_HOST_ID, + localDesktopSession(), + nameBasedLoadContext() + ) + return publishWorkspaceLayout(layout.workspaces[GIT_KEY]!, LOCAL_EXECUTION_HOST_ID) +} + +const diff = (before: PublishedWorkspaceLayout, after: PublishedWorkspaceLayout) => + diffPublishedLayouts(GIT_KEY, indexPublishedLayout(before), indexPublishedLayout(after), after) + +/** `before` with one edit: the frame for it carries what changed and applies back to `after`. */ +function edited(edit: (layout: PublishedWorkspaceLayout) => void) { + const before = published() + const after = structuredClone(before) + edit(after) + const change = diff(before, after)! + const applied = applyWorkspaceLayoutRecordChange(before, change) + return { change, before, after, applied } +} + +const shellPane = (layout: PublishedWorkspaceLayout, leafId: string) => + layout.tabs + .find((tab) => tab.id === 'tab-shell')! + .terminal!.panes.find((pane) => pane.leafId === leafId)! + +describe('a published layout as records', () => { + it('splits and joins back to the same layout, field for field', () => { + const layout = published() + expect(stableJson(joinPublishedLayout(splitPublishedLayout(layout)))).toBe(stableJson(layout)) + }) + + it('keeps a pane’s binding out of its tab’s tree', () => { + const records = splitPublishedLayout(published()) + const tree = records.paneTrees.find((candidate) => candidate.tabId === 'tab-shell')! + expect(tree.panes.every((pane) => !('ptyId' in pane))).toBe(true) + expect(records.bindings).toContainEqual({ leafId: leaf(1), ptyId: `${GIT_KEY}@@aaaa0001` }) + }) + + it('sends nothing when nothing changed', () => { + expect(diff(published(), published())).toBeNull() + }) + + it.each([ + [ + 'a respawn under the same id: one binding record', + (layout: PublishedWorkspaceLayout) => { + shellPane(layout, leaf(1)).ptyGeneration = 2 + }, + { + bindings: { set: [{ leafId: leaf(1), ptyId: `${GIT_KEY}@@aaaa0001`, ptyGeneration: 2 }] } + } + ], + [ + 'a pane unbound: its binding record removed', + (layout: PublishedWorkspaceLayout) => { + delete shellPane(layout, leaf(1)).ptyId + }, + { bindings: { removed: [leaf(1)] } } + ], + [ + 'a generated title: one tab row', + (layout: PublishedWorkspaceLayout) => { + layout.tabs[0]!.generatedTitle = 'Fix the build' + }, + { + tabs: { + set: [expect.objectContaining({ id: 'tab-shell', generatedTitle: 'Fix the build' })] + } + } + ], + [ + 'a tab moved within its group: the workspace fields and the tab order', + (layout: PublishedWorkspaceLayout) => { + layout.groups[0]!.tabIds = layout.groups[0]!.tabIds.toReversed() + layout.tabs = [...layout.tabs.slice(0, 3).toReversed(), ...layout.tabs.slice(3)] + }, + { + workspace: expect.objectContaining({ groups: expect.any(Array) }), + tabs: { order: expect.any(Array) } + } + ] + ])('%s', (_case, edit, expected) => { + const { change, after, applied } = edited(edit) + expect(change).toEqual({ key: GIT_KEY, change: 'records', ...expected }) + expect(stableJson(applied)).toBe(stableJson(after)) + }) + + it('applies a tab closed, with its tree and bindings, back to the layout without it', () => { + const { change, after, applied } = edited((layout) => { + layout.tabs = layout.tabs.filter((tab) => tab.id !== 'tab-shell') + layout.groups[0]!.tabIds = layout.groups[0]!.tabIds.filter((id) => id !== 'tab-shell') + }) + expect(change).toMatchObject({ + tabs: { removed: ['tab-shell'] }, + paneTrees: { removed: ['tab-shell'] }, + bindings: { removed: expect.arrayContaining([leaf(1)]) } + }) + expect(stableJson(applied)).toBe(stableJson(after)) + }) + + it('sends a layout whose records repeat a key whole', () => { + const before = published() + const after = structuredClone(before) + after.browserTabs = [...after.browserTabs, { ...after.browserTabs[0]!, label: 'other' }] + expect(indexPublishedLayout(after)).toBeNull() + expect(diff(before, after)).toEqual({ key: GIT_KEY, change: 'layout', layout: after }) + }) + + it('refuses a change that does not fit what the reader holds', () => { + const { change } = edited((layout) => { + layout.tabs[0]!.customTitle = 'renamed' + }) + expect(() => applyWorkspaceLayoutRecordChange(undefined, change)).toThrow('unknown workspace') + expect(() => + applyWorkspaceLayoutRecordChange(published(), { + key: GIT_KEY, + change: 'records', + tabs: { order: ['tab-shell', 'missing'] } + }) + ).toThrow(/missing/) + }) +}) diff --git a/src/shared/workspace-layout/workspace-layout-records.ts b/src/shared/workspace-layout/workspace-layout-records.ts new file mode 100644 index 00000000000..faa8bdfa940 --- /dev/null +++ b/src/shared/workspace-layout/workspace-layout-records.ts @@ -0,0 +1,310 @@ +// A published workspace layout as the records main keys by its own ids: the workspace's groups, +// each tab's row, each terminal tab's pane tree, each pane's terminal binding, each editor file and +// browser tab. A change frame carries only the records an operation changed (design E1); splitting +// and joining keep every field, so a field added to a published type rides its record unchanged. + +import { editorFileKey } from './workspace-layout-editor-tab-file' +import { stableJson } from './workspace-layout-load-report' +import type { + PublishedBrowserTab, + PublishedEditorFile, + PublishedPane, + PublishedTab, + PublishedTerminal, + PublishedWorkspaceLayout +} from './workspace-layout-published' + +/** The workspace's own fields: its groups (the one tab order) and group tree. */ +export type PublishedWorkspaceFields = Omit< + PublishedWorkspaceLayout, + 'tabs' | 'editorFiles' | 'browserTabs' +> + +/** A tab's row: all but its panes. A terminal tab keeps its creation fields here. */ +export type PublishedTabRow = Omit & { + terminal?: Omit +} + +/** A pane as its tab's tree holds it: all but its terminal binding. */ +export type PublishedTreePane = Omit + +/** A terminal tab's pane tree, keyed by the tab's id. */ +export type PublishedPaneTree = Pick & { + tabId: string + /** In tree order. */ + panes: PublishedTreePane[] +} + +/** A pane's terminal binding, apart from its tree so a respawn changes only this record. */ +export type PublishedPaneBinding = Pick + +type RecordKinds = { + tabs: PublishedTabRow + paneTrees: PublishedPaneTree + bindings: PublishedPaneBinding + editorFiles: PublishedEditorFile + browserTabs: PublishedBrowserTab +} + +type RecordKind = keyof RecordKinds + +export type PublishedLayoutRecords = { workspace: PublishedWorkspaceFields } & { + [Kind in RecordKind]: RecordKinds[Kind][] +} + +/** One kind's changes: `set` adds or replaces by key; `order` is every key in order, sent when it changed. */ +export type LayoutRecordChanges = { set?: T[]; removed?: string[]; order?: string[] } + +export type WorkspaceLayoutRecordChanges = { workspace?: PublishedWorkspaceFields } & { + [Kind in RecordKind]?: LayoutRecordChanges +} + +export type WorkspaceLayoutRecordChange = + | { key: string; change: 'removed' } + /** A workspace new to the stream, or one whose records repeat a key, goes whole. */ + | { key: string; change: 'layout'; layout: PublishedWorkspaceLayout } + | ({ key: string; change: 'records' } & WorkspaceLayoutRecordChanges) + +const RECORD_KEYS: { [Kind in RecordKind]: (record: RecordKinds[Kind]) => string } = { + tabs: (row) => row.id, + paneTrees: (tree) => tree.tabId, + bindings: (binding) => binding.leafId, + editorFiles: (file) => editorFileKey(file), + browserTabs: (tab) => tab.id +} + +export function splitPublishedLayout(layout: PublishedWorkspaceLayout): PublishedLayoutRecords { + const { tabs, editorFiles, browserTabs, ...workspace } = layout + const rows: PublishedTabRow[] = [] + const paneTrees: PublishedPaneTree[] = [] + const bindings: PublishedPaneBinding[] = [] + for (const tab of tabs) { + const { terminal, ...row } = tab + if (!terminal) { + rows.push(row) + continue + } + const { root, panes, chatLeafId, ...creation } = terminal + rows.push({ ...row, terminal: creation }) + paneTrees.push({ + tabId: tab.id, + root, + ...(chatLeafId !== undefined ? { chatLeafId } : {}), + panes: panes.map(({ ptyId, ptyGeneration, ...pane }) => { + if (ptyId !== undefined || ptyGeneration !== undefined) { + bindings.push({ + leafId: pane.leafId, + ...(ptyId !== undefined ? { ptyId } : {}), + ...(ptyGeneration !== undefined ? { ptyGeneration } : {}) + }) + } + return pane + }) + }) + } + return { workspace, tabs: rows, paneTrees, bindings, editorFiles, browserTabs } +} + +/** Throws when a terminal tab's row has no pane tree: the records are not one layout. */ +export function joinPublishedLayout(records: PublishedLayoutRecords): PublishedWorkspaceLayout { + const trees = new Map(records.paneTrees.map((tree) => [tree.tabId, tree])) + const bindings = new Map(records.bindings.map((binding) => [binding.leafId, binding])) + const tabs = records.tabs.map((row): PublishedTab => { + const { terminal, ...rest } = row + if (!terminal) { + return rest + } + const tree = trees.get(row.id) + if (!tree) { + throw new Error(`layout records: terminal tab ${row.id} has no pane tree`) + } + const { tabId: _tabId, panes, ...treeFields } = tree + return { + ...rest, + terminal: { + ...terminal, + ...treeFields, + panes: panes.map((pane) => { + const binding = bindings.get(pane.leafId) + if (!binding) { + return pane + } + const { leafId: _leafId, ...bound } = binding + return { ...pane, ...bound } + }) + } + } + }) + return { + ...records.workspace, + tabs, + editorFiles: records.editorFiles, + browserTabs: records.browserTabs + } +} + +type IndexedKind = { order: string[]; byKey: Map } + +/** A layout's records with each one's JSON, so a later layout diffs record by record. */ +export type IndexedLayoutRecords = { + workspaceJson: string + workspace: PublishedWorkspaceFields +} & { + [Kind in RecordKind]: IndexedKind +} + +function indexKind(records: T[], keyOf: (record: T) => string): IndexedKind | null { + const order: string[] = [] + const byKey = new Map() + for (const record of records) { + const key = keyOf(record) + const json = stableJson(record) + const seen = byKey.get(key) + // A tab in two groups repeats the same row; two records under one key cannot be keyed. + if (seen && seen.json !== json) { + return null + } + byKey.set(key, { record, json }) + order.push(key) + } + return { order, byKey } +} + +/** Null when two different records share a key: that layout only goes whole. */ +export function indexPublishedLayout( + layout: PublishedWorkspaceLayout +): IndexedLayoutRecords | null { + const records = splitPublishedLayout(layout) + const tabs = indexKind(records.tabs, RECORD_KEYS.tabs) + const paneTrees = indexKind(records.paneTrees, RECORD_KEYS.paneTrees) + const bindings = indexKind(records.bindings, RECORD_KEYS.bindings) + const editorFiles = indexKind(records.editorFiles, RECORD_KEYS.editorFiles) + const browserTabs = indexKind(records.browserTabs, RECORD_KEYS.browserTabs) + if (!tabs || !paneTrees || !bindings || !editorFiles || !browserTabs) { + return null + } + return { + workspaceJson: stableJson(records.workspace), + workspace: records.workspace, + tabs, + paneTrees, + bindings, + editorFiles, + browserTabs + } +} + +const sameOrder = (a: string[], b: string[]) => + a.length === b.length && a.every((key, index) => key === b[index]) + +function diffKind( + before: IndexedKind, + after: IndexedKind, + ordered: boolean +): LayoutRecordChanges | undefined { + const set: T[] = [] + for (const [key, entry] of after.byKey) { + if (before.byKey.get(key)?.json !== entry.json) { + set.push(entry.record) + } + } + const removed = [...before.byKey.keys()].filter((key) => !after.byKey.has(key)) + const order = ordered && !sameOrder(before.order, after.order) ? after.order : undefined + if (set.length === 0 && removed.length === 0 && !order) { + return undefined + } + return { + ...(set.length > 0 ? { set } : {}), + ...(removed.length > 0 ? { removed } : {}), + ...(order ? { order } : {}) + } +} + +/** What changed between two published copies of one workspace; null when nothing did. */ +export function diffPublishedLayouts( + key: string, + before: IndexedLayoutRecords | null, + after: IndexedLayoutRecords | null, + layout: PublishedWorkspaceLayout +): WorkspaceLayoutRecordChange | null { + if (!before || !after) { + return { key, change: 'layout', layout } + } + // Trees and bindings are placed by their tabs, so only the other kinds publish an order. + const tabs = diffKind(before.tabs, after.tabs, true) + const paneTrees = diffKind(before.paneTrees, after.paneTrees, false) + const bindings = diffKind(before.bindings, after.bindings, false) + const editorFiles = diffKind(before.editorFiles, after.editorFiles, true) + const browserTabs = diffKind(before.browserTabs, after.browserTabs, true) + const changes: WorkspaceLayoutRecordChanges = { + ...(before.workspaceJson !== after.workspaceJson ? { workspace: after.workspace } : {}), + ...(tabs ? { tabs } : {}), + ...(paneTrees ? { paneTrees } : {}), + ...(bindings ? { bindings } : {}), + ...(editorFiles ? { editorFiles } : {}), + ...(browserTabs ? { browserTabs } : {}) + } + return Object.keys(changes).length > 0 ? { key, change: 'records', ...changes } : null +} + +function applyKind( + records: T[], + changes: LayoutRecordChanges | undefined, + keyOf: (record: T) => string, + ordered: boolean +): T[] { + if (!changes) { + return records + } + const byKey = new Map(records.map((record) => [keyOf(record), record])) + for (const key of changes.removed ?? []) { + byKey.delete(key) + } + for (const record of changes.set ?? []) { + byKey.set(keyOf(record), record) + } + if (!ordered) { + return [...byKey.values()] + } + const order = changes.order ?? records.map(keyOf) + const placed = new Set(order) + if ([...byKey.keys()].some((key) => !placed.has(key))) { + throw new Error('layout records: a record is missing from its order') + } + return order.map((key) => { + const record = byKey.get(key) + if (record === undefined) { + throw new Error(`layout records: order names a missing record ${key}`) + } + return record + }) +} + +/** + * One workspace's change applied to what was last shown for it; undefined once removed. Throws + * when the change does not fit `before` (a record change for a workspace never shown, a broken + * order): the reader's copy is no longer the host's and must be fetched again. + */ +export function applyWorkspaceLayoutRecordChange( + before: PublishedWorkspaceLayout | undefined, + change: WorkspaceLayoutRecordChange +): PublishedWorkspaceLayout | undefined { + if (change.change === 'removed') { + return undefined + } + if (change.change === 'layout') { + return change.layout + } + if (!before) { + throw new Error(`layout records: a change for unknown workspace ${change.key}`) + } + const records = splitPublishedLayout(before) + return joinPublishedLayout({ + workspace: change.workspace ?? records.workspace, + tabs: applyKind(records.tabs, change.tabs, RECORD_KEYS.tabs, true), + paneTrees: applyKind(records.paneTrees, change.paneTrees, RECORD_KEYS.paneTrees, false), + bindings: applyKind(records.bindings, change.bindings, RECORD_KEYS.bindings, false), + editorFiles: applyKind(records.editorFiles, change.editorFiles, RECORD_KEYS.editorFiles, true), + browserTabs: applyKind(records.browserTabs, change.browserTabs, RECORD_KEYS.browserTabs, true) + }) +} diff --git a/src/shared/workspace-layout/workspace-layout-stream-frames.test.ts b/src/shared/workspace-layout/workspace-layout-stream-frames.test.ts index bfef560a9c7..f90432f34f6 100644 --- a/src/shared/workspace-layout/workspace-layout-stream-frames.test.ts +++ b/src/shared/workspace-layout/workspace-layout-stream-frames.test.ts @@ -5,7 +5,10 @@ import { nameBasedLoadContext } from './workspace-layout-minted-ids' import { localDesktopSession } from './workspace-layout-profile.test-fixture' import { publishWorkspaceLayout } from './workspace-layout-published' import { GIT_KEY } from './workspace-layout-session.test-fixture' -import { readWorkspaceLayoutStreamFrame } from './workspace-layout-stream-frames' +import { + isKnownWorkspaceLayoutFrameType, + readWorkspaceLayoutStreamFrame +} from './workspace-layout-stream-frames' function publishedLayout() { const { layout } = loadWorkspaceLayout( @@ -73,4 +76,37 @@ describe('readWorkspaceLayoutStreamFrame', () => { expect(readWorkspaceLayoutStreamFrame(unreadable)).toBeNull() } }) + + it('reads a records frame and a snapshot’s epoch, and refuses records it cannot key', () => { + const layout = JSON.parse(JSON.stringify(publishedLayout())) + const records = { + type: 'records', + epoch: 'epoch-1', + seq: 4, + workspaces: [ + { + key: GIT_KEY, + change: 'records', + bindings: { set: [{ leafId: 'l1', ptyGeneration: 2 }] } + }, + { key: 'b', change: 'layout', layout }, + { key: 'c', change: 'removed' } + ] + } + expect(readWorkspaceLayoutStreamFrame(records)).toEqual(records) + const snapshot = { type: 'snapshot', workspaces: [], seq: 3, epoch: 'epoch-1' } + expect(readWorkspaceLayoutStreamFrame(snapshot)).toEqual(snapshot) + for (const unreadable of [ + { ...records, epoch: undefined }, + { ...records, seq: undefined }, + { ...records, workspaces: [{ key: GIT_KEY, change: 'renamed' }] }, + { ...records, workspaces: [{ key: GIT_KEY, change: 'records', tabs: { removed: [1] } }] }, + { ...records, workspaces: [{ key: GIT_KEY, change: 'layout', layout: {} }] } + ]) { + expect(readWorkspaceLayoutStreamFrame(unreadable)).toBeNull() + // Known but malformed: a reader resubscribes rather than skip it. + expect(isKnownWorkspaceLayoutFrameType(unreadable)).toBe(true) + } + expect(isKnownWorkspaceLayoutFrameType({ type: 'navigate' })).toBe(false) + }) }) diff --git a/src/shared/workspace-layout/workspace-layout-stream-frames.ts b/src/shared/workspace-layout/workspace-layout-stream-frames.ts index e88be8bd5b9..d55e150e5b6 100644 --- a/src/shared/workspace-layout/workspace-layout-stream-frames.ts +++ b/src/shared/workspace-layout/workspace-layout-stream-frames.ts @@ -2,6 +2,7 @@ // one client that reads them. import type { PublishedWorkspaceLayout } from './workspace-layout-published' +import type { WorkspaceLayoutRecordChange } from './workspace-layout-records' /** `seq`: the stream's position after this frame. It rises by one per change frame for the host's * life, so a subscriber to every workspace sees each one. A `layout.apply` reply names the @@ -14,6 +15,21 @@ export type WorkspaceLayoutEvent = ( ) & Sequenced +/** + * A layout-owner host's one frame per operation: the records it changed, workspace by workspace. + * `epoch` names the host's stream for its life; a reader whose epoch differs, or whose last + * position is not `seq - 1`, missed frames and must take a fresh snapshot. + */ +export type WorkspaceLayoutRecordsFrame = { + type: 'records' + epoch: string + seq: number + workspaces: WorkspaceLayoutRecordChange[] +} + +/** What a host's stream hands its subscribers: an older host's per-workspace events, or records. */ +export type WorkspaceLayoutStreamEvent = WorkspaceLayoutEvent | WorkspaceLayoutRecordsFrame + export type WorkspaceLayoutSnapshotEntry = { key: string; layout: PublishedWorkspaceLayout } export type WorkspaceLayoutStreamFrame = @@ -22,8 +38,10 @@ export type WorkspaceLayoutStreamFrame = type: 'snapshot' subscriptionId?: string workspaces: WorkspaceLayoutSnapshotEntry[] + /** A layout-owner host's stream epoch; its records frames carry the same one. */ + epoch?: string } & Sequenced) - | WorkspaceLayoutEvent + | WorkspaceLayoutStreamEvent | { type: 'end' } /** What a reader's cache takes from the stream. */ @@ -64,6 +82,62 @@ function isSnapshotEntry(value: unknown): value is WorkspaceLayoutSnapshotEntry return isRecord(value) && isKey(value.key) && isPublishedWorkspaceLayout(value.layout) } +const RECORD_KINDS = ['tabs', 'paneTrees', 'bindings', 'editorFiles', 'browserTabs'] as const + +const isKeyList = (value: unknown) => + value === undefined || (Array.isArray(value) && value.every(isKey)) + +function isRecordChanges(value: unknown): boolean { + return ( + value === undefined || + (isRecord(value) && + (value.set === undefined || (Array.isArray(value.set) && value.set.every(isRecord))) && + isKeyList(value.removed) && + isKeyList(value.order)) + ) +} + +// Top-level shape only, as for a whole layout: the records' fields are the host's contract. +function isRecordChange(value: unknown): value is WorkspaceLayoutRecordChange { + if (!isRecord(value) || !isKey(value.key)) { + return false + } + switch (value.change) { + case 'removed': + return true + case 'layout': + return isPublishedWorkspaceLayout(value.layout) + case 'records': + return ( + (value.workspace === undefined || + (isRecord(value.workspace) && + typeof value.workspace.worktreeId === 'string' && + Array.isArray(value.workspace.groups))) && + RECORD_KINDS.every((kind) => isRecordChanges(value[kind])) + ) + default: + return false + } +} + +function readRecordsFrame(value: Record): WorkspaceLayoutRecordsFrame | null { + const seq = readWorkspaceLayoutFramePosition(value) + return typeof value.epoch === 'string' && + value.epoch.length > 0 && + seq !== undefined && + Array.isArray(value.workspaces) && + value.workspaces.every(isRecordChange) + ? { type: 'records', epoch: value.epoch, seq, workspaces: value.workspaces } + : null +} + +const FRAME_TYPES: readonly unknown[] = ['snapshot', 'workspace', 'removed', 'records', 'end'] + +/** A type this build reads: a newer host's other types are skipped, never a reason to resync. */ +export function isKnownWorkspaceLayoutFrameType(value: unknown): boolean { + return isRecord(value) && FRAME_TYPES.includes(value.type) +} + /** Null for a malformed frame or a frame type this build does not know (a newer host's). */ export function readWorkspaceLayoutStreamFrame(value: unknown): WorkspaceLayoutStreamFrame | null { if (!isRecord(value)) { @@ -71,9 +145,18 @@ export function readWorkspaceLayoutStreamFrame(value: unknown): WorkspaceLayoutS } switch (value.type) { case 'snapshot': - return Array.isArray(value.workspaces) && value.workspaces.every(isSnapshotEntry) - ? { type: 'snapshot', workspaces: value.workspaces, ...sequenced(value) } + return Array.isArray(value.workspaces) && + value.workspaces.every(isSnapshotEntry) && + (value.epoch === undefined || isKey(value.epoch)) + ? { + type: 'snapshot', + workspaces: value.workspaces, + ...sequenced(value), + ...(isKey(value.epoch) ? { epoch: value.epoch } : {}) + } : null + case 'records': + return readRecordsFrame(value) case 'workspace': return isKey(value.key) && isPublishedWorkspaceLayout(value.layout) ? { type: 'workspace', key: value.key, layout: value.layout, ...sequenced(value) } diff --git a/tests/e2e/helpers/workspace-layout-oracle-feed.ts b/tests/e2e/helpers/workspace-layout-oracle-feed.ts index e5a72879566..69b07d5176e 100644 --- a/tests/e2e/helpers/workspace-layout-oracle-feed.ts +++ b/tests/e2e/helpers/workspace-layout-oracle-feed.ts @@ -11,6 +11,7 @@ import type { WorkspaceLayoutModel } from '../../../src/shared/workspace-layout/workspace-layout-model' import type { PublishedWorkspaceLayout } from '../../../src/shared/workspace-layout/workspace-layout-published' +import { applyWorkspaceLayoutRecordChange } from '../../../src/shared/workspace-layout/workspace-layout-records' import { readWorkspaceLayoutStreamFrame, type WorkspaceLayoutChangeFrame @@ -52,7 +53,12 @@ export class LiveLayoutFeed { this.ready = false return } - this.applyChange(frame) + try { + this.applyChange(frame) + } catch (error) { + this.fail(`layout frame does not apply: ${String(error)}`) + return + } this.frames += 1 this.onFrame(this) } @@ -68,6 +74,17 @@ export class LiveLayoutFeed { return case 'removed': this.workspaces.delete(frame.key) + return + case 'records': + // A layout-owner host's one frame per operation: each workspace's changed records. + for (const change of frame.workspaces) { + const layout = applyWorkspaceLayoutRecordChange(this.workspaces.get(change.key), change) + if (layout) { + this.workspaces.set(change.key, layout) + } else { + this.workspaces.delete(change.key) + } + } } }