From 8950e2ae69a7ee800ca14db43e70431b48527339 Mon Sep 17 00:00:00 2001 From: Menghuan1918 Date: Sat, 8 Aug 2026 14:54:40 +0800 Subject: [PATCH] fix: zombie terminals eating the quota; tab drop double-firing into two panes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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). --- src/client/TabBar.tsx | 5 +++++ src/pty-manager.ts | 5 +++++ tests/smoke.spec.ts | 19 +++++++++++++++++++ 3 files changed, 29 insertions(+) diff --git a/src/client/TabBar.tsx b/src/client/TabBar.tsx index 0e3ae80..1a486f6 100644 --- a/src/client/TabBar.tsx +++ b/src/client/TabBar.tsx @@ -64,12 +64,17 @@ export function TabBar(props: {
{ + // 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) diff --git a/src/pty-manager.ts b/src/pty-manager.ts index 0663265..6322a03 100644 --- a/src/pty-manager.ts +++ b/src/pty-manager.ts @@ -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) } diff --git a/tests/smoke.spec.ts b/tests/smoke.spec.ts index 9afd927..789b5cd 100644 --- a/tests/smoke.spec.ts +++ b/tests/smoke.spec.ts @@ -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 {