Skip to content
33 changes: 25 additions & 8 deletions apps/desktop/src/main/terminal-host/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,18 +360,32 @@ export class Session {

case PtySubprocessIpcType.Data: {
if (payload.length === 0) break;
let data = payload.toString("utf8");

// Scan for OSC 133;A (shell ready) and strip from output.
// scanForShellReady operates on bytes — the OSC marker is pure
// ASCII, so byte-level matching is identical to char-level
// matching, and we avoid `payload.toString("utf8")` per chunk
// (which mangles multi-byte codepoints split across chunks).
let bytes: Uint8Array = payload;
if (this.shellReadyState === "pending") {
const result = scanForShellReady(this.scanState, data);
data = result.output;
const result = scanForShellReady(this.scanState, payload);
bytes = result.output;
if (result.matched) {
this.resolveShellReady("ready");
}
}

if (data.length === 0) break;
if (bytes.length === 0) break;
// v1's emulator + IPC consumers want a string. UTF-8 decode the
// stripped bytes here. Boundary mangling is still possible at
// chunk edges (v1 has no per-session StringDecoder), but v1 is
// sunset — the v2 daemon-backed path is the supported one and
// it's clean end-to-end.
const data = Buffer.from(
bytes.buffer,
bytes.byteOffset,
bytes.byteLength,
).toString("utf8");

this.enqueueEmulatorWrite(data);

Expand Down Expand Up @@ -1037,14 +1051,17 @@ export class Session {
clearTimeout(this.shellReadyTimeoutId);
this.shellReadyTimeoutId = null;
}
// Flush held marker bytes — they weren't part of a full marker
// Flush held marker bytes — they weren't part of a full marker.
// heldBytes is `number[]` after the byte-scanner refactor; decode to a
// utf-8 string for v1's emulator/event surface, which is string-based.
if (this.scanState.heldBytes.length > 0) {
this.enqueueEmulatorWrite(this.scanState.heldBytes);
const flushed = Buffer.from(this.scanState.heldBytes).toString("utf8");
this.enqueueEmulatorWrite(flushed);
this.broadcastEvent("data", {
type: "data",
data: this.scanState.heldBytes,
data: flushed,
} satisfies TerminalDataEvent);
this.scanState.heldBytes = "";
this.scanState.heldBytes.length = 0;
}
this.scanState.matchPos = 0;
}
Expand Down
25 changes: 18 additions & 7 deletions apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,14 @@ export interface TerminalLogEntry {
message: string;
}

// PTY output bytes arrive as binary WebSocket frames and are fed straight
// into xterm.write(Uint8Array) — no UTF-8 decoding hop, so multi-byte
// codepoints that straddle a frame boundary stay intact (xterm.js buffers
// partial sequences internally). Control messages (title/error/exit) stay
// JSON.
type TerminalServerMessage =
| { type: "data"; data: string }
| { type: "error"; message: string }
| { type: "exit"; exitCode: number; signal: number }
| { type: "replay"; data: string }
| { type: "title"; title: string | null };

export interface TerminalTransport {
Expand Down Expand Up @@ -193,6 +196,10 @@ export function connect(
transport._exited = false;
setConnectionState(transport, "connecting");
const socket = new WebSocket(wsUrl);
// Receive PTY bytes as ArrayBuffer (the default would be Blob, which
// forces an async read); we want to feed bytes synchronously into
// xterm.write to keep render order strict.
socket.binaryType = "arraybuffer";
transport.socket = socket;

socket.addEventListener("open", () => {
Expand All @@ -212,6 +219,15 @@ export function connect(

socket.addEventListener("message", (event) => {
if (transport.socket !== socket) return;

// Binary frame = PTY output bytes (data + replay collapsed onto one
// channel; renderer treats them identically). Pipe straight into
// xterm without any decoding step.
if (event.data instanceof ArrayBuffer) {
terminal.write(new Uint8Array(event.data));
return;
}

let message: TerminalServerMessage;
try {
message = JSON.parse(String(event.data)) as TerminalServerMessage;
Expand All @@ -220,11 +236,6 @@ export function connect(
return;
}

if (message.type === "data" || message.type === "replay") {
terminal.write(message.data);
return;
}

if (message.type === "title") {
setTerminalTitle(transport, message.title);
return;
Expand Down
4 changes: 2 additions & 2 deletions packages/host-service/src/daemon/DaemonSupervisor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,8 +73,8 @@ async function startFakeDaemon(opts: FakeDaemonOptions): Promise<{
const decoder = new FrameDecoder();
sock.on("data", (chunk: Buffer) => {
decoder.push(chunk);
for (const raw of decoder.drain()) {
const msg = raw as ClientMessage;
for (const decoded of decoder.drain()) {
const msg = decoded.message as ClientMessage;
if (msg.type !== "hello") continue;
if (opts.silent) return;
if (opts.hangUpAfterHello) {
Expand Down
8 changes: 4 additions & 4 deletions packages/host-service/src/daemon/DaemonSupervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -658,8 +658,8 @@ export async function listDaemonSessions(
sock.on("data", (chunk: Buffer) => {
try {
decoder.push(chunk);
for (const raw of decoder.drain()) {
const msg = raw as ServerMessage;
for (const decoded of decoder.drain()) {
const msg = decoded.message as ServerMessage;
if (!helloAcked) {
if (msg.type !== "hello-ack") {
cleanup(null);
Expand Down Expand Up @@ -740,8 +740,8 @@ export async function probeDaemonVersion(
sock.on("data", (chunk: Buffer) => {
try {
decoder.push(chunk);
for (const raw of decoder.drain()) {
const msg = raw as ServerMessage;
for (const decoded of decoder.drain()) {
const msg = decoded.message as ServerMessage;
if (msg.type === "hello-ack") {
cleanup(msg.daemonVersion ?? null);
return;
Expand Down
32 changes: 19 additions & 13 deletions packages/host-service/src/terminal/DaemonClient/DaemonClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,11 +150,9 @@ export class DaemonClient {

/** Fire-and-forget; bytes go straight to the PTY. */
input(id: string, data: Buffer): void {
this.send({
type: "input",
id,
data: data.toString("base64"),
});
// Bytes ride in the frame's binary tail (see ../../protocol/framing.ts).
// No base64 hop on either side.
this.send({ type: "input", id }, data);
}

/** Fire-and-forget; daemon validates dims. */
Expand Down Expand Up @@ -368,17 +366,17 @@ export class DaemonClient {
});
}

private send(msg: unknown): void {
private send(msg: unknown, payload?: Uint8Array): void {
const sock = this.socket;
if (!sock || sock.destroyed) {
throw new Error("DaemonClient: socket not connected");
}
sock.write(encodeFrame(msg));
sock.write(encodeFrame(msg, payload));
}

private onData(chunk: Buffer): void {
this.decoder.push(chunk);
let frames: unknown[];
let frames: ReturnType<FrameDecoder["drain"]>;
try {
frames = this.decoder.drain();
} catch (err) {
Expand All @@ -390,13 +388,21 @@ export class DaemonClient {
this.onClose(err as Error);
return;
}
for (const raw of frames) {
const msg = raw as ServerMessage;
for (const frame of frames) {
const msg = frame.message as ServerMessage;
// Route session-keyed events to subscriber callbacks.
if (msg.type === "output" && this.callbacks.has(msg.id)) {
const buf = Buffer.from(msg.data, "base64");
for (const cb of this.callbacks.get(msg.id)?.output ?? []) {
cb(buf);
if (frame.payload) {
// Hand the bytes to subscribers as a Buffer view; same shape
// they got pre-binary-tail when we base64-decoded into Buffer.
const buf = Buffer.from(
frame.payload.buffer,
frame.payload.byteOffset,
frame.payload.byteLength,
);
for (const cb of this.callbacks.get(msg.id)?.output ?? []) {
cb(buf);
}
}
continue;
}
Expand Down
Loading
Loading