diff options
| author | Adam <[email protected]> | 2026-03-13 08:56:48 -0500 |
|---|---|---|
| committer | GitHub <[email protected]> | 2026-03-13 08:56:48 -0500 |
| commit | c9e9dbeee1ceb5af3d1b1ce292317390286fe7a0 (patch) | |
| tree | e679df64078db58eadb3cba2d1ac65b170175360 /packages/app/src/components | |
| parent | b88b323049ed7ad7bf41fbec6ce66247ec4047ab (diff) | |
| download | opencode-c9e9dbeee1ceb5af3d1b1ce292317390286fe7a0.tar.gz opencode-c9e9dbeee1ceb5af3d1b1ce292317390286fe7a0.zip | |
fix(app): terminal cloning without retry (#17354)
Diffstat (limited to 'packages/app/src/components')
| -rw-r--r-- | packages/app/src/components/terminal.tsx | 200 |
1 files changed, 133 insertions, 67 deletions
diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index ff455ebe2..d4eccbc82 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -65,6 +65,16 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } +const errorStatus = (err: unknown) => { + if (!err || typeof err !== "object") return + if (!("data" in err)) return + const data = err.data + if (!data || typeof data !== "object") return + if (!("statusCode" in data)) return + const status = data.statusCode + return typeof status === "number" ? status : undefined +} + const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -189,7 +199,11 @@ export const Terminal = (props: TerminalProps) => { const start = typeof local.pty.cursor === "number" && Number.isSafeInteger(local.pty.cursor) ? local.pty.cursor : undefined let cursor = start ?? 0 + let seek = start !== undefined ? start : restore ? -1 : 0 let output: ReturnType<typeof terminalWriter> | undefined + let drop: VoidFunction | undefined + let reconn: ReturnType<typeof setTimeout> | undefined + let tries = 0 const cleanup = () => { if (!cleanups.length) return @@ -453,85 +467,135 @@ export const Terminal = (props: TerminalProps) => { } const once = { value: false } - let closing = false - - const url = new URL(sdk.url + `/pty/${id}/connect`) - url.searchParams.set("directory", sdk.directory) - url.searchParams.set("cursor", String(start !== undefined ? start : restore ? -1 : 0)) - url.protocol = url.protocol === "https:" ? "wss:" : "ws:" - url.username = server.current?.http.username ?? "opencode" - url.password = server.current?.http.password ?? "" - - const socket = new WebSocket(url) - socket.binaryType = "arraybuffer" - ws = socket - - const handleOpen = () => { - probe.connect() - local.onConnect?.() - scheduleSize(t.cols, t.rows) + const decoder = new TextDecoder() + + const fail = (err: unknown) => { + if (disposed) return + if (once.value) return + once.value = true + local.onConnectError?.(err) } - socket.addEventListener("open", handleOpen) - if (socket.readyState === WebSocket.OPEN) handleOpen() - const decoder = new TextDecoder() - const handleMessage = (event: MessageEvent) => { + const gone = () => + sdk.client.pty + .get({ ptyID: id }) + .then(() => false) + .catch((err) => { + if (errorStatus(err) === 404) return true + debugTerminal("failed to inspect terminal session", err) + return false + }) + + const retry = (err: unknown) => { if (disposed) return - if (closing) return - if (event.data instanceof ArrayBuffer) { - const bytes = new Uint8Array(event.data) - if (bytes[0] !== 0) return - const json = decoder.decode(bytes.subarray(1)) - try { - const meta = JSON.parse(json) as { cursor?: unknown } - const next = meta?.cursor - if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { - cursor = next + if (reconn !== undefined) return + + const ms = Math.min(250 * 2 ** Math.min(tries, 4), 4_000) + reconn = setTimeout(async () => { + reconn = undefined + if (disposed) return + if (await gone()) { + if (disposed) return + fail(err) + return + } + if (disposed) return + tries += 1 + open() + }, ms) + } + + const open = () => { + if (disposed) return + drop?.() + + const url = new URL(sdk.url + `/pty/${id}/connect`) + url.searchParams.set("directory", sdk.directory) + url.searchParams.set("cursor", String(seek)) + url.protocol = url.protocol === "https:" ? "wss:" : "ws:" + url.username = server.current?.http.username ?? "opencode" + url.password = server.current?.http.password ?? "" + + const socket = new WebSocket(url) + socket.binaryType = "arraybuffer" + ws = socket + + const handleOpen = () => { + if (disposed) return + tries = 0 + probe.connect() + local.onConnect?.() + scheduleSize(t.cols, t.rows) + } + + const handleMessage = (event: MessageEvent) => { + if (disposed) return + if (event.data instanceof ArrayBuffer) { + const bytes = new Uint8Array(event.data) + if (bytes[0] !== 0) return + const json = decoder.decode(bytes.subarray(1)) + try { + const meta = JSON.parse(json) as { cursor?: unknown } + const next = meta?.cursor + if (typeof next === "number" && Number.isSafeInteger(next) && next >= 0) { + cursor = next + seek = next + } + } catch (err) { + debugTerminal("invalid websocket control frame", err) } - } catch (err) { - debugTerminal("invalid websocket control frame", err) + return } - return + + const data = typeof event.data === "string" ? event.data : "" + if (!data) return + output?.push(data) + cursor += data.length + seek = cursor } - const data = typeof event.data === "string" ? event.data : "" - if (!data) return - output?.push(data) - cursor += data.length - } - socket.addEventListener("message", handleMessage) + const handleError = (error: Event) => { + if (disposed) return + debugTerminal("websocket error", error) + } - 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) + const stop = () => { + socket.removeEventListener("open", handleOpen) + socket.removeEventListener("message", handleMessage) + socket.removeEventListener("error", handleError) + socket.removeEventListener("close", handleClose) + if (ws === socket) ws = undefined + if (drop === stop) drop = undefined + if (socket.readyState !== WebSocket.CLOSED && socket.readyState !== WebSocket.CLOSING) socket.close(1000) + } - 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) { - if (once.value) return - once.value = true - local.onConnectError?.(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) + const handleClose = (event: CloseEvent) => { + if (ws === socket) ws = undefined + if (drop === stop) drop = undefined + socket.removeEventListener("open", handleOpen) + socket.removeEventListener("message", handleMessage) + socket.removeEventListener("error", handleError) + socket.removeEventListener("close", handleClose) + if (disposed) return + if (event.code === 1000) return + retry(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) } + + drop = stop + socket.addEventListener("open", handleOpen) + socket.addEventListener("message", handleMessage) + socket.addEventListener("error", handleError) + socket.addEventListener("close", handleClose) } - socket.addEventListener("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) + + probe.control({ + disconnect: () => { + if (!ws) return + ws.close(4_000, "e2e") + }, }) + + open() } void run().catch((err) => { @@ -549,6 +613,8 @@ export const Terminal = (props: TerminalProps) => { disposed = true if (fitFrame !== undefined) cancelAnimationFrame(fitFrame) if (sizeTimer !== undefined) clearTimeout(sizeTimer) + if (reconn !== undefined) clearTimeout(reconn) + drop?.() if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(1000) const finalize = () => { |
