summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src/components')
-rw-r--r--packages/app/src/components/terminal.tsx200
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 = () => {