fix: terminal quota leak and dead-terminal reuse; error boundary against blank panel
- Terminals were never destroyed when their tab closed (socket drop only
detached listeners), so the per-session quota (3) filled up and new
terminals failed to mount with 'terminal limit reached'. Now the client
sends a {type:'close'} control frame on tab teardown and the host releases
the pty immediately; a bare socket drop (refresh/switch) keeps the process
for a 30s grace period, and a reconnect within it cancels the pending
close. open() also respawns a process whose shell already exited instead
of reusing a dead handle (input sink).
- The sidebar portal renders inside an error boundary: a render failure now
shows a dismissible error strip with the message (console.error with the
component stack) instead of a silent blank panel.
- Tests: +2 real-pty smoke specs (close releases quota + respawn after
exit; reconnect cancels pending close).
This commit is contained in:
@@ -7,6 +7,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import { apply } from '../src/index.ts'
|
||||
import * as git from '../src/git.ts'
|
||||
import { listDirectory } from '../src/fs-tree.ts'
|
||||
import { defaultShell, PtyManager } from '../src/pty-manager.ts'
|
||||
import type { SidebarWebRoute, SidebarWebUpgradeRoute } from '../src/context-types.ts'
|
||||
|
||||
interface FakeContext {
|
||||
@@ -58,6 +59,47 @@ describe('host plugin smoke', () => {
|
||||
expect(branches.names).toContain(branches.current)
|
||||
})
|
||||
|
||||
it('pty manager releases the quota on close and respawns after exit', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
const first = manager.open('s1', 't1', process.cwd(), 80, 24)
|
||||
expect(manager.keysOf('s1')).toHaveLength(1)
|
||||
// Tab-close semantics (close frame): quota released immediately.
|
||||
manager.scheduleClose(first.key, 0)
|
||||
await new Promise(resolve => setTimeout(resolve, 50))
|
||||
expect(manager.keysOf('s1')).toHaveLength(0)
|
||||
// Reopen spawns a fresh process.
|
||||
const second = manager.open('s1', 't1', process.cwd(), 80, 24)
|
||||
expect(second).not.toBe(first)
|
||||
expect(manager.keysOf('s1')).toHaveLength(1)
|
||||
// After the shell exits, a reconnect respawns instead of reusing the dead handle.
|
||||
second.pty.write('exit\r')
|
||||
const deadline = Date.now() + 5000
|
||||
while (!second.exited && Date.now() < deadline) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
expect(second.exited).toBe(true)
|
||||
const third = manager.open('s1', 't1', process.cwd(), 80, 24)
|
||||
expect(third.exited).toBe(false)
|
||||
expect(third).not.toBe(second)
|
||||
} finally {
|
||||
manager.disposeAll()
|
||||
}
|
||||
})
|
||||
|
||||
it('pty manager: a reconnect within the grace period cancels the pending close', async () => {
|
||||
const manager = new PtyManager(defaultShell(), 3)
|
||||
try {
|
||||
const handle = manager.open('s2', 't1', process.cwd(), 80, 24)
|
||||
manager.scheduleClose(handle.key, 200)
|
||||
manager.open('s2', 't1', process.cwd(), 80, 24)
|
||||
await new Promise(resolve => setTimeout(resolve, 400))
|
||||
expect(manager.get(handle.key)).toBeDefined()
|
||||
} 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)
|
||||
|
||||
Reference in New Issue
Block a user