Skip to content

Commit 548608b

Browse files
committed
fix(app): terminal pty isolation
1 parent 4e0f509 commit 548608b

6 files changed

Lines changed: 190 additions & 19 deletions

File tree

packages/app/src/components/terminal.tsx

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { resolveThemeVariant, useTheme, withAlpha, type HexColor } from "@openco
1010
import { useLanguage } from "@/context/language"
1111
import { showToast } from "@opencode-ai/ui/toast"
1212
import { disposeIfDisposable, getHoveredLinkText, setOptionIfSupported } from "@/utils/runtime-adapters"
13+
import { terminalWriter } from "@/utils/terminal-writer"
1314

1415
const TOGGLE_TERMINAL_ID = "terminal.toggle"
1516
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
@@ -160,6 +161,7 @@ export const Terminal = (props: TerminalProps) => {
160161
const start =
161162
typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined
162163
let cursor = start ?? 0
164+
let output: ReturnType<typeof terminalWriter> | undefined
163165

164166
const cleanup = () => {
165167
if (!cleanups.length) return
@@ -300,7 +302,7 @@ export const Terminal = (props: TerminalProps) => {
300302
fontSize: 14,
301303
fontFamily: monoFontFamily(settings.appearance.font()),
302304
allowTransparency: false,
303-
convertEol: true,
305+
convertEol: false,
304306
theme: terminalColors(),
305307
scrollback: 10_000,
306308
ghostty: g,
@@ -312,6 +314,7 @@ export const Terminal = (props: TerminalProps) => {
312314
}
313315
ghostty = g
314316
term = t
317+
output = terminalWriter((data) => t.write(data))
315318

316319
t.attachCustomKeyEventHandler((event) => {
317320
const key = event.key.toLowerCase()
@@ -416,7 +419,7 @@ export const Terminal = (props: TerminalProps) => {
416419

417420
const data = typeof event.data === "string" ? event.data : ""
418421
if (!data) return
419-
t.write(data)
422+
output?.push(data)
420423
cursor += data.length
421424
}
422425
socket.addEventListener("message", handleMessage)
@@ -459,6 +462,7 @@ export const Terminal = (props: TerminalProps) => {
459462

460463
onCleanup(() => {
461464
disposed = true
465+
output?.flush()
462466
persistTerminal({ term, addon: serializeAddon, cursor, pty: local.pty, onCleanup: props.onCleanup })
463467
cleanup()
464468
})
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { terminalWriter } from "./terminal-writer"
3+
4+
describe("terminalWriter", () => {
5+
test("buffers and flushes once per schedule", () => {
6+
const calls: string[] = []
7+
const scheduled: VoidFunction[] = []
8+
const writer = terminalWriter(
9+
(data) => calls.push(data),
10+
(flush) => scheduled.push(flush),
11+
)
12+
13+
writer.push("a")
14+
writer.push("b")
15+
writer.push("c")
16+
17+
expect(calls).toEqual([])
18+
expect(scheduled).toHaveLength(1)
19+
20+
scheduled[0]?.()
21+
expect(calls).toEqual(["abc"])
22+
})
23+
24+
test("flush is a no-op when empty", () => {
25+
const calls: string[] = []
26+
const writer = terminalWriter(
27+
(data) => calls.push(data),
28+
(flush) => flush(),
29+
)
30+
writer.flush()
31+
expect(calls).toEqual([])
32+
})
33+
})
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export function terminalWriter(
2+
write: (data: string) => void,
3+
schedule: (flush: VoidFunction) => void = queueMicrotask,
4+
) {
5+
let chunks: string[] | undefined
6+
let scheduled = false
7+
8+
const flush = () => {
9+
scheduled = false
10+
const items = chunks
11+
if (!items?.length) return
12+
chunks = undefined
13+
write(items.join(""))
14+
}
15+
16+
const push = (data: string) => {
17+
if (!data) return
18+
if (chunks) chunks.push(data)
19+
else chunks = [data]
20+
21+
if (scheduled) return
22+
scheduled = true
23+
schedule(flush)
24+
}
25+
26+
return { push, flush }
27+
}

packages/opencode/src/pty/index.ts

Lines changed: 50 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ import { type IPty } from "bun-pty"
44
import z from "zod"
55
import { Identifier } from "../id/id"
66
import { Log } from "../util/log"
7-
import type { WSContext } from "hono/ws"
87
import { Instance } from "../project/instance"
98
import { lazy } from "@opencode-ai/util/lazy"
109
import { Shell } from "@/shell/shell"
@@ -17,6 +16,22 @@ export namespace Pty {
1716
const BUFFER_CHUNK = 64 * 1024
1817
const encoder = new TextEncoder()
1918

19+
type Socket = {
20+
readyState: number
21+
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
22+
close: (code?: number, reason?: string) => void
23+
}
24+
25+
const sockets = new WeakMap<object, number>()
26+
let socketCounter = 0
27+
28+
const tagSocket = (ws: Socket) => {
29+
if (!ws || typeof ws !== "object") return
30+
const next = (socketCounter = (socketCounter + 1) % Number.MAX_SAFE_INTEGER)
31+
sockets.set(ws, next)
32+
return next
33+
}
34+
2035
// WebSocket control frame: 0x00 + UTF-8 JSON (currently { cursor }).
2136
const meta = (cursor: number) => {
2237
const json = JSON.stringify({ cursor })
@@ -81,7 +96,7 @@ export namespace Pty {
8196
buffer: string
8297
bufferCursor: number
8398
cursor: number
84-
subscribers: Set<WSContext>
99+
subscribers: Map<Socket, number>
85100
}
86101

87102
const state = Instance.state(
@@ -91,8 +106,12 @@ export namespace Pty {
91106
try {
92107
session.process.kill()
93108
} catch {}
94-
for (const ws of session.subscribers) {
95-
ws.close()
109+
for (const ws of session.subscribers.keys()) {
110+
try {
111+
ws.close()
112+
} catch {
113+
// ignore
114+
}
96115
}
97116
}
98117
sessions.clear()
@@ -154,18 +173,26 @@ export namespace Pty {
154173
buffer: "",
155174
bufferCursor: 0,
156175
cursor: 0,
157-
subscribers: new Set(),
176+
subscribers: new Map(),
158177
}
159178
state().set(id, session)
160179
ptyProcess.onData((data) => {
161180
session.cursor += data.length
162181

163-
for (const ws of session.subscribers) {
182+
for (const [ws, id] of session.subscribers) {
164183
if (ws.readyState !== 1) {
165184
session.subscribers.delete(ws)
166185
continue
167186
}
168-
ws.send(data)
187+
if (typeof ws === "object" && sockets.get(ws) !== id) {
188+
session.subscribers.delete(ws)
189+
continue
190+
}
191+
try {
192+
ws.send(data)
193+
} catch {
194+
session.subscribers.delete(ws)
195+
}
169196
}
170197

171198
session.buffer += data
@@ -177,14 +204,15 @@ export namespace Pty {
177204
ptyProcess.onExit(({ exitCode }) => {
178205
log.info("session exited", { id, exitCode })
179206
session.info.status = "exited"
180-
for (const ws of session.subscribers) {
181-
ws.close()
207+
for (const ws of session.subscribers.keys()) {
208+
try {
209+
ws.close()
210+
} catch {
211+
// ignore
212+
}
182213
}
183214
session.subscribers.clear()
184215
Bus.publish(Event.Exited, { id, exitCode })
185-
for (const ws of session.subscribers) {
186-
ws.close()
187-
}
188216
state().delete(id)
189217
})
190218
Bus.publish(Event.Created, { info })
@@ -211,9 +239,14 @@ export namespace Pty {
211239
try {
212240
session.process.kill()
213241
} catch {}
214-
for (const ws of session.subscribers) {
215-
ws.close()
242+
for (const ws of session.subscribers.keys()) {
243+
try {
244+
ws.close()
245+
} catch {
246+
// ignore
247+
}
216248
}
249+
session.subscribers.clear()
217250
state().delete(id)
218251
Bus.publish(Event.Deleted, { id })
219252
}
@@ -232,7 +265,7 @@ export namespace Pty {
232265
}
233266
}
234267

235-
export function connect(id: string, ws: WSContext, cursor?: number) {
268+
export function connect(id: string, ws: Socket, cursor?: number) {
236269
const session = state().get(id)
237270
if (!session) {
238271
ws.close()
@@ -272,7 +305,8 @@ export namespace Pty {
272305
return
273306
}
274307

275-
session.subscribers.add(ws)
308+
const socketId = tagSocket(ws)
309+
if (typeof socketId === "number") session.subscribers.set(ws, socketId)
276310
return {
277311
onMessage: (message: string | ArrayBuffer) => {
278312
session.process.write(String(message))

packages/opencode/src/server/routes/pty.ts

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -160,16 +160,35 @@ export const PtyRoutes = lazy(() =>
160160
})()
161161
let handler: ReturnType<typeof Pty.connect>
162162
if (!Pty.get(id)) throw new Error("Session not found")
163+
164+
type Socket = {
165+
readyState: number
166+
send: (data: string | Uint8Array<ArrayBuffer> | ArrayBuffer) => void
167+
close: (code?: number, reason?: string) => void
168+
}
169+
170+
const isSocket = (value: unknown): value is Socket => {
171+
if (!value || typeof value !== "object") return false
172+
if (!("readyState" in value)) return false
173+
if (!("send" in value) || typeof (value as { send?: unknown }).send !== "function") return false
174+
if (!("close" in value) || typeof (value as { close?: unknown }).close !== "function") return false
175+
return typeof (value as { readyState?: unknown }).readyState === "number"
176+
}
177+
163178
return {
164179
onOpen(_event, ws) {
165-
handler = Pty.connect(id, ws, cursor)
180+
const socket = isSocket(ws.raw) ? ws.raw : ws
181+
handler = Pty.connect(id, socket, cursor)
166182
},
167183
onMessage(event) {
168184
handler?.onMessage(String(event.data))
169185
},
170186
onClose() {
171187
handler?.onClose()
172188
},
189+
onError() {
190+
handler?.onClose()
191+
},
173192
}
174193
}),
175194
),
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { Instance } from "../../src/project/instance"
3+
import { Pty } from "../../src/pty"
4+
import { tmpdir } from "../fixture/fixture"
5+
6+
describe("pty", () => {
7+
test("does not leak output when websocket objects are reused", async () => {
8+
await using dir = await tmpdir({ git: true })
9+
10+
await Instance.provide({
11+
directory: dir.path,
12+
fn: async () => {
13+
const a = await Pty.create({ command: "cat", title: "a" })
14+
const b = await Pty.create({ command: "cat", title: "b" })
15+
try {
16+
const outA: string[] = []
17+
const outB: string[] = []
18+
19+
const ws = {
20+
readyState: 1,
21+
send: (data: unknown) => {
22+
outA.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
23+
},
24+
close: () => {
25+
// no-op (simulate abrupt drop)
26+
},
27+
}
28+
29+
// Connect "a" first with ws.
30+
Pty.connect(a.id, ws as any)
31+
32+
// Now "reuse" the same ws object for another connection.
33+
ws.send = (data: unknown) => {
34+
outB.push(typeof data === "string" ? data : Buffer.from(data as Uint8Array).toString("utf8"))
35+
}
36+
Pty.connect(b.id, ws as any)
37+
38+
// Clear connect metadata writes.
39+
outA.length = 0
40+
outB.length = 0
41+
42+
// Output from a must never show up in b.
43+
Pty.write(a.id, "AAA\n")
44+
await Bun.sleep(100)
45+
46+
expect(outB.join("")).not.toContain("AAA")
47+
} finally {
48+
await Pty.remove(a.id)
49+
await Pty.remove(b.id)
50+
}
51+
},
52+
})
53+
})
54+
})

0 commit comments

Comments
 (0)