diff --git a/src/main/runtime/mobile-folder-session-mutation-race.test.ts b/src/main/runtime/mobile-folder-session-mutation-race.test.ts index 84b158f4d56..53d6f9b27b0 100644 --- a/src/main/runtime/mobile-folder-session-mutation-race.test.ts +++ b/src/main/runtime/mobile-folder-session-mutation-race.test.ts @@ -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) => { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 13534a7d560..d4aa86c0152 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 1d7fbfd47bb..4602cef5c53 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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))