From c3bbd6fd67b74224d64d25c488b6f92c410fabc2 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:50:02 -0700 Subject: [PATCH] fix(session): clear an emptied field on a runtime partition this window loaded (#33972) * fix(session): clear an emptied field on a runtime partition this window loaded * fix(session): leave hosts with parked contested rows to the shadow Also replaces the new type assertions with checked types. --- .../startup-window-view.test-fixture.ts | 3 +- .../app-shell/use-app-startup-hydration.ts | 7 +- ...kspace-session-host-emptied-fields.test.ts | 64 +++++++++++++++++++ .../lib/workspace-session-host-hydration.ts | 5 +- .../lib/workspace-session-host-persistence.ts | 15 ++++- .../src/lib/workspace-session-host-split.ts | 11 +++- src/renderer/src/store/slices/terminals.ts | 1 + .../src/store/terminals/terminal-contracts.ts | 2 + .../src/store/terminals/terminal-state.ts | 2 + .../workspace-terminal-hydration-patch.ts | 9 ++- .../terminals/workspace-terminal-hydration.ts | 3 + 11 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 src/renderer/src/lib/workspace-session-host-emptied-fields.test.ts diff --git a/src/renderer/src/app-shell/startup-window-view.test-fixture.ts b/src/renderer/src/app-shell/startup-window-view.test-fixture.ts index 15d8ac51824..6c245eb26c9 100644 --- a/src/renderer/src/app-shell/startup-window-view.test-fixture.ts +++ b/src/renderer/src/app-shell/startup-window-view.test-fixture.ts @@ -84,7 +84,8 @@ export async function bootWindowFromPartitions( ...options, runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey, contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions, - contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey + contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey, + hydratedRuntimeSessionHostIds: sessionRead.hydratedRuntimeSessionHostIds }) actions.hydrateTabsSession(sessionRead.session, options) actions.hydrateEditorSession(sessionRead.session, options) diff --git a/src/renderer/src/app-shell/use-app-startup-hydration.ts b/src/renderer/src/app-shell/use-app-startup-hydration.ts index aece548c61b..b25dd44f16d 100644 --- a/src/renderer/src/app-shell/use-app-startup-hydration.ts +++ b/src/renderer/src/app-shell/use-app-startup-hydration.ts @@ -181,11 +181,10 @@ export function useAppStartupHydration(onOnboardingLoaded: (state: OnboardingSta additionalValidWorkspaceKeys: collectFolderWorkspaceKeysFromSession(sessionRead.session) } timeRendererStartupSyncStep('hydrate-session-stores', () => { + // The read's host-ownership fields are named as the hydration options it feeds. actions.hydrateWorkspaceSession(sessionRead.session, { - ...sessionHydrationOptions, - runtimeHostIdByWorkspaceSessionKey: sessionRead.runtimeHostIdByWorkspaceSessionKey, - contestedHostWorkspaceSessions: sessionRead.contestedHostWorkspaceSessions, - contestedPrimaryHostBySessionKey: sessionRead.contestedPrimaryHostBySessionKey + ...sessionRead, + ...sessionHydrationOptions }) actions.hydrateTabsSession(sessionRead.session, sessionHydrationOptions) actions.hydrateEditorSession(sessionRead.session, sessionHydrationOptions) diff --git a/src/renderer/src/lib/workspace-session-host-emptied-fields.test.ts b/src/renderer/src/lib/workspace-session-host-emptied-fields.test.ts new file mode 100644 index 00000000000..57e80007563 --- /dev/null +++ b/src/renderer/src/lib/workspace-session-host-emptied-fields.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { getDefaultWorkspaceSession } from '../../../shared/constants' +import { toRuntimeExecutionHostId, type ExecutionHostId } from '../../../shared/execution-host' +import type { WorkspaceSessionPatch } from '../../../shared/workspace-session-state-types' +import { + patchWorkspaceSessionByHost, + type HostPersistenceState +} from './workspace-session-host-persistence' + +const RUNTIME = toRuntimeExecutionHostId('env-1') +const WT = 'repo-remote::/srv/project' + +// #23914: main merges a patch fieldwise, so a runtime partition the patch omits keeps a closed tab. +async function patchAfterLastBrowserTabCloses( + state: Partial +): Promise>> { + const partitions: Record> = { + local: { browserTabsByWorktree: {}, browserPagesByWorkspace: {} }, + [RUNTIME]: { + browserTabsByWorktree: { [WT]: [{ id: 'bw-1', worktreeId: WT }] }, + browserPagesByWorkspace: { 'bw-1': [{ id: 'p-1', workspaceId: 'bw-1' }] } + } + } + await patchWorkspaceSessionByHost( + { + get: async () => getDefaultWorkspaceSession(), + patch: async (patch: WorkspaceSessionPatch, hostId?: ExecutionHostId) => { + const key = hostId ?? 'local' + partitions[key] = { ...partitions[key], ...patch } + } + }, + { browserTabsByWorktree: {}, browserPagesByWorkspace: {} }, + { + repos: [{ id: 'repo-remote', connectionId: null, executionHostId: RUNTIME }], + worktreesByRepo: {}, + tabsByWorktree: {}, + ...state + } + ).written + return partitions +} + +describe('a patch that empties a field on a runtime host', () => { + it('clears the closed tab from a runtime partition this window loaded', async () => { + const partitions = await patchAfterLastBrowserTabCloses({ + hydratedRuntimeSessionHostIds: [RUNTIME] + }) + expect(partitions[RUNTIME]?.browserTabsByWorktree).toEqual({}) + expect(partitions[RUNTIME]?.browserPagesByWorkspace).toEqual({}) + }) + + it('leaves a runtime partition this window never loaded, or holds parked rows for', async () => { + const unread = await patchAfterLastBrowserTabCloses({}) + expect(unread[RUNTIME]?.browserTabsByWorktree).toEqual({ [WT]: [expect.anything()] }) + + const parked = await patchAfterLastBrowserTabCloses({ + hydratedRuntimeSessionHostIds: [RUNTIME], + contestedHostWorkspaceSessions: { [RUNTIME]: getDefaultWorkspaceSession() } + }) + expect(parked[RUNTIME]?.browserPagesByWorkspace).toEqual({ + 'bw-1': [expect.anything()] + }) + }) +}) diff --git a/src/renderer/src/lib/workspace-session-host-hydration.ts b/src/renderer/src/lib/workspace-session-host-hydration.ts index d8d7ca3414d..d7caa0f3a1c 100644 --- a/src/renderer/src/lib/workspace-session-host-hydration.ts +++ b/src/renderer/src/lib/workspace-session-host-hydration.ts @@ -34,6 +34,8 @@ export type WorkspaceSessionHostRead = { runtimeHostIdByWorkspaceSessionKey: Record contestedHostWorkspaceSessions: HostSessionSlices contestedPrimaryHostBySessionKey: Record + /** Runtime partitions this read actually loaded, so a later write may speak for all of their rows. */ + hydratedRuntimeSessionHostIds: ExecutionHostId[] } const WORKSPACE_SESSION_KEYED_FIELDS = [ @@ -223,7 +225,8 @@ export async function fetchWorkspaceSessionWithRuntimeHostOwners( // Derive owners from surviving merged rows, never parked raw rows, or startup creates runtime placeholders for local winners. runtimeHostIdByWorkspaceSessionKey: buildRuntimeHostIdByWorkspaceSessionKey(merged.slices), contestedHostWorkspaceSessions: shadow, - contestedPrimaryHostBySessionKey: primaryHostBySessionKey + contestedPrimaryHostBySessionKey: primaryHostBySessionKey, + hydratedRuntimeSessionHostIds: [...runtimeHostIds].filter((hostId) => slices[hostId]) } } diff --git a/src/renderer/src/lib/workspace-session-host-persistence.ts b/src/renderer/src/lib/workspace-session-host-persistence.ts index 323ff8f72cc..d79d27a60a1 100644 --- a/src/renderer/src/lib/workspace-session-host-persistence.ts +++ b/src/renderer/src/lib/workspace-session-host-persistence.ts @@ -49,6 +49,7 @@ export type HostPersistenceState = WorkspaceTabOwnerCatalog & { }[] worktreesByRepo: Record restoredRuntimeHostIdByWorkspaceSessionKey?: Record + hydratedRuntimeSessionHostIds?: readonly ExecutionHostId[] /** Entries a co-claimant host lost to the primary of a contested workspace id; written straight * back to their own partition so the primary's write cannot erase them. */ contestedHostWorkspaceSessions?: HostSessionSlices @@ -201,10 +202,22 @@ function splitWorkspaceSessionForWrite( mode: HostSessionWriteMode ): HostSessionSlices { const routing = buildHostSessionRouting(state) + const catalogRuntimeHostIds = new Set(state.repos.map((repo) => getRepoExecutionHostId(repo))) + // A patch replaces fields fieldwise, so a host whose last entry went needs the emptied field. Only a + // partition this window loaded and still catalogs is one its state speaks for in full. + // The contested shadow restores only worktree-keyed rows, so a host holding parked rows keeps today's rule. + const emptyContainerHostIds = + mode === 'patch' + ? (state.hydratedRuntimeSessionHostIds ?? []).filter( + (hostId) => + catalogRuntimeHostIds.has(hostId) && !state.contestedHostWorkspaceSessions?.[hostId] + ) + : [] // Use live tab catalogs for layouts-only patches; payload-only routing loses remote park scrollback in local pruning (#21295). const worktreeIdByTabId = extendWorktreeIdByTabId(buildWorktreeIdByTabId(payload), state) const slices = splitWorkspaceSessionByHost(payload, routing.hostIdByWorktreeId, { - worktreeIdByTabId + worktreeIdByTabId, + emptyContainerHostIds }) attachHostSessionShadow(slices, state.contestedHostWorkspaceSessions, routing.claims, mode) return slices diff --git a/src/renderer/src/lib/workspace-session-host-split.ts b/src/renderer/src/lib/workspace-session-host-split.ts index 7640f575c85..2b46d6691f0 100644 --- a/src/renderer/src/lib/workspace-session-host-split.ts +++ b/src/renderer/src/lib/workspace-session-host-split.ts @@ -129,7 +129,12 @@ function assignKeyedByResolvedWorktree( export function splitWorkspaceSessionByHost( state: WorkspaceSessionState, hostIdByWorktreeId: HostIdByWorktreeId, - options: { worktreeIdByTabId?: Map } = {} + options: { + worktreeIdByTabId?: Map + /** Hosts whose rows this write speaks for in full: a field it carries replaces theirs even when + * none of their workspaces has an entry left, or main's fieldwise patch keeps the stale rows (#23914). */ + emptyContainerHostIds?: readonly ExecutionHostId[] + } = {} ): HostSessionSlices { // Copy only present global own-keys: partial patches must not inject undefined over absent fields; explicit undefined remains intentional. const template = {} as WorkspaceSessionState @@ -167,6 +172,10 @@ export function splitWorkspaceSessionByHost( // Seed explicit empty containers locally so merge preserves their presence. if (ownership !== 'global' && ownership !== 'hostPrivate') { localSlice[field] ??= Array.isArray(value) ? [] : {} + for (const hostId of options.emptyContainerHostIds ?? []) { + const slice = ensureSlice(slices, hostId, templates) + Object.assign(slice, { [field]: slice[field] ?? (Array.isArray(value) ? [] : {}) }) + } } switch (ownership) { case 'global': diff --git a/src/renderer/src/store/slices/terminals.ts b/src/renderer/src/store/slices/terminals.ts index b8e07ca76c0..774912dc5f0 100644 --- a/src/renderer/src/store/slices/terminals.ts +++ b/src/renderer/src/store/slices/terminals.ts @@ -61,6 +61,7 @@ export const createTerminalSlice: StateCreator restoredRuntimeHostIdByWorkspaceSessionKey: {}, contestedHostWorkspaceSessions: {}, contestedPrimaryHostBySessionKey: {}, + hydratedRuntimeSessionHostIds: [], defaultTerminalTabsAppliedByWorktreeId: {}, closedTerminalTabTombstonesByTabId: {}, hydrationSucceeded: false, diff --git a/src/renderer/src/store/terminals/terminal-contracts.ts b/src/renderer/src/store/terminals/terminal-contracts.ts index ef55544ee3d..43a066a5e86 100644 --- a/src/renderer/src/store/terminals/terminal-contracts.ts +++ b/src/renderer/src/store/terminals/terminal-contracts.ts @@ -32,4 +32,6 @@ export type HydrateWorkspaceSessionOptions = { contestedHostWorkspaceSessions?: HostSessionSlices /** Partition each restored session key was read from; omitted leaves the store's copy. */ contestedPrimaryHostBySessionKey?: Record + /** Runtime partitions the cold read loaded; omitted leaves the store's copy. */ + hydratedRuntimeSessionHostIds?: ExecutionHostId[] } & WorkspaceSessionHydrationOptions diff --git a/src/renderer/src/store/terminals/terminal-state.ts b/src/renderer/src/store/terminals/terminal-state.ts index b012993af3e..283fc06d841 100644 --- a/src/renderer/src/store/terminals/terminal-state.ts +++ b/src/renderer/src/store/terminals/terminal-state.ts @@ -99,6 +99,8 @@ export type TerminalState = { contestedHostWorkspaceSessions: HostSessionSlices /** Partition each restored session key was read from, so a write returns its rows there. */ contestedPrimaryHostBySessionKey: Record + /** Runtime partitions the startup read loaded; only these may receive emptied host-keyed fields. */ + hydratedRuntimeSessionHostIds: ExecutionHostId[] defaultTerminalTabsAppliedByWorktreeId: Record closedTerminalTabTombstonesByTabId: ClosedTerminalTabTombstonesByTabId hydrationSucceeded: boolean diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts index ab6910abcf0..f6865d59ecb 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration-patch.ts @@ -35,7 +35,14 @@ export type WorkspaceHydrationPatch = Pick< | 'localOnlyScrollbackByTabId' > & // Only cold reads carry contested-host shadows; scoped hydration must preserve the store’s copy. - Partial> + Partial< + Pick< + AppState, + | 'contestedHostWorkspaceSessions' + | 'contestedPrimaryHostBySessionKey' + | 'hydratedRuntimeSessionHostIds' + > + > export function replaceHydratedRecordKeys( current: Record, diff --git a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts index ce3d7a9bae1..d8105efd859 100644 --- a/src/renderer/src/store/terminals/workspace-terminal-hydration.ts +++ b/src/renderer/src/store/terminals/workspace-terminal-hydration.ts @@ -161,6 +161,9 @@ export function createWorkspaceTerminalHydrationActions( ...(options?.contestedPrimaryHostBySessionKey ? { contestedPrimaryHostBySessionKey: options.contestedPrimaryHostBySessionKey } : {}), + ...(options?.hydratedRuntimeSessionHostIds + ? { hydratedRuntimeSessionHostIds: options.hydratedRuntimeSessionHostIds } + : {}), repos: runtimeSessionPlaceholders.repos, tabsByWorktree, worktreesByRepo,