summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/app/src')
-rw-r--r--packages/app/src/components/terminal.tsx200
-rw-r--r--packages/app/src/testing/terminal.ts63
2 files changed, 181 insertions, 82 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 = () => {
diff --git a/packages/app/src/testing/terminal.ts b/packages/app/src/testing/terminal.ts
index aa1974404..4c179dee3 100644
--- a/packages/app/src/testing/terminal.ts
+++ b/packages/app/src/testing/terminal.ts
@@ -2,21 +2,28 @@ export const terminalAttr = "data-pty-id"
export type TerminalProbeState = {
connected: boolean
+ connects: number
rendered: string
settled: number
}
+type TerminalProbeControl = {
+ disconnect?: VoidFunction
+}
+
export type E2EWindow = Window & {
__opencode_e2e?: {
terminal?: {
enabled?: boolean
terminals?: Record<string, TerminalProbeState>
+ controls?: Record<string, TerminalProbeControl>
}
}
}
const seed = (): TerminalProbeState => ({
connected: false,
+ connects: 0,
rendered: "",
settled: 0,
})
@@ -25,15 +32,28 @@ const root = () => {
if (typeof window === "undefined") return
const state = (window as E2EWindow).__opencode_e2e?.terminal
if (!state?.enabled) return
+ return state
+}
+
+const terms = () => {
+ const state = root()
+ if (!state) return
state.terminals ??= {}
return state.terminals
}
+const controls = () => {
+ const state = root()
+ if (!state) return
+ state.controls ??= {}
+ return state.controls
+}
+
export const terminalProbe = (id: string) => {
const set = (next: Partial<TerminalProbeState>) => {
- const terms = root()
- if (!terms) return
- terms[id] = { ...(terms[id] ?? seed()), ...next }
+ const state = terms()
+ if (!state) return
+ state[id] = { ...(state[id] ?? seed()), ...next }
}
return {
@@ -41,24 +61,37 @@ export const terminalProbe = (id: string) => {
set(seed())
},
connect() {
- set({ connected: true })
+ const state = terms()
+ if (!state) return
+ const prev = state[id] ?? seed()
+ state[id] = {
+ ...prev,
+ connected: true,
+ connects: prev.connects + 1,
+ }
},
render(data: string) {
- const terms = root()
- if (!terms) return
- const prev = terms[id] ?? seed()
- terms[id] = { ...prev, rendered: prev.rendered + data }
+ const state = terms()
+ if (!state) return
+ const prev = state[id] ?? seed()
+ state[id] = { ...prev, rendered: prev.rendered + data }
},
settle() {
- const terms = root()
- if (!terms) return
- const prev = terms[id] ?? seed()
- terms[id] = { ...prev, settled: prev.settled + 1 }
+ const state = terms()
+ if (!state) return
+ const prev = state[id] ?? seed()
+ state[id] = { ...prev, settled: prev.settled + 1 }
+ },
+ control(next: Partial<TerminalProbeControl>) {
+ const state = controls()
+ if (!state) return
+ state[id] = { ...(state[id] ?? {}), ...next }
},
drop() {
- const terms = root()
- if (!terms) return
- delete terms[id]
+ const state = terms()
+ if (state) delete state[id]
+ const control = controls()
+ if (control) delete control[id]
},
}
}