fix(mobile): reattach restored session terminals

This commit is contained in:
Brennan Benson
2026-08-01 00:58:04 -07:00
parent 72010c7311
commit c96b5a6029
3 changed files with 205 additions and 6 deletions
@@ -24,8 +24,7 @@ vi.mock('electron', () => electronMocks)
const TAB_ID = '11111111-1111-4111-8111-111111111111'
const LEAF_ID = '22222222-2222-4222-8222-222222222222'
function makeSession(worktreeId: string): WorkspaceSessionState {
const ptyId = `${worktreeId}@@race-pty`
function makeSession(worktreeId: string, ptyId = `${worktreeId}@@race-pty`): WorkspaceSessionState {
return {
...getDefaultWorkspaceSession(),
activeWorktreeId: worktreeId,
@@ -59,8 +58,12 @@ function makeSession(worktreeId: string): WorkspaceSessionState {
}
}
function createRuntime(folders: FolderWorkspace[], group: ProjectGroup): OrcaRuntimeService {
const session = makeSession(`folder:${folders[0]!.id}`)
function createRuntime(
folders: FolderWorkspace[],
group: ProjectGroup,
ptyId?: string
): OrcaRuntimeService {
const session = makeSession(`folder:${folders[0]!.id}`, ptyId)
return new OrcaRuntimeService({
getRepo: () => undefined,
getRepos: () => [],
@@ -86,6 +89,104 @@ function createRuntime(folders: FolderWorkspace[], group: ProjectGroup): OrcaRun
}
describe('mobile folder session mutation races', () => {
it('reattaches an inventory-restored serve terminal before folder input is writable', async () => {
const ptyId = 'serve-restarted-folder-pty'
const folder: FolderWorkspace = {
id: 'restored-folder',
projectGroupId: 'restored-folder-group',
name: 'Restored folder',
folderPath: '/workspace/restored-folder',
connectionId: null,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 1,
createdAt: 1,
updatedAt: 1
}
const group: ProjectGroup = {
id: folder.projectGroupId,
name: folder.projectGroupId,
parentPath: folder.folderPath,
connectionId: null,
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
createdAt: 1,
updatedAt: 1
}
const folderKey = `folder:${folder.id}`
const incarnationId = '33333333-3333-4333-8333-333333333333'
const runtime = createRuntime([folder], group, ptyId)
vi.spyOn(
runtime as unknown as {
resolveTerminalWorkspaceLaunchScope: () => Promise<{
id: string
path: string
connectionId: null
repo: null
folderWorkspace: FolderWorkspace
}>
},
'resolveTerminalWorkspaceLaunchScope'
).mockResolvedValue({
id: folderKey,
path: folder.folderPath,
connectionId: null,
repo: null,
folderWorkspace: folder
})
let attached = false
const spawn = vi.fn(async () => {
attached = true
return { id: ptyId, incarnationId }
})
runtime.setPtyController({
spawn,
write: () => attached,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{
id: ptyId,
cwd: folder.folderPath,
title: 'Restored folder terminal',
worktreeId: folderKey,
terminalHandle: 'term_restarted_folder',
incarnationId
}
]
})
const listed = await runtime.listMobileSessionTabs(`id:${folderKey}`)
const listedTab = listed.tabs[0]
expect(listedTab).toMatchObject({ status: 'ready', terminal: 'term_restarted_folder' })
await expect(
runtime.sendTerminal(listedTab?.type === 'terminal' ? listedTab.terminal! : 'missing', {
text: 'before attach'
})
).rejects.toThrow('terminal_not_writable')
const activated = await runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID)
const activatedTab = activated.tabs[0]
await expect(
runtime.sendTerminal(activatedTab?.type === 'terminal' ? activatedTab.terminal! : 'missing', {
text: 'folder input'
})
).resolves.toMatchObject({ accepted: true, bytesWritten: 12 })
await runtime.activateMobileSessionTab(`id:${folderKey}`, TAB_ID)
expect(spawn).toHaveBeenCalledOnce()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({ sessionId: ptyId, worktreeId: folderKey })
)
})
it.each(['activate', 'close'] as const)(
'rejects a folder tab %s when deletion races PTY inventory',
async (operation) => {
+96 -1
View File
@@ -28259,7 +28259,88 @@ describe('OrcaRuntimeService', () => {
})
})
it('reattaches an inventory-restored serve terminal once before accepting input', async () => {
const ptyId = 'serve-restarted-git-pty'
const incarnationId = '91919191-9191-4919-8919-919191919191'
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
[TEST_WORKTREE_ID]: [
{
id: 'host-tab',
ptyId,
worktreeId: TEST_WORKTREE_ID,
title: 'Restored Git Terminal',
customTitle: null,
color: null,
sortOrder: 0,
createdAt: 1
}
]
},
terminalLayoutsByTabId: {
'host-tab': makeHeadlessTerminalLayout({ [HEADLESS_LEAF_ID]: ptyId })
},
terminalPtyIncarnationsByPaneKey: {
[makePaneKey('host-tab', HEADLESS_LEAF_ID)]: incarnationId
}
})
)
let attached = false
const spawn = vi.fn(async () => {
attached = true
return { id: ptyId, incarnationId }
})
const runtime = new OrcaRuntimeService(runtimeStore as never)
runtime.setPtyController({
spawn,
write: () => attached,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => [
{
id: ptyId,
cwd: TEST_WORKTREE_PATH,
title: 'Restored Git Terminal',
worktreeId: TEST_WORKTREE_ID,
terminalHandle: 'term_restarted_git',
incarnationId
}
]
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
const listed = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
const listedTab = listed.tabs[0]
expect(listedTab).toMatchObject({ status: 'ready', terminal: 'term_restarted_git' })
await expect(
runtime.sendTerminal(listedTab?.type === 'terminal' ? listedTab.terminal! : 'missing', {
text: 'before attach'
})
).rejects.toThrow('terminal_not_writable')
const activated = await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
const activatedTab = activated.tabs[0]
await expect(
runtime.sendTerminal(activatedTab?.type === 'terminal' ? activatedTab.terminal! : 'missing', {
text: 'after attach'
})
).resolves.toMatchObject({ accepted: true, bytesWritten: 12 })
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
expect(spawn).toHaveBeenCalledOnce()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: ptyId,
tabId: 'host-tab',
leafId: HEADLESS_LEAF_ID,
worktreeId: TEST_WORKTREE_ID
})
)
})
it('reattaches hydrated SSH headless terminals with the persisted relay identity', async () => {
const incarnationId = '92929292-9292-4929-8929-929292929292'
const { runtimeStore } = makeRuntimeStoreWithWorkspaceSession(
makeWorkspaceSessionWithHeadlessTerminal({
tabsByWorktree: {
@@ -28280,6 +28361,9 @@ describe('OrcaRuntimeService', () => {
'host-tab': makeHeadlessTerminalLayout({
[HEADLESS_LEAF_ID]: 'ssh:ssh-1@@relay-pty'
})
},
terminalPtyIncarnationsByPaneKey: {
[makePaneKey('host-tab', HEADLESS_LEAF_ID)]: incarnationId
}
})
)
@@ -28296,12 +28380,23 @@ describe('OrcaRuntimeService', () => {
write: () => true,
kill: () => true,
getForegroundProcess: async () => null,
listProcesses: async () => []
listProcesses: async () => [
{
id: 'ssh:ssh-1@@relay-pty',
cwd: TEST_WORKTREE_PATH,
title: 'Remote Terminal',
worktreeId: TEST_WORKTREE_ID,
terminalHandle: 'term_restarted_ssh',
incarnationId
}
]
})
runtime.syncWindowGraph(0, { tabs: [], leaves: [] })
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
await runtime.activateMobileSessionTab(`id:${TEST_WORKTREE_ID}`, 'host-tab')
expect(spawn).toHaveBeenCalledOnce()
expect(spawn).toHaveBeenCalledWith(
expect.objectContaining({
connectionId: 'ssh-1',
+4 -1
View File
@@ -8090,9 +8090,12 @@ export class OrcaRuntimeService {
// their tab id, so focusing the renderer would silently no-op.
// Phone-local activation also needs this path for inactive restored tabs:
// desktop focus is intentionally suppressed, but the PTY still must exist.
// PTY inventory proves liveness, not attachment to this runtime generation.
const shouldMaterializePendingTerminal =
publicTab?.type === 'terminal' &&
publicTab.status !== 'ready' &&
(publicTab.status !== 'ready' ||
(this.hasServeOrSshOwnedBinding(tab) &&
!this.hasLiveRuntimeSessionOwnedPtyBinding(worktreeId, tab))) &&
(!targetsHost ||
!this.notifier?.focusTerminal ||
this.shouldMaterializeHeadlessMobileSessionTab(snapshot!, tab))