mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
Let Resource Manager End stop orphaned terminals that main is attached to (#34040)
This commit is contained in:
@@ -32,6 +32,7 @@ export function beginTerminalHostFencedTermination(
|
||||
if (request.requireNoAgentOwner && deps.owners.listForPty(request.id).length > 0) {
|
||||
return { accepted: false, result: { outcome: 'refused', reason: 'claimed' } }
|
||||
}
|
||||
// Current mains check viewers themselves; this guards older mains and direct callers.
|
||||
if (request.requireUnattached && session.hasAttachedClients) {
|
||||
return { accepted: false, result: { outcome: 'refused', reason: 'attached' } }
|
||||
}
|
||||
|
||||
@@ -161,7 +161,15 @@ async function stopFencedRendererPty(
|
||||
if (!provider?.killFenced) {
|
||||
return { outcome: 'refused', reason: 'unsupported' }
|
||||
}
|
||||
const result = await provider.killFenced(args)
|
||||
// Main attaches what it spawns, so only main sees viewers; best-effort, a later viewer sees the confirmed End.
|
||||
if (args.requireUnattached && deps.runtime?.hasTerminalViewer(args.id)) {
|
||||
return { outcome: 'refused', reason: 'attached' }
|
||||
}
|
||||
// Without a runtime to ask about viewers, keep the daemon's attached check.
|
||||
const result = await provider.killFenced({
|
||||
...args,
|
||||
requireUnattached: args.requireUnattached && !deps.runtime
|
||||
})
|
||||
if (
|
||||
(result.outcome === 'ended' || result.outcome === 'already_exited') &&
|
||||
ptyIncarnationById.get(args.id) === args.expectedIncarnationId
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { DaemonPtyAdapter } from '../../daemon/daemon-pty-adapter'
|
||||
import { waitFor } from '../../daemon/daemon-pty-adapter-test-harness'
|
||||
import { startLateExitHarness } from './daemon-late-exit-test-fixture'
|
||||
import { ptyIncarnationById } from './provider/ownership-state'
|
||||
import { installPtyKillIpcHandler } from './ipc/renderer-kill'
|
||||
import { shutdownProviderAndDetectExit } from './provider/shutdown-detect'
|
||||
|
||||
const request = (id: string, expectedIncarnationId: string) => ({
|
||||
id,
|
||||
@@ -66,6 +70,108 @@ describe('checked renderer stop route', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('ends a tab-less terminal this run spawned, though main holds its stream', async () => {
|
||||
const harness = await startLateExitHarness()
|
||||
try {
|
||||
await expect(
|
||||
harness.killFenced({
|
||||
...request(harness.id, harness.result.incarnationId ?? 'missing'),
|
||||
requireUnattached: true
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'ended' })
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the daemon attached check when no runtime can answer for viewers', async () => {
|
||||
const harness = await startLateExitHarness()
|
||||
try {
|
||||
installPtyKillIpcHandler({
|
||||
getLocalPtyProviderStartupPromise: () => undefined,
|
||||
shutdownProviderAndDetectExit,
|
||||
rememberSyntheticKillExit: harness.session.rememberSyntheticKillExit,
|
||||
sendPtyExitToRenderer: harness.session.sendPtyExitToRenderer
|
||||
})
|
||||
await expect(
|
||||
harness.killFenced({
|
||||
...request(harness.id, harness.result.incarnationId ?? 'missing'),
|
||||
requireUnattached: true
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'refused', reason: 'attached' })
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('ends a tab-less restart survivor once main attaches it', async () => {
|
||||
const harness = await startLateExitHarness()
|
||||
const previousRun = new DaemonPtyAdapter({
|
||||
socketPath: harness.socketPath,
|
||||
tokenPath: harness.tokenPath
|
||||
})
|
||||
try {
|
||||
const survivorId = 'repo::/tmp/late-exit-audit@@survivor'
|
||||
const survivor = await previousRun.spawn({ cols: 80, rows: 24, sessionId: survivorId })
|
||||
const detached = vi.spyOn(harness.server['attachments'], 'detachClientSessions')
|
||||
await previousRun.disconnectOnly()
|
||||
await waitFor(() => detached.mock.calls.length > 0)
|
||||
await harness.adapter.attach(survivorId)
|
||||
await expect(
|
||||
harness.killFenced({
|
||||
...request(survivorId, survivor.incarnationId ?? 'missing'),
|
||||
requireUnattached: true
|
||||
})
|
||||
).resolves.toEqual({ outcome: 'ended' })
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a tab-less end while a pane or remote view uses the terminal', async () => {
|
||||
const harness = await startLateExitHarness()
|
||||
try {
|
||||
const checked = {
|
||||
...request(harness.id, harness.result.incarnationId ?? 'missing'),
|
||||
requireUnattached: true
|
||||
}
|
||||
const releaseRemote = harness.runtime.registerRemoteTerminalViewSubscriber(harness.id)
|
||||
await expect(harness.killFenced(checked)).resolves.toEqual({
|
||||
outcome: 'refused',
|
||||
reason: 'attached'
|
||||
})
|
||||
releaseRemote()
|
||||
harness.runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo::/tmp/late-exit-audit',
|
||||
title: '',
|
||||
activeLeafId: 'leaf-1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'repo::/tmp/late-exit-audit',
|
||||
leafId: 'leaf-1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: harness.id,
|
||||
paneTitle: ''
|
||||
}
|
||||
]
|
||||
})
|
||||
await expect(harness.killFenced(checked)).resolves.toEqual({
|
||||
outcome: 'refused',
|
||||
reason: 'attached'
|
||||
})
|
||||
expect(harness.subprocess.forceKill).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
await harness.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves operator-close provenance when the physical exit beats the control response', async () => {
|
||||
const harness = await startLateExitHarness()
|
||||
try {
|
||||
|
||||
@@ -82,6 +82,11 @@ export abstract class OrcaRuntimeWithNotifyRemoteTerminalViewPresenceChanged ext
|
||||
return this.terminalViewSubscribers.hasRaw(ptyId)
|
||||
}
|
||||
|
||||
/** A pane or remote view uses this PTY; main's own ingestion stream does not count. */
|
||||
hasTerminalViewer(ptyId: string): boolean {
|
||||
return this.getLeavesForPty(ptyId).length > 0 || this.hasRawTerminalViewSubscriber(ptyId)
|
||||
}
|
||||
|
||||
/** Whether a remote view answers this PTY's queries now. */
|
||||
hasRemoteTerminalViewSubscriber(ptyId: string): boolean {
|
||||
return this.terminalViewSubscribers.hasRemoteAnsweringQueries(ptyId)
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { expect, test } from './helpers/orca-app'
|
||||
import { makeResourceManagerFixtureVisible } from './helpers/resource-manager-visible-fixture'
|
||||
import { waitForSessionReady } from './helpers/store'
|
||||
|
||||
test.use({ seedTestRepo: false })
|
||||
|
||||
test('Resource Manager ends a tab-less terminal this run spawned', async ({
|
||||
orcaPage,
|
||||
electronApp
|
||||
}, testInfo) => {
|
||||
const folderPath = testInfo.outputPath('notes')
|
||||
mkdirSync(folderPath, { recursive: true })
|
||||
await waitForSessionReady(orcaPage)
|
||||
await orcaPage.setViewportSize({ width: 1200, height: 900 })
|
||||
const ptyId = await orcaPage.evaluate(async (folderPath) => {
|
||||
const state = window.__store!.getState()
|
||||
const group = await window.api.projectGroups.create({
|
||||
name: 'Notes',
|
||||
parentPath: folderPath,
|
||||
createdFrom: 'folder-scan'
|
||||
})
|
||||
await state.fetchProjectGroups({ runtimeEnvironmentId: null, throwOnError: true })
|
||||
if (!group) {
|
||||
throw new Error('Could not create project group')
|
||||
}
|
||||
const folder = await state.createFolderWorkspace(
|
||||
{ projectGroupId: group.id, name: 'Notes', folderPath },
|
||||
{ runtimeEnvironmentId: null }
|
||||
)
|
||||
if (!folder) {
|
||||
throw new Error('Could not create folder workspace')
|
||||
}
|
||||
const spawned = await window.api.pty.spawn({
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
cwd: folderPath,
|
||||
worktreeId: `folder:${folder.id}`,
|
||||
initiallyHidden: true
|
||||
})
|
||||
return spawned.id
|
||||
}, folderPath)
|
||||
|
||||
await makeResourceManagerFixtureVisible(electronApp, orcaPage)
|
||||
await orcaPage.getByRole('button', { name: /^Resource Manager,/ }).click()
|
||||
await orcaPage.getByRole('tab', { name: 'PTYs', exact: true }).click()
|
||||
const panel = orcaPage.getByRole('tabpanel', { name: /^PTYs/ })
|
||||
await expect(panel.getByText(/1 without a tab/)).toBeVisible()
|
||||
await panel.getByRole('button', { name: /^End / }).click()
|
||||
const dialog = orcaPage.getByRole('dialog', { name: /^End .*\?$/ })
|
||||
await dialog.getByRole('button', { name: 'End terminal', exact: true }).click()
|
||||
// The dialog closes only after End returns, so the row state below is End's result.
|
||||
await expect(dialog).toBeHidden()
|
||||
await expect(panel.getByRole('button', { name: /^End / })).toHaveCount(0)
|
||||
await expect
|
||||
.poll(async () =>
|
||||
(await orcaPage.evaluate(() => window.api.pty.listSessions({ connectionId: null }))).map(
|
||||
(session) => session.id
|
||||
)
|
||||
)
|
||||
.not.toContain(ptyId)
|
||||
})
|
||||
Reference in New Issue
Block a user