From 78fb82b25737ec7ebdc9f041b7e5f87d561a54f7 Mon Sep 17 00:00:00 2001 From: nntrivi2001 Date: Thu, 13 Aug 2026 13:54:19 +0700 Subject: [PATCH] fix(run): decode binary WS frames in TerminalView so pty output actually renders MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server streams PTY output as binary WS frames, but the browser's default binaryType ("blob") handed onmessage a Blob that never matched the `typeof === "string"` check — every keystroke response was silently dropped and the terminal stayed blank despite the backend streaming correctly (verified via a raw ws client against the live tmux session). --- client/src/components/run/TerminalView.tsx | 19 ++++++++++++------- .../run/__tests__/TerminalView.test.tsx | 9 +++++++++ 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/client/src/components/run/TerminalView.tsx b/client/src/components/run/TerminalView.tsx index bb48548..f16ff27 100644 --- a/client/src/components/run/TerminalView.tsx +++ b/client/src/components/run/TerminalView.tsx @@ -29,18 +29,23 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { fit.fit(); const ws = new WebSocket(`${wsBaseUrl}/ws-pty/${encodeURIComponent(runId)}`); + // Server sends PTY bytes as binary frames — default binaryType ("blob") + // would hand onmessage a Blob that the string checks below never match, + // silently dropping all terminal output. "arraybuffer" keeps it sync. + ws.binaryType = "arraybuffer"; + const decoder = new TextDecoder(); ws.onopen = () => { ws.send(JSON.stringify({ type: "resize", cols: term.cols, rows: term.rows })); }; ws.onmessage = (event) => { - if (typeof event.data === "string") { - // Binary PTY output arrives as text here too (the browser WS API - // decodes non-Blob/ArrayBuffer frames as strings) — a JSON control - // frame is the only thing that starts with `{"type"`. - if (event.data.startsWith('{"type"')) { + const isArrayBuffer = Object.prototype.toString.call(event.data) === "[object ArrayBuffer]"; + const data = isArrayBuffer ? decoder.decode(event.data as ArrayBuffer) : event.data; + if (typeof data === "string") { + // A JSON control frame is the only thing that starts with `{"type"`. + if (data.startsWith('{"type"')) { try { - const msg = JSON.parse(event.data); + const msg = JSON.parse(data); if (msg.type === "exit") { term.write(`\r\n[session ended, exit code ${msg.code}]\r\n`); } @@ -49,7 +54,7 @@ export function TerminalView({ runId, wsBaseUrl }: TerminalViewProps) { /* not JSON — fall through and render as PTY output */ } } - term.write(event.data); + term.write(data); } }; diff --git a/client/src/components/run/__tests__/TerminalView.test.tsx b/client/src/components/run/__tests__/TerminalView.test.tsx index 2cf1c76..dfda206 100644 --- a/client/src/components/run/__tests__/TerminalView.test.tsx +++ b/client/src/components/run/__tests__/TerminalView.test.tsx @@ -75,6 +75,15 @@ describe("TerminalView", () => { expect(writeMock).toHaveBeenCalledWith("hello"); }); + it("decodes binary ArrayBuffer frames (server sends PTY output as binary)", () => { + render(); + const ws = MockWebSocket.instances[0]!; + ws.onopen?.(); + const bytes = new TextEncoder().encode("hello-binary").buffer; + ws.onmessage?.({ data: bytes }); + expect(writeMock).toHaveBeenCalledWith("hello-binary"); + }); + it("forwards terminal keystrokes as outgoing WS sends", () => { render(); const ws = MockWebSocket.instances[0]!;