From d07f09925fae3dd0eac245b1817ace5eee19f0aa Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 19 Feb 2026 06:35:14 -0600 Subject: [PATCH] fix(app): terminal rework (#14217) --- packages/app/src/components/terminal.tsx | 58 +++++++++------- .../app/src/pages/session/terminal-panel.tsx | 60 +++++++++++++---- packages/opencode/src/pty/index.ts | 67 +++++++++++-------- packages/opencode/src/server/routes/pty.ts | 13 ++-- .../test/pty/pty-output-isolation.test.ts | 46 ------------- patches/ghostty-web@0.3.0.patch | 40 ----------- 6 files changed, 124 insertions(+), 160 deletions(-) delete mode 100644 patches/ghostty-web@0.3.0.patch diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 085a79613..bd7ab2447 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -320,8 +320,6 @@ export const Terminal = (props: TerminalProps) => { const mod = loaded.mod const g = loaded.ghostty - const once = { value: false } - const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restoreSize = restore && @@ -416,20 +414,28 @@ export const Terminal = (props: TerminalProps) => { cleanups.push(() => window.removeEventListener("resize", handleResize)) } - if (restore && restoreSize) { - t.write(restore, () => { - fit.fit() - scheduleSize(t.cols, t.rows) - if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) - startResize() + const write = (data: string) => + new Promise((resolve) => { + if (!output) { + resolve() + return + } + output.push(data) + output.flush(resolve) }) + + if (restore && restoreSize) { + await write(restore) + fit.fit() + scheduleSize(t.cols, t.rows) + if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) + startResize() } else { fit.fit() scheduleSize(t.cols, t.rows) if (restore) { - t.write(restore, () => { - if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) - }) + await write(restore) + if (typeof local.pty.scrollY === "number") t.scrollToLine(local.pty.scrollY) } startResize() } @@ -438,38 +444,32 @@ export const Terminal = (props: TerminalProps) => { // console.log("Scroll position:", ydisp) // }) + const once = { value: false } + let closing = false + const url = new URL(sdk.url + `/pty/${local.pty.id}/connect`) url.searchParams.set("directory", sdk.directory) url.searchParams.set("cursor", String(start !== undefined ? start : local.pty.buffer ? -1 : 0)) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" url.username = server.current?.http.username ?? "" url.password = server.current?.http.password ?? "" + const socket = new WebSocket(url) socket.binaryType = "arraybuffer" ws = socket - cleanups.push(() => { - if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close() - }) - if (disposed) { - cleanup() - return - } const handleOpen = () => { local.onConnect?.() scheduleSize(t.cols, t.rows) } socket.addEventListener("open", handleOpen) - cleanups.push(() => socket.removeEventListener("open", handleOpen)) - if (socket.readyState === WebSocket.OPEN) handleOpen() const decoder = new TextDecoder() - const handleMessage = (event: MessageEvent) => { if (disposed) return + if (closing) return if (event.data instanceof ArrayBuffer) { - // WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }). const bytes = new Uint8Array(event.data) if (bytes[0] !== 0) return const json = decoder.decode(bytes.subarray(1)) @@ -491,20 +491,20 @@ export const Terminal = (props: TerminalProps) => { cursor += data.length } socket.addEventListener("message", handleMessage) - cleanups.push(() => socket.removeEventListener("message", handleMessage)) const handleError = (error: Event) => { if (disposed) return + if (closing) return if (once.value) return once.value = true console.error("WebSocket error:", error) local.onConnectError?.(error) } socket.addEventListener("error", handleError) - cleanups.push(() => socket.removeEventListener("error", handleError)) const handleClose = (event: CloseEvent) => { if (disposed) return + if (closing) return // Normal closure (code 1000) means PTY process exited - server event handles cleanup // For other codes (network issues, server restart), trigger error handler if (event.code !== 1000) { @@ -514,7 +514,15 @@ export const Terminal = (props: TerminalProps) => { } } socket.addEventListener("close", handleClose) - cleanups.push(() => socket.removeEventListener("close", handleClose)) + + cleanups.push(() => { + closing = true + socket.removeEventListener("open", handleOpen) + socket.removeEventListener("message", handleMessage) + socket.removeEventListener("error", handleError) + socket.removeEventListener("close", handleClose) + if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000) + }) } void run().catch((err) => { diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 33421c386..73f61ab05 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -38,9 +38,34 @@ export function TerminalPanel() { const [store, setStore] = createStore({ autoCreated: false, + everOpened: false, activeDraggable: undefined as string | undefined, }) + const rendered = createMemo(() => isDesktop() && (opened() || store.everOpened)) + + createEffect( + on(open, (isOpen, prev) => { + if (isOpen) { + if (!store.everOpened) setStore("everOpened", true) + const activeId = terminal.active() + if (!activeId) return + if (document.activeElement instanceof HTMLElement) { + document.activeElement.blur() + } + setTimeout(() => focusTerminalById(activeId), 0) + return + } + + if (!prev) return + const panel = document.getElementById("terminal-panel") + const activeElement = document.activeElement + if (!panel || !(activeElement instanceof HTMLElement)) return + if (!panel.contains(activeElement)) return + activeElement.blur() + }), + ) + createEffect(() => { if (!opened()) { setStore("autoCreated", false) @@ -67,7 +92,7 @@ export function TerminalPanel() { on( () => terminal.active(), (activeId) => { - if (!activeId || !opened()) return + if (!activeId || !open()) return if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() } @@ -133,23 +158,32 @@ export function TerminalPanel() { } return ( - +
- + + + | ArrayBuffer) => void + send: (data: string | Uint8Array | ArrayBuffer) => void close: (code?: number, reason?: string) => void } - // Bun's ServerWebSocket has a per-connection `.data` object (set during - // `server.upgrade`) that changes when the underlying connection is recycled. - // We keep a reference to a stable part of it so output can't leak even when - // websocket objects are reused. - const token = (ws: Socket) => { - const data = ws.data - const events = (data as { events?: unknown }).events - if (events && typeof events === "object") return events - - const url = (data as { url?: unknown }).url - if (url && typeof url === "object") return url - - return data + type Subscriber = { + id: number } - // WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }). + const sockets = new WeakMap() + const owners = new WeakMap() + let socketCounter = 0 + + const tagSocket = (ws: Socket) => { + if (!ws || typeof ws !== "object") return + const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER) + sockets.set(ws, next) + return next + } + + // WebSocket control frame: 0x00 + UTF-8 JSON. const meta = (cursor: number) => { const json = JSON.stringify({ cursor }) const bytes = encoder.encode(json) @@ -102,7 +101,7 @@ export namespace Pty { buffer: string bufferCursor: number cursor: number - subscribers: Map + subscribers: Map } const state = Instance.state( @@ -185,13 +184,13 @@ export namespace Pty { ptyProcess.onData((chunk) => { session.cursor += chunk.length - for (const [ws, data] of session.subscribers) { + for (const [ws, sub] of session.subscribers) { if (ws.readyState !== 1) { session.subscribers.delete(ws) continue } - if (token(ws) !== data) { + if (typeof ws === "object" && sockets.get(ws) !== sub.id) { session.subscribers.delete(ws) continue } @@ -280,6 +279,25 @@ export namespace Pty { } log.info("client connected to session", { id }) + const socketId = tagSocket(ws) + if (socketId === undefined) { + ws.close() + return + } + + const previous = owners.get(ws) + if (previous && previous !== id) { + state().get(previous)?.subscribers.delete(ws) + } + + owners.set(ws, id) + session.subscribers.set(ws, { id: socketId }) + + const cleanup = () => { + session.subscribers.delete(ws) + if (owners.get(ws) === id) owners.delete(ws) + } + const start = session.bufferCursor const end = session.cursor @@ -300,6 +318,7 @@ export namespace Pty { ws.send(data.slice(i, i + BUFFER_CHUNK)) } } catch { + cleanup() ws.close() return } @@ -308,23 +327,17 @@ export namespace Pty { try { ws.send(meta(end)) } catch { + cleanup() ws.close() return } - - if (!ws.data || typeof ws.data !== "object") { - ws.close() - return - } - - session.subscribers.set(ws, token(ws)) return { onMessage: (message: string | ArrayBuffer) => { session.process.write(String(message)) }, onClose: () => { log.info("client disconnected from session", { id }) - session.subscribers.delete(ws) + cleanup() }, } } diff --git a/packages/opencode/src/server/routes/pty.ts b/packages/opencode/src/server/routes/pty.ts index d516859f7..368c9612b 100644 --- a/packages/opencode/src/server/routes/pty.ts +++ b/packages/opencode/src/server/routes/pty.ts @@ -163,18 +163,13 @@ export const PtyRoutes = lazy(() => type Socket = { readyState: number - data: object - send: (data: string | Uint8Array | ArrayBuffer) => void + send: (data: string | Uint8Array | ArrayBuffer) => void close: (code?: number, reason?: string) => void } const isSocket = (value: unknown): value is Socket => { if (!value || typeof value !== "object") return false if (!("readyState" in value)) return false - if (!("data" in value)) return false - if (!((value as { data?: unknown }).data && typeof (value as { data?: unknown }).data === "object")) { - return false - } if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false return typeof (value as { readyState?: unknown }).readyState === "number" @@ -182,12 +177,12 @@ export const PtyRoutes = lazy(() => return { onOpen(_event, ws) { - const raw = ws.raw - if (!isSocket(raw)) { + const socket = ws.raw + if (!isSocket(socket)) { ws.close() return } - handler = Pty.connect(id, raw, cursor) + handler = Pty.connect(id, socket, cursor) }, onMessage(event) { if (typeof event.data !== "string") return diff --git a/packages/opencode/test/pty/pty-output-isolation.test.ts b/packages/opencode/test/pty/pty-output-isolation.test.ts index 337280d18..b80d37345 100644 --- a/packages/opencode/test/pty/pty-output-isolation.test.ts +++ b/packages/opencode/test/pty/pty-output-isolation.test.ts @@ -18,7 +18,6 @@ describe("pty", () => { const ws = { readyState: 1, - data: { events: { connection: "a" } }, send: (data: unknown) => { outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) }, @@ -31,7 +30,6 @@ describe("pty", () => { Pty.connect(a.id, ws as any) // Now "reuse" the same ws object for another connection. - ws.data = { events: { connection: "b" } } ws.send = (data: unknown) => { outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) } @@ -53,48 +51,4 @@ describe("pty", () => { }, }) }) - - test("does not leak output when Bun recycles websocket objects before re-connect", 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 ws = { - readyState: 1, - data: { events: { connection: "a" } }, - send: (data: unknown) => { - outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8")) - }, - close: () => { - // no-op (simulate abrupt drop) - }, - } - - // Connect "a" first. - Pty.connect(a.id, ws as any) - outA.length = 0 - - // Simulate Bun reusing the same websocket object for another connection - // before the new onOpen handler has a chance to tag it. - ws.data = { events: { connection: "b" } } - 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) - } - }, - }) - }) }) diff --git a/patches/ghostty-web@0.3.0.patch b/patches/ghostty-web@0.3.0.patch deleted file mode 100644 index d63a693b8..000000000 --- a/patches/ghostty-web@0.3.0.patch +++ /dev/null @@ -1,40 +0,0 @@ -diff --git a/dist/ghostty-web.js b/dist/ghostty-web.js -index 7c9d64a617bbeb29d757a1acd54686e582868313..2d61098cdb77fa66cbb162897c5590f35cfcf791 100644 ---- a/dist/ghostty-web.js -+++ b/dist/ghostty-web.js -@@ -1285,7 +1285,7 @@ const e = class H { - continue; - } - const C = g.getCodepoint(); -- C === 0 || C < 32 ? B.push(" ") : B.push(String.fromCodePoint(C)); -+ C === 0 || C < 32 || C > 1114111 || (C >= 55296 && C <= 57343) ? B.push(" ") : B.push(String.fromCodePoint(C)); - } - return B.join(""); - } -@@ -1484,7 +1484,7 @@ class _ { - return; - let J = ""; - A.flags & U.ITALIC && (J += "italic "), A.flags & U.BOLD && (J += "bold "), this.ctx.font = `${J}${this.fontSize}px ${this.fontFamily}`, this.ctx.fillStyle = this.rgbToCSS(w, o, i), A.flags & U.FAINT && (this.ctx.globalAlpha = 0.5); -- const s = g, F = C + this.metrics.baseline, a = String.fromCodePoint(A.codepoint || 32); -+ const s = g, F = C + this.metrics.baseline, a = (A.codepoint === 0 || A.codepoint == null || A.codepoint < 0 || A.codepoint > 1114111 || (A.codepoint >= 55296 && A.codepoint <= 57343)) ? " " : String.fromCodePoint(A.codepoint); - if (this.ctx.fillText(a, s, F), A.flags & U.FAINT && (this.ctx.globalAlpha = 1), A.flags & U.UNDERLINE) { - const N = C + this.metrics.baseline + 2; - this.ctx.strokeStyle = this.ctx.fillStyle, this.ctx.lineWidth = 1, this.ctx.beginPath(), this.ctx.moveTo(g, N), this.ctx.lineTo(g + I, N), this.ctx.stroke(); -@@ -1730,7 +1730,7 @@ const L = class R { - let G = ""; - for (let J = M; J <= k; J++) { - const s = o[J]; -- if (s && s.codepoint !== 0) { -+ if (s && s.codepoint !== 0 && s.codepoint <= 1114111 && !(s.codepoint >= 55296 && s.codepoint <= 57343)) { - const F = String.fromCodePoint(s.codepoint); - G += F, F.trim() && (i = G.length); - } else -@@ -1995,7 +1995,7 @@ const L = class R { - if (!Q) - return null; - const g = (w) => { -- if (!w || w.codepoint === 0) -+ if (!w || w.codepoint === 0 || w.codepoint > 1114111 || (w.codepoint >= 55296 && w.codepoint <= 57343)) - return !1; - const o = String.fromCodePoint(w.codepoint); - return /[\w-]/.test(o);