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 {