|
|
@@ -65,16 +65,6 @@ 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
|
|
|
@@ -199,11 +189,7 @@ 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
|
|
|
@@ -467,135 +453,85 @@ export const Terminal = (props: TerminalProps) => {
|
|
|
}
|
|
|
|
|
|
const once = { value: false }
|
|
|
- const decoder = new TextDecoder()
|
|
|
-
|
|
|
- const fail = (err: unknown) => {
|
|
|
- if (disposed) return
|
|
|
- if (once.value) return
|
|
|
- once.value = true
|
|
|
- local.onConnectError?.(err)
|
|
|
- }
|
|
|
-
|
|
|
- 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 (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)
|
|
|
+ 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)
|
|
|
}
|
|
|
+ socket.addEventListener("open", handleOpen)
|
|
|
+ if (socket.readyState === WebSocket.OPEN) handleOpen()
|
|
|
|
|
|
- const open = () => {
|
|
|
+ const decoder = new TextDecoder()
|
|
|
+ const handleMessage = (event: MessageEvent) => {
|
|
|
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)
|
|
|
+ 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
|
|
|
}
|
|
|
- return
|
|
|
+ } catch (err) {
|
|
|
+ debugTerminal("invalid websocket control frame", err)
|
|
|
}
|
|
|
-
|
|
|
- const data = typeof event.data === "string" ? event.data : ""
|
|
|
- if (!data) return
|
|
|
- output?.push(data)
|
|
|
- cursor += data.length
|
|
|
- seek = cursor
|
|
|
+ return
|
|
|
}
|
|
|
|
|
|
- const handleError = (error: Event) => {
|
|
|
- if (disposed) return
|
|
|
- debugTerminal("websocket error", error)
|
|
|
- }
|
|
|
+ const data = typeof event.data === "string" ? event.data : ""
|
|
|
+ if (!data) return
|
|
|
+ output?.push(data)
|
|
|
+ cursor += data.length
|
|
|
+ }
|
|
|
+ socket.addEventListener("message", handleMessage)
|
|
|
|
|
|
- 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 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 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 })))
|
|
|
+ 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 })))
|
|
|
}
|
|
|
-
|
|
|
- drop = stop
|
|
|
- socket.addEventListener("open", handleOpen)
|
|
|
- socket.addEventListener("message", handleMessage)
|
|
|
- socket.addEventListener("error", handleError)
|
|
|
- socket.addEventListener("close", handleClose)
|
|
|
}
|
|
|
-
|
|
|
- probe.control({
|
|
|
- disconnect: () => {
|
|
|
- if (!ws) return
|
|
|
- ws.close(4_000, "e2e")
|
|
|
- },
|
|
|
+ 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)
|
|
|
})
|
|
|
-
|
|
|
- open()
|
|
|
}
|
|
|
|
|
|
void run().catch((err) => {
|
|
|
@@ -613,8 +549,6 @@ 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 = () => {
|