Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions src/main/ipc/pty-pane-claim-arbitration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ describe('registerPtyHandlers', () => {
leafId,
ptyId: expect.any(String),
incarnationId: expect.any(String),
hostAdmittedMembership: true,
mayCreate: false,
origin: 'spawn'
})
})
Expand Down Expand Up @@ -519,7 +519,7 @@ describe('registerPtyHandlers', () => {
leafId,
ptyId: 'pty-shared',
startupCwd: '/tmp',
hostAdmittedMembership: true,
mayCreate: false,
origin: 'spawn'
})
})
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/pty-pane-reservation-settlement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,7 +542,7 @@ describe('registerPtyHandlers', () => {
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-1@@relay-pty',
hostAdmittedMembership: true,
mayCreate: false,
origin: 'spawn'
},
'ssh:ssh-1'
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/pty-runtime-ssh-binding-persistence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -202,7 +202,7 @@ describe('registerPtyHandlers', () => {
tabId: 'tab-remote',
leafId,
ptyId: 'ssh:ssh-reattach-ok@@relay-pty',
hostAdmittedMembership: true,
mayCreate: false,
origin: 'reattach'
},
'ssh:ssh-reattach-ok'
Expand Down
2 changes: 1 addition & 1 deletion src/main/ipc/pty-spawn-placement-threading.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ describe('pty spawn placement threading', () => {
leafId: LEAF,
ptyId: expect.any(String),
incarnationId: expect.any(String),
hostAdmittedMembership: true,
mayCreate: false,
placement: SPLIT,
origin: 'spawn'
})
Expand Down
3 changes: 2 additions & 1 deletion src/main/ipc/pty/runtime/spawn-commit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,8 @@ async function commitReservedRuntimePtySpawn(ctx: RuntimePtySpawnState) {
tabId,
leafId,
ptyId: ctx.result.id,
hostAdmittedMembership: true,
// The runtime wrote the pane before spawning; a pane gone by now was closed meanwhile.
mayCreate: false,
...(ctx.result.incarnationId ? { incarnationId: ctx.result.incarnationId } : {}),
...(ctx.cwd ? { startupCwd: ctx.cwd } : {}),
...(expectedSourceBinding ? { expectedSourceBinding } : {}),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,23 @@ function persistedTabIds(session: WorkspaceSessionState, worktreeId: string): st
return (session.tabsByWorktree?.[worktreeId] ?? []).map((tab) => tab.id)
}

describe('host-admitted terminal membership survives a stale renderer replay', () => {
/** `orca terminal create`: the runtime writes the pane, then binds the terminal it started. */
async function startRuntimePane(
store: Awaited<ReturnType<typeof createStore>>,
pane: { worktreeId: string; tabId: string; leafId: string; ptyId: string; incarnationId?: string }
): Promise<boolean> {
expect(
await store.admitTerminalPane({
type: 'createTerminalTab',
workspace: pane.worktreeId,
tabId: pane.tabId,
leafId: pane.leafId
})
).toBe('admitted')
return store.persistPtyBinding({ ...pane, mayCreate: false })
}

describe('runtime-admitted terminal membership survives a stale renderer replay', () => {
beforeEach(() => {
testState.dir = mkdtempSync(join(tmpdir(), 'orca-host-membership-'))
})
Expand All @@ -57,38 +73,42 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
rmSync(testState.dir, { recursive: true, force: true })
})

it('keeps the first host-admitted tab when the renderer replays its pre-create tab list', async () => {
it('keeps the first runtime-admitted tab when the renderer replays its pre-create tab list', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())

// `orca terminal create`: the host mints a tab the renderer has never seen.
expect(
await store.persistPtyBinding({
await startRuntimePane(store, {
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
hostAdmittedMembership: true
ptyId: 'host-pty'
})
).toBe(true)
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab')
const session = store.getWorkspaceSession()
expect(persistedTabIds(session, WORKTREE)).toContain('host-tab')
// Written with its tab-bar entry and group, so no view reads a row the tab bar lacks.
expect(session.unifiedTabs?.[WORKTREE]?.map((tab) => tab.entityId)).toContain('host-tab')
expect(session.tabGroups?.[WORKTREE]?.flatMap((group) => group.tabOrder)).toContain('host-tab')
expect(session.terminalLayoutsByTabId['host-tab']?.ptyIdsByLeafId).toEqual({
[TEST_LEAF_2]: 'host-pty'
})

// The renderer's debounced writer flushes a snapshot taken before the create.
store.setWorkspaceSession(rendererSession())

expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toContain('host-tab')
})

it('keeps a host-admitted tab in a second worktree of the same repo', async () => {
it('keeps a runtime-admitted tab in a second worktree of the same repo', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())

await store.persistPtyBinding({
await startRuntimePane(store, {
worktreeId: OTHER_WORKTREE,
tabId: 'host-tab-other',
leafId: TEST_LEAF_2,
ptyId: 'host-pty-other',
hostAdmittedMembership: true
ptyId: 'host-pty-other'
})
store.setWorkspaceSession(rendererSession())

Expand All @@ -100,12 +120,11 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
store.setWorkspaceSession(rendererSession())

expect(
await store.persistPtyBinding({
await startRuntimePane(store, {
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
hostAdmittedMembership: true
ptyId: 'host-pty'
})
).toBe(true)
expect(store.getWorkspaceSession().defaultTerminalTabsAppliedByWorktreeId?.[WORKTREE]).toBe(
Expand All @@ -118,9 +137,8 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
)
})

// Polarity: without the flag the renderer still owns membership, so a renderer
// spawn racing its own writer must not freeze the tab list.
it('leaves renderer-owned membership alone when the binding is not host-admitted', async () => {
// Polarity: a renderer spawn racing its own writer must not freeze the tab list.
it('leaves renderer-owned membership alone when the runtime did not admit the pane', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())

Expand All @@ -138,16 +156,15 @@ describe('host-admitted terminal membership survives a stale renderer replay', (
// Closing must still work afterwards. Closes are host-driven: the retirement is
// computed from the store's own session (see stageTerminalSurfaceRetirements),
// which is what outranks the fence this create just raised.
it('still lets the authoritative retirement path close the host-admitted tab', async () => {
it('still lets the authoritative retirement path close the runtime-admitted tab', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
await store.persistPtyBinding({
await startRuntimePane(store, {
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
incarnationId: 'host-incarnation',
hostAdmittedMembership: true
incarnationId: 'host-incarnation'
})

store.setWorkspaceSession(
Expand All @@ -164,4 +181,76 @@ describe('host-admitted terminal membership survives a stale renderer replay', (

expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab'])
})

it('refuses to bind a pane closed while its terminal started, and mints no tab for it', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
expect(
await store.admitTerminalPane({
type: 'createTerminalTab',
workspace: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2
})
).toBe('admitted')
await store.withdrawTerminalPane({
type: 'closePane',
workspace: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2
})

expect(
await store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: 'host-tab',
leafId: TEST_LEAF_2,
ptyId: 'host-pty',
mayCreate: false
})
).toBe(false)
expect(persistedTabIds(store.getWorkspaceSession(), WORKTREE)).toEqual(['renderer-tab'])
})

it('refuses to bind a split pane that moved to another tab while its terminal started', async () => {
const store = await createStore()
store.setWorkspaceSession(rendererSession())
expect(
await store.admitTerminalPane({
type: 'splitPane',
workspace: WORKTREE,
tabId: 'renderer-tab',
leafId: TEST_LEAF_1,
direction: 'vertical',
newLeafId: TEST_LEAF_2
})
).toBe('admitted')
await expect(
store.moveTerminalLeafToNewTab({
worktreeId: WORKTREE,
sourceTabId: 'renderer-tab',
targetTabId: 'moved-tab',
leafId: TEST_LEAF_2,
ptyId: null
})
).resolves.toMatchObject({ status: 'moved' })

expect(
await store.persistPtyBinding({
worktreeId: WORKTREE,
tabId: 'renderer-tab',
leafId: TEST_LEAF_2,
ptyId: 'late-pty',
mayCreate: false
})
).toBe(false)
const session = store.getWorkspaceSession()
expect(session.terminalLayoutsByTabId['renderer-tab']?.root).toEqual({
type: 'leaf',
leafId: TEST_LEAF_1
})
expect(
session.terminalLayoutsByTabId['moved-tab']?.ptyIdsByLeafId?.[TEST_LEAF_2]
).toBeUndefined()
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import type {
ProfileStateDomainReplacement
} from './profile-state-authority'
import { Store } from './store'
import { toSshExecutionHostId } from '../../../shared/execution-host'

export function deferred<T>() {
let resolve!: (value: T) => void
Expand Down Expand Up @@ -147,3 +148,20 @@ export async function fixture(legacyOpenCodeGoApiKey?: string) {
}
return { store, authority, readState }
}

/** The runtime writes a pane before it spawns; a runtime spawn commit binds only that pane. */
export async function admitRuntimeSpawnPane(
store: Store,
pane: { worktreeId: string; tabId: string; leafId: string },
connectionId?: string | null
): Promise<void> {
await store.admitTerminalPane(
{
type: 'createTerminalTab',
workspace: pane.worktreeId,
tabId: pane.tabId,
leafId: pane.leafId
},
connectionId ? toSshExecutionHostId(connectionId) : undefined
)
}
58 changes: 47 additions & 11 deletions src/main/persistence/loading-store/pty-binding-persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,20 @@ import type {
TerminalLeafMoveRequest,
TerminalLeafMoveResult
} from '../../../shared/terminal-leaf-move'
import { moveLeaf } from '../terminal-topology/terminal-topology-commit'
import {
admitPane,
moveLeaf,
withdrawPane,
type TerminalTopologyCommitContext
} from '../terminal-topology/terminal-topology-commit'
import type {
TerminalPaneAdmission,
TerminalPaneAdmissionOutcome
} from '../terminal-topology/terminal-pane-admission'
import type {
CommandOf,
LayoutRefusalCode
} from '../../../shared/workspace-layout/workspace-layout-command-types'
import { findTerminalBindingConflict } from '../../../shared/workspace-layout/terminal-owner-invariants'

type PtyBindingPersistenceOperationsRuntime = Pick<
Expand All @@ -46,8 +59,6 @@ export type PersistPtyBindingArgs = {
startupCwd?: string
expectedBinding?: { ptyId: string; incarnationId?: string }
expectedSourceBinding?: PtyBindingSourceExpectation
/** Set by host-initiated creates, which have no renderer session writer behind them. */
hostAdmittedMembership?: boolean
/**
* Defaults true, which is what `pty:spawn` needs — it can beat the debounced layout writer
* and must be able to mint the surface it is binding. A reattach is the opposite: the pane
Expand Down Expand Up @@ -217,16 +228,41 @@ export class PtyBindingPersistenceOperations {
* the binding domain only for its runtime and partition access; the commit module owns the write.
*/
moveTerminalLeafToNewTab(request: TerminalLeafMoveRequest): Promise<TerminalLeafMoveResult> {
const { runtime, sessions } = this[ptyBindingPersistenceOperationsContext]
return runtime.runDurableMutation(
moveLeaf(request, {
state: runtime.state,
hostIds: () => sessions.getWorkspaceSessionHostIds(),
getSession: (hostId) => sessions.getWorkspaceSession(hostId),
markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain)
})
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
moveLeaf(request, topologyCommitContext(this))
)
}

/** Writes a runtime-started pane before its terminal starts; the commit module owns the write. */
admitTerminalPane(
admission: TerminalPaneAdmission,
hostId?: string | null
): Promise<TerminalPaneAdmissionOutcome> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
admitPane(resolveHostId(hostId), admission, topologyCommitContext(this))
)
}

withdrawTerminalPane(
pane: CommandOf<'closePane'>,
hostId?: string | null
): Promise<LayoutRefusalCode | null> {
return this[ptyBindingPersistenceOperationsContext].runtime.runDurableMutation(
withdrawPane(resolveHostId(hostId), pane, topologyCommitContext(this))
)
}
}

function topologyCommitContext(
owner: PtyBindingPersistenceOperations
): TerminalTopologyCommitContext {
const { runtime, sessions } = owner[ptyBindingPersistenceOperationsContext]
return {
state: runtime.state,
hostIds: () => sessions.getWorkspaceSessionHostIds(),
getSession: (hostId) => sessions.getWorkspaceSession(hostId),
markDirty: (domain) => runtime.dirtyProfileStateDomains?.add(domain)
}
}

function writePtyBinding(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,7 @@ const SCENARIOS: Scenario[] = [
leafId: TEST_LEAF_1,
ptyId: 'pty-new',
incarnationId: 'inc-new',
startupCwd: '/fixture/local/sub',
hostAdmittedMembership: true
startupCwd: '/fixture/local/sub'
},
placements: (leaf) => [
[NEW_TAB, 'agrees'],
Expand Down Expand Up @@ -110,8 +109,7 @@ const SCENARIOS: Scenario[] = [
worktreeId: FOLDER_WORKTREE,
tabId: 'tab-folder',
leafId: TEST_LEAF_1,
ptyId: 'pty-folder',
hostAdmittedMembership: true
ptyId: 'pty-folder'
},
placements: () => [
[NEW_TAB, 'agrees'],
Expand Down
Loading
Loading