diff --git a/packages/app/e2e/terminal/terminal-init.spec.ts b/packages/app/e2e/terminal/terminal-init.spec.ts index 87934b66e..18991bf76 100644 --- a/packages/app/e2e/terminal/terminal-init.spec.ts +++ b/packages/app/e2e/terminal/terminal-init.spec.ts @@ -6,6 +6,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes await gotoSession() const terminals = page.locator(terminalSelector) + const tabs = page.locator('#terminal-panel [data-slot="tabs-trigger"]') const opened = await terminals.first().isVisible() if (!opened) { @@ -21,6 +22,7 @@ test("smoke terminal mounts and can create a second tab", async ({ page, gotoSes await page.locator(promptSelector).click() await page.keyboard.press("Control+Alt+T") - await expect(terminals).toHaveCount(2) - await expect(terminals.nth(1).locator("textarea")).toHaveCount(1) + await expect(tabs).toHaveCount(2) + await expect(terminals).toHaveCount(1) + await expect(terminals.first().locator("textarea")).toHaveCount(1) }) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index bd7ab2447..ce811463f 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -540,7 +540,7 @@ export const Terminal = (props: TerminalProps) => { disposed = true if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (sizeTimer !== undefined) clearTimeout(sizeTimer) - if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close() + if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) const finalize = () => { persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup }) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 33421c386..27ea4e6f3 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -67,11 +67,11 @@ export function TerminalPanel() { on( () => terminal.active(), (activeId) => { - if (!activeId || !opened()) return + if (!activeId || !open()) return if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } - focusTerminalById(activeId) + setTimeout(() => focusTerminalById(activeId), 0) }, ), ) @@ -209,21 +209,17 @@ export function TerminalPanel() {
- - {(pty) => ( -
- - terminal.clone(pty.id)} /> - -
+ + {(id) => ( + + {(pty) => ( +
+ terminal.clone(id)} /> +
+ )} +
)} -
+
diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 2dda403e1..33083485b 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -41,13 +41,38 @@ export namespace Pty { const token = (ws: Socket) => { const data = ws.data - if (!data || typeof data !== "object") return + if (data === undefined) return + if (data === null) return + if (typeof data !== "object") return data - const events = (data as { events?: unknown }).events - if (events && typeof events === "object") return events + const id = (data as { connId?: unknown }).connId + if (typeof id === "number" || typeof id === "string") return id + + const href = (data as { href?: unknown }).href + if (typeof href === "string") return href const url = (data as { url?: unknown }).url - if (url && typeof url === "object") return url + if (typeof url === "string") return url + if (url && typeof url === "object") { + const href = (url as { href?: unknown }).href + if (typeof href === "string") return href + return url + } + + const events = (data as { events?: unknown }).events + if (typeof events === "number" || typeof events === "string") return events + if (events && typeof events === "object") { + const id = (events as { connId?: unknown }).connId + if (typeof id === "number" || typeof id === "string") return id + + const id2 = (events as { connection?: unknown }).connection + if (typeof id2 === "number" || typeof id2 === "string") return id2 + + const id3 = (events as { id?: unknown }).id + if (typeof id3 === "number" || typeof id3 === "string") return id3 + + return events + } return data } @@ -210,7 +235,7 @@ export namespace Pty { continue } - if (sub.token !== undefined && token(ws) !== sub.token) { + if (token(ws) !== sub.token) { session.subscribers.delete(ws) continue } diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 1b89a6374..07e86ea97 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -97,4 +97,48 @@ describe("pty", () => { }, }) }) + + test("does not leak output when socket data mutates in-place", async () => { + await using dir = await tmpdir({ git: true }) + + await Instance.provide({ + directory: dir.path, + fn: async () => { + const a = await Pty.create({ command: "cat", title: "a" }) + try { + const outA: string[] = [] + const outB: string[] = [] + + const ctx = { connId: 1 } + const ws = { + readyState: 1, + data: ctx, + send: (data: unknown) => { + outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + }, + close: () => { + // no-op + }, + } + + Pty.connect(a.id, ws as any) + outA.length = 0 + + // Simulate the runtime mutating per-connection data without + // swapping the reference (ws.data stays the same object). + ctx.connId = 2 + ws.send = (data: unknown) => { + outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) + } + + Pty.write(a.id, "AAA\n") + await Bun.sleep(100) + + expect(outB.join("")).not.toContain("AAA") + } finally { + await Pty.remove(a.id) + } + }, + }) + }) })