terminal: keep the pty alive while its tab is open, whatever the view does
The close frame was sent on every unmount, so switching conversations (or refreshing) killed the shell even though the tab was still open; coming back spawned a fresh terminal. Now the unmount close frame only fires when the tab is truly gone (checked against the per-session store state), so a bare unmount hands the process to the host's reconnect grace and the next connect reattaches the SAME shell with its transcript. Closing a tab releases the quota immediately: the socket-open path keeps the close frame, and a new pty.close API covers the socket-down (reconnect loop) case that previously left a 30s zombie holding the quota. The host also records the spawn cwd and respawns when a reconnect carries a different authoritative cwd — the page-load hydrate race can attach the real session cwd after the first connect fell back to the process cwd, and a shell in the wrong directory must not linger.
This commit is contained in:
+37
-6
@@ -119,6 +119,25 @@ describe('host plugin smoke', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: reopening with a different cwd respawns in the new directory', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
const first = manager.open('s4', 't1', process.cwd(), 80, 24)
|
||||
// The hydrate race: the first connect fell back to the process cwd,
|
||||
// the reconnect carries the session's real cwd — the shell must move.
|
||||
const second = manager.open('s4', 't1', '/tmp', 80, 24)
|
||||
expect(second).not.toBe(first)
|
||||
expect(second.cwd).toBe('/tmp')
|
||||
expect(manager.keysOf('s4')).toHaveLength(1)
|
||||
// A same-cwd reconnect reattaches without respawning.
|
||||
const third = manager.open('s4', 't1', '/tmp', 80, 24)
|
||||
expect(third).toBe(second)
|
||||
expect(manager.keysOf('s4')).toHaveLength(1)
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('lists the repository root level', async () => {
|
||||
const listing = await listDirectory(process.cwd(), 1000)
|
||||
expect(listing.entries.some(entry => entry.name === 'src' && entry.isDir)).toBe(true)
|
||||
@@ -148,11 +167,15 @@ describe('session cwd resolution over the API route', () => {
|
||||
return routes.find(route => route.path === '/sidebar/api')!
|
||||
}
|
||||
|
||||
const invoke = async (route: SidebarWebRoute, payload: unknown): Promise<{ ok: boolean; value?: { cwd: string }; error?: { message: string } }> => {
|
||||
const invoke = async (
|
||||
route: SidebarWebRoute,
|
||||
method: string,
|
||||
payload: unknown,
|
||||
): Promise<{ ok: boolean; value?: { cwd: string }; error?: { message: string } }> => {
|
||||
const body = Buffer.from(JSON.stringify(payload))
|
||||
const req = {
|
||||
method: 'POST',
|
||||
url: '/sidebar/api/session.cwd',
|
||||
url: `/sidebar/api/${method}`,
|
||||
headers: { host: '127.0.0.1:3080' },
|
||||
[Symbol.asyncIterator]: async function* () { yield body },
|
||||
} as never
|
||||
@@ -167,14 +190,14 @@ describe('session cwd resolution over the API route', () => {
|
||||
|
||||
it('uses the client summary cwd while the session is detached', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, { sessionId: 's-detached', cwd: '/tmp/summary-cwd' })
|
||||
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: '/tmp/summary-cwd' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value?.cwd).toBe('/tmp/summary-cwd')
|
||||
})
|
||||
|
||||
it('falls back to the process cwd with no summary cwd', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, { sessionId: 's-unknown' })
|
||||
const result = await invoke(route, 'session.cwd', { sessionId: 's-unknown' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value?.cwd).toBe(process.cwd())
|
||||
})
|
||||
@@ -185,15 +208,23 @@ describe('session cwd resolution over the API route', () => {
|
||||
get: (id) => id === 's-attached' ? { header: { cwd: '/attached-cwd' } } : undefined,
|
||||
},
|
||||
})
|
||||
const result = await invoke(route, { sessionId: 's-attached', cwd: '/tmp/summary-cwd' })
|
||||
const result = await invoke(route, 'session.cwd', { sessionId: 's-attached', cwd: '/tmp/summary-cwd' })
|
||||
expect(result.ok).toBe(true)
|
||||
expect(result.value?.cwd).toBe('/attached-cwd')
|
||||
})
|
||||
|
||||
it('rejects a non-absolute client cwd', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, { sessionId: 's-detached', cwd: 'relative/path' })
|
||||
const result = await invoke(route, 'session.cwd', { sessionId: 's-detached', cwd: 'relative/path' })
|
||||
expect(result.ok).toBe(false)
|
||||
expect(result.error?.message).toMatch(/invalid working directory/)
|
||||
})
|
||||
|
||||
it('pty.close releases a terminal key (and rejects a missing tab)', async () => {
|
||||
const route = mount()
|
||||
const result = await invoke(route, 'pty.close', { sessionId: 's-pty', tab: 't1' })
|
||||
expect(result.ok).toBe(true)
|
||||
const missing = await invoke(route, 'pty.close', { sessionId: 's-pty' })
|
||||
expect(missing.ok).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
+23
-1
@@ -3,7 +3,8 @@ import { compareEntries, parentOf, rootLabel, requireAbsolute } from '../src/fs-
|
||||
import { parseLogLines, parsePorcelainZ } from '../src/git.ts'
|
||||
import {
|
||||
activateTab, closeTab, makeDefaultState, moveTab, moveTabToEdge, openTab,
|
||||
resizeSplit, sanitizeState, splitPane, toggleExpanded, type SidebarState, type SplitNode,
|
||||
resizeSplit, sanitizeState, splitPane, tabOpenIn, toggleExpanded,
|
||||
type SidebarState, type SplitNode,
|
||||
} from '../src/client/state.ts'
|
||||
import { extOf, languageKeyForExt } from '../src/client/lang.ts'
|
||||
import { producedForClosing, resolveSidebarPath, selectProducedFiles } from '../src/client/produced-files.ts'
|
||||
@@ -175,6 +176,27 @@ describe('sidebar state', () => {
|
||||
const after = activateTab(s, leaf.id, tabId)
|
||||
expect((after.splits as { active: string | null }).active).toBe(tabId)
|
||||
})
|
||||
|
||||
it('tabOpenIn: a tab is open until it is truly closed, wherever it lives', () => {
|
||||
let s = state()
|
||||
const leaf = s.splits as { id: string; tabs: { id: string }[] }
|
||||
const explorerId = leaf.tabs[0]!.id
|
||||
expect(tabOpenIn(s, explorerId)).toBe(true)
|
||||
// Moving the tab to another pane keeps it open.
|
||||
s = splitPane(s, 'row')
|
||||
const split = s.splits as Extract<SplitNode, { kind: 'split' }>
|
||||
const paneA = split.children[0] as { id: string; tabs: { id: string }[] }
|
||||
const paneB = split.children[1] as { id: string }
|
||||
s = moveTab(s, paneA.id, explorerId, paneB.id)
|
||||
expect(tabOpenIn(s, explorerId)).toBe(true)
|
||||
// Closing it removes it from the whole tree.
|
||||
const target = s.splits as { id: string; tabs: { id: string }[] }
|
||||
s = closeTab(s, target.id, explorerId)
|
||||
expect(tabOpenIn(s, explorerId)).toBe(false)
|
||||
// A terminal tab added later is open too.
|
||||
s = openTab(s, { id: 'terminal:9', type: 'terminal', title: 'Terminal 9' })
|
||||
expect(tabOpenIn(s, 'terminal:9')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pty helpers', () => {
|
||||
|
||||
Reference in New Issue
Block a user