fix: zombie terminals eating the quota; tab drop double-firing into two panes

- The per-session terminal quota counted exited zombie handles (shells
  closed or dropped on hosts without the close-frame protocol), so enough
  abandoned terminals permanently blocked new ones — the 'disconnected,
  reconnecting' loop. open() now sweeps the session's exited handles before
  the quota check, and the running instance's leftovers disappear on the
  next restart (disposeAll).
- Dragging a tab onto the strip's background fired the drop TWICE: the
  TabBar handled it (merge into this pane) and the event bubbled to the
  pane root, which ran its edge-zone handler again — producing two splits /
  a duplicated instance (up+down). The strip now stops propagation for
  drag-over and drop.
- Tests: +1 real-pty smoke (exited handle swept, new tab spawns under a
  quota of 1).
This commit is contained in:
Menghuan1918
2026-08-08 14:54:40 +08:00
parent 302bd85e62
commit 8950e2ae69
3 changed files with 29 additions and 0 deletions
+5
View File
@@ -64,12 +64,17 @@ export function TabBar(props: {
<div
className={clsx(css.tabBar, dragOver && css.tabBarDrop)}
onDragOver={(event) => {
// The strip owns drops on itself (merge into this pane); stopping
// propagation keeps the pane root from also running its edge-zone
// handler on the same drop.
event.preventDefault()
event.stopPropagation()
setDragOver(true)
}}
onDragLeave={() => { setDragOver(false) }}
onDrop={(event) => {
event.preventDefault()
event.stopPropagation()
setDragOver(false)
const raw = event.dataTransfer.getData(TAB_DRAG_TYPE)
const payload = parseDrag(raw)
+5
View File
@@ -96,6 +96,11 @@ export class PtyManager {
const existing = this.sessions.get(key)
if (existing !== undefined && !existing.exited) return existing
if (existing !== undefined) this.close(key)
// Zombie cleanup: a session's exited handles (shell closed, tab dropped
// on an old host without the close frame) must not eat the quota.
for (const [candidate, handle] of [...this.sessions]) {
if (handle.sessionId === sessionId && handle.exited) this.close(candidate)
}
if (this.keysOf(sessionId).length >= this.maxPerSession) {
throw new SidebarError('pty-error', `terminal limit reached (${this.maxPerSession}) for this session`, 400)
}
+19
View File
@@ -87,6 +87,25 @@ describe('host plugin smoke', () => {
}
})
it('pty manager: exited zombie handles do not consume the quota', async () => {
const manager = new PtyManager(defaultShell(), 1)
try {
const first = manager.open('s3', 't1', process.cwd(), 80, 24)
first.pty.write('exit\r')
const deadline = Date.now() + 5000
while (!first.exited && Date.now() < deadline) {
await new Promise(resolve => setTimeout(resolve, 100))
}
expect(first.exited).toBe(true)
// Quota is 1; the exited handle is swept, so a NEW tab can still spawn.
const second = manager.open('s3', 't2', process.cwd(), 80, 24)
expect(second.exited).toBe(false)
expect(manager.keysOf('s3')).toHaveLength(1)
} finally {
manager.disposeAll()
}
})
it('pty manager: a reconnect within the grace period cancels the pending close', async () => {
const manager = new PtyManager(defaultShell(), 3)
try {