diff --git a/apps/server/src/features/terminal/host/__tests__/pty-host-runtime.test.ts b/apps/server/src/features/terminal/host/__tests__/pty-host-runtime.test.ts index 5ed6ef3ab..b8b47ec4a 100644 --- a/apps/server/src/features/terminal/host/__tests__/pty-host-runtime.test.ts +++ b/apps/server/src/features/terminal/host/__tests__/pty-host-runtime.test.ts @@ -125,6 +125,7 @@ describe("PtyHostProcessRuntime", () => { rows: 30, }); pty.emitData("ok\r\n"); + await vi.advanceTimersByTimeAsync(2); expect(pty.write).toHaveBeenCalledWith(Buffer.from("echo ok\r")); expect(pty.resize).toHaveBeenCalledWith(100, 30); expect(events.map((event) => event.kind)).toContain("output"); @@ -301,4 +302,188 @@ describe("PtyHostProcessRuntime", () => { expect(scope.close).toHaveBeenCalledOnce(); await runtime.dispose(); }); + + const createRunningSession = async ( + pty: FakePty, + events: PtyHostEvent[], + queueBytes?: () => number, + ): Promise => { + const runtime = new PtyHostProcessRuntime({ + platform: "windows", + hostRuntime: TEST_HOST_RUNTIME, + nativeAbi: "fake-v1", + publish: (event) => events.push(event), + queueBytes, + spawnPty: vi.fn(() => pty), + createScope: vi.fn(() => createScope()), + }); + await runtime.receive({ + contractVersion: 1, + kind: "handshake", + requestedGeneration: "7", + platform: "windows", + }); + await runtime.receive({ + contractVersion: 1, + kind: "create", + sessionId: SESSION_ID, + hostGeneration: "7", + scope: { kind: "workspace", workspaceId: SESSION_ID }, + executable: "pwsh.exe", + arguments: [], + cwd: "C:\\repo", + cols: 80, + rows: 24, + env: [], + }); + events.length = 0; + return runtime; + }; + + const outputEvents = (events: readonly PtyHostEvent[]) => + events.filter((event): event is Extract => event.kind === "output"); + + it("coalesces a synchronous output burst into one event", async () => { + vi.useFakeTimers(); + const pty = new FakePty(); + const events: PtyHostEvent[] = []; + const runtime = await createRunningSession(pty, events); + + pty.emitData("a"); + pty.emitData("b"); + pty.emitData("c"); + await vi.advanceTimersByTimeAsync(2); + + const outputs = outputEvents(events); + expect(outputs).toHaveLength(1); + expect(outputs[0]!.outputSeq).toBe("1"); + expect(Buffer.from(outputs[0]!.dataBase64, "base64").toString()).toBe("abc"); + await runtime.dispose(); + vi.useRealTimers(); + }); + + it("flushes a full-sized chunk without waiting for the batch window", async () => { + vi.useFakeTimers(); + const pty = new FakePty(); + const events: PtyHostEvent[] = []; + const runtime = await createRunningSession(pty, events); + + pty.emitData("x".repeat(65_536 + 10)); + + const outputs = outputEvents(events); + expect(outputs).toHaveLength(2); + expect(Buffer.from(outputs[0]!.dataBase64, "base64").length).toBe(65_536); + expect(Buffer.from(outputs[1]!.dataBase64, "base64").length).toBe(10); + await runtime.dispose(); + vi.useRealTimers(); + }); + + it("pauses a flooding PTY while IPC is saturated and resumes after drain", async () => { + vi.useFakeTimers(); + const pty = new FakePty(); + const events: PtyHostEvent[] = []; + let queued = 800 * 1024; + const runtime = await createRunningSession(pty, events, () => queued); + + pty.emitData("flood"); + await vi.advanceTimersByTimeAsync(4); + // One pause at spawn, one from backpressure. + expect(pty.pause).toHaveBeenCalledTimes(2); + expect(outputEvents(events)).toHaveLength(0); + + queued = 0; + await vi.advanceTimersByTimeAsync(4); + expect(pty.resume).toHaveBeenCalled(); + const outputs = outputEvents(events); + expect(outputs).toHaveLength(1); + expect(Buffer.from(outputs[0]!.dataBase64, "base64").toString()).toBe("flood"); + await runtime.dispose(); + vi.useRealTimers(); + }); + + it("flushes pending output before the exit event", async () => { + vi.useFakeTimers(); + const pty = new FakePty(); + const events: PtyHostEvent[] = []; + const runtime = await createRunningSession(pty, events); + + pty.emitData("tail"); + pty.emitExit(3); + + const outputs = outputEvents(events); + expect(outputs).toHaveLength(1); + const exit = events.at(-1); + expect(exit).toMatchObject({ kind: "exit", code: 3, finalOutputSeq: "1" }); + await runtime.dispose(); + vi.useRealTimers(); + }); + + it("kills a session whose pending output exceeds the per-session bound", async () => { + vi.useFakeTimers(); + const pty = new FakePty(); + const events: PtyHostEvent[] = []; + const runtime = await createRunningSession(pty, events, () => 900 * 1024); + + pty.emitData("x".repeat(64 * 1024 * 1024 + 1)); + expect(pty.kill).toHaveBeenCalledOnce(); + await runtime.dispose(); + vi.useRealTimers(); + }); + + it("keeps a paused session paused inside the hysteresis band", async () => { + vi.useFakeTimers(); + const pty = new FakePty(); + const events: PtyHostEvent[] = []; + let queued = 800 * 1024; + const runtime = await createRunningSession(pty, events, () => queued); + + pty.emitData("flood"); + await vi.advanceTimersByTimeAsync(4); + expect(pty.pause).toHaveBeenCalledTimes(2); + + queued = 600 * 1024; + await vi.advanceTimersByTimeAsync(6); + expect(pty.resume).toHaveBeenCalledTimes(1); + expect(outputEvents(events)).toHaveLength(0); + + queued = 400 * 1024; + await vi.advanceTimersByTimeAsync(4); + expect(outputEvents(events)).toHaveLength(1); + await runtime.dispose(); + vi.useRealTimers(); + }); + + it("defers the exit event behind pending output while pressured, then publishes in order", async () => { + vi.useFakeTimers(); + const pty = new FakePty(); + const events: PtyHostEvent[] = []; + let queued = 800 * 1024; + const runtime = await createRunningSession(pty, events, () => queued); + + pty.emitData("tail"); + pty.emitExit(3); + await vi.advanceTimersByTimeAsync(4); + expect(events.map((event) => event.kind)).not.toContain("exit"); + + queued = 0; + await vi.advanceTimersByTimeAsync(4); + expect(events.map((event) => event.kind)).toEqual(["output", "exit"]); + expect(events.at(-1)).toMatchObject({ kind: "exit", finalOutputSeq: "1" }); + await runtime.dispose(); + vi.useRealTimers(); + }); + + it("publishes a pressured exit by deadline when IPC never drains", async () => { + vi.useFakeTimers(); + const pty = new FakePty(); + const events: PtyHostEvent[] = []; + const runtime = await createRunningSession(pty, events, () => 900 * 1024); + + pty.emitData("dropped"); + pty.emitExit(0); + await vi.advanceTimersByTimeAsync(2_100); + expect(events.at(-1)).toMatchObject({ kind: "exit", finalOutputSeq: "0" }); + await runtime.dispose(); + vi.useRealTimers(); + }); }); diff --git a/apps/server/src/features/terminal/host/pty-host-process.ts b/apps/server/src/features/terminal/host/pty-host-process.ts index d1a40072c..16b62b01b 100644 --- a/apps/server/src/features/terminal/host/pty-host-process.ts +++ b/apps/server/src/features/terminal/host/pty-host-process.ts @@ -3,7 +3,6 @@ import type { HostRuntime } from "@mcode/shared/node/host-runtime"; import { PTY_HOST_MAX_MESSAGE_BYTES, PTY_HOST_MAX_RETAINED_RECORDS, - PtyHostEventSchema, type PtyHostEvent, } from "./pty-host-protocol.js"; import { PtyHostProcessRuntime } from "./pty-host-runtime.js"; @@ -85,17 +84,17 @@ export function runPtyHostProcess(hostRuntime: HostRuntime): PtyHostProcessRunti process.exitCode = 1; process.disconnect?.(); }; + // The server revalidates every event on receipt; the host trusts its own producer shapes. const publish = (event: PtyHostEvent): void => { - const validated = PtyHostEventSchema().parse(event); if (!process.connected || !process.send) { throw new Error("PTY host IPC channel is unavailable"); } - const eventBytes = NodeBuffer.Buffer.byteLength(JSON.stringify(validated), "utf8"); + const eventBytes = NodeBuffer.Buffer.byteLength(JSON.stringify(event), "utf8"); if (outboundBytes + eventBytes > MAX_IPC_QUEUE_BYTES) { throw new Error("PTY host event queue exceeds 1 MiB"); } outboundBytes += eventBytes; - process.send(validated, (error) => { + process.send(event, (error) => { outboundBytes = Math.max(0, outboundBytes - eventBytes); if (error) void failHost(error); }); diff --git a/apps/server/src/features/terminal/host/pty-host-runtime.ts b/apps/server/src/features/terminal/host/pty-host-runtime.ts index a9ef27be1..52debb6db 100644 --- a/apps/server/src/features/terminal/host/pty-host-runtime.ts +++ b/apps/server/src/features/terminal/host/pty-host-runtime.ts @@ -15,6 +15,17 @@ import { createPtyProcessScope } from "./pty-process-scope.js"; const nativeRequire = NodeModule.createRequire(import.meta.url); const MAX_SESSIONS = 20; +/** Batching window that coalesces PTY bursts into fewer IPC output events. */ +const OUTPUT_FLUSH_DELAY_MS = 2; +/** IPC queue level (inbound pending + outbound in-flight) that pauses PTY reads until the pipe drains. */ +const OUTPUT_PAUSE_QUEUE_BYTES = 768 * 1024; +/** IPC queue level that resumes a pressure-paused PTY; the band below pause avoids flapping. */ +const OUTPUT_RESUME_QUEUE_BYTES = 512 * 1024; +/** Per-session pending bound. Reachable only when PTY pause fails to stem output while IPC is + * saturated for a sustained flood; the kill is a last resort so one session cannot OOM the host. */ +const SESSION_MAX_PENDING_OUTPUT_BYTES = 64 * 1024 * 1024; +/** Longest an exited session may wait for pending output to drain before the exit publishes anyway. */ +const EXIT_OUTPUT_DEADLINE_MS = 2_000; /** Containment operations owned by one PTY host session. */ export interface PtyProcessScope { @@ -38,12 +49,20 @@ export interface PtyHostProcessRuntimeOptions { } interface HostSession { + readonly sessionId: string; readonly pty: IPty; readonly scope: PtyProcessScope; readonly dataDisposable: { dispose(): void }; readonly exitDisposable: { dispose(): void }; commandSeq: bigint; outputSeq: bigint; + pendingOutput: NodeBuffer.Buffer[]; + pendingOutputBytes: number; + flushTimer: ReturnType | null; + pausedForPressure: boolean; + pressureRetryScheduled: boolean; + exited: { readonly code: number; readonly signal: number | null } | null; + exitDeadline: ReturnType | null; closeReason: | "natural" | "user-close" @@ -97,17 +116,17 @@ export class PtyHostProcessRuntime { case "command.resize": this.applyCommand(message); return; - case "inspectChildren": + case "inspectChildren": { + const session = this.requireSession(message.sessionId); this.options.publish({ contractVersion: 1, kind: "children", sessionId: message.sessionId, hostGeneration: message.hostGeneration, - hasChildren: await this.requireSession( - message.sessionId, - ).scope.hasChildren(), + hasChildren: await session.scope.hasChildren(), }); return; + } case "close": await this.closeSession( message.sessionId, @@ -136,6 +155,10 @@ export class PtyHostProcessRuntime { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer); this.heartbeatTimer = null; for (const session of this.sessions.values()) { + if (session.flushTimer !== null) clearTimeout(session.flushTimer); + if (session.exitDeadline !== null) clearTimeout(session.exitDeadline); + session.pendingOutput = []; + session.pendingOutputBytes = 0; session.dataDisposable.dispose(); session.exitDisposable.dispose(); session.scope.dispose(); @@ -199,10 +222,18 @@ export class PtyHostProcessRuntime { resolveExit = resolve; }); const session: HostSession = { + sessionId: message.sessionId, pty, scope, commandSeq: 0n, outputSeq: 0n, + pendingOutput: [], + pendingOutputBytes: 0, + flushTimer: null, + pausedForPressure: false, + pressureRetryScheduled: false, + exited: null, + exitDeadline: null, closeReason: "natural", exitPromise, resolveExit, @@ -270,6 +301,8 @@ export class PtyHostProcessRuntime { >, ): void { const session = this.requireSession(message.sessionId); + // The PTY is dead and only its exit event is pending; writing would throw and kill the host. + if (session.exited) return; const sequence = BigInt(message.commandSeq); if (sequence !== session.commandSeq + 1n) throw new Error("PTY command sequence is out of order"); @@ -294,23 +327,120 @@ export class PtyHostProcessRuntime { const session = this.sessions.get(sessionId); if (!session) return; const bytes = NodeBuffer.Buffer.isBuffer(data) ? data : NodeBuffer.Buffer.from(data, "utf8"); - for ( - let offset = 0; - offset < bytes.length; - offset += PTY_HOST_MAX_DATA_BYTES - ) { - const chunk = bytes.subarray(offset, offset + PTY_HOST_MAX_DATA_BYTES); + if (bytes.length === 0) return; + session.pendingOutput.push(bytes); + session.pendingOutputBytes += bytes.length; + if (session.pendingOutputBytes > SESSION_MAX_PENDING_OUTPUT_BYTES) { + // The exit reports "natural" because the protocol has no flood-kill reason; bytes dropped here were never sequenced. + session.pendingOutput = []; + session.pendingOutputBytes = 0; + session.pty.kill(); + return; + } + if (session.pendingOutputBytes >= PTY_HOST_MAX_DATA_BYTES) this.flushOutput(session); + this.scheduleFlush(session); + } + + private scheduleFlush(session: HostSession): void { + if ( + session.flushTimer !== null || + session.pressureRetryScheduled || + session.pendingOutputBytes === 0 + ) return; + session.flushTimer = setTimeout(() => { + session.flushTimer = null; + if (this.sessions.get(session.sessionId) === session) this.flushOutput(session); + if (session.pendingOutputBytes > 0) this.scheduleFlush(session); + }, OUTPUT_FLUSH_DELAY_MS); + } + + /** Emits pending output as ≤64 KiB events, applying PTY backpressure while IPC is saturated. */ + private flushOutput(session: HostSession): void { + if (session.pendingOutputBytes === 0) { + this.maybePublishExit(session); + return; + } + if (this.isPressured()) { + this.applyPressure(session); + return; + } + const data = session.pendingOutput.length === 1 + ? session.pendingOutput[0]! + : NodeBuffer.Buffer.concat(session.pendingOutput, session.pendingOutputBytes); + session.pendingOutput = []; + session.pendingOutputBytes = 0; + this.emitChunks(session, data); + } + + private emitChunks(session: HostSession, data: NodeBuffer.Buffer): void { + for (let offset = 0; offset < data.length; offset += PTY_HOST_MAX_DATA_BYTES) { + if (this.isPressured()) { + // publish is synchronous, so the re-stashed remainder cannot be reentered mid-loop. + session.pendingOutput = [data.subarray(offset)]; + session.pendingOutputBytes = data.length - offset; + this.applyPressure(session); + return; + } + const chunk = data.subarray(offset, offset + PTY_HOST_MAX_DATA_BYTES); if (chunk.length === 0) continue; session.outputSeq += 1n; this.options.publish({ contractVersion: 1, kind: "output", - sessionId, + sessionId: session.sessionId, hostGeneration: this.requireGeneration(), outputSeq: session.outputSeq.toString(), dataBase64: chunk.toString("base64"), }); } + this.maybePublishExit(session); + } + + private isPressured(): boolean { + return (this.options.queueBytes?.() ?? 0) > OUTPUT_PAUSE_QUEUE_BYTES; + } + + /** Pauses PTY reads and retries the flush once outbound IPC drains below the resume level. */ + private applyPressure(session: HostSession): void { + if (!session.pausedForPressure) { + session.pausedForPressure = true; + session.pty.pause(); + } + if (session.pressureRetryScheduled) return; + session.pressureRetryScheduled = true; + setTimeout(() => { + session.pressureRetryScheduled = false; + if (this.sessions.get(session.sessionId) !== session) return; + if ((this.options.queueBytes?.() ?? 0) > OUTPUT_RESUME_QUEUE_BYTES) { + this.applyPressure(session); + return; + } + session.pausedForPressure = false; + session.pty.resume(); + this.flushOutput(session); + if (session.pendingOutputBytes > 0) this.scheduleFlush(session); + }, OUTPUT_FLUSH_DELAY_MS); + } + + private maybePublishExit(session: HostSession): void { + if (!session.exited || session.pendingOutputBytes > 0) return; + if (session.exitDeadline !== null) { + clearTimeout(session.exitDeadline); + session.exitDeadline = null; + } + this.options.publish({ + contractVersion: 1, + kind: "exit", + sessionId: session.sessionId, + hostGeneration: this.requireGeneration(), + finalOutputSeq: session.outputSeq.toString(), + code: session.exited.code, + signal: session.exited.signal, + reason: session.closeReason, + }); + this.sessions.delete(session.sessionId); + session.resolveExit(); + session.scope.dispose(); } private async closeSession( @@ -320,6 +450,12 @@ export class PtyHostProcessRuntime { graceful = false, ): Promise { const session = this.requireSession(sessionId); + // The PTY already exited; only its pending exit event remains. Commands can no longer + // be applied, so closeSeq enforcement would reject a legitimate close and kill the host. + if (session.exited) { + await session.exitPromise; + return; + } if ( closeSeq !== undefined && BigInt(closeSeq) !== session.commandSeq + 1n @@ -340,19 +476,18 @@ export class PtyHostProcessRuntime { if (!session) return; session.dataDisposable.dispose(); session.exitDisposable.dispose(); - session.scope.dispose(); - this.sessions.delete(sessionId); - this.options.publish({ - contractVersion: 1, - kind: "exit", - sessionId, - hostGeneration: this.requireGeneration(), - finalOutputSeq: session.outputSeq.toString(), - code, - signal, - reason: session.closeReason, - }); - session.resolveExit(); + session.exited = { code, signal }; + // Scope stays alive while the exit event waits behind pending output; the deadline + // bounds that wait so a stalled IPC queue cannot wedge the serial close path. + this.flushOutput(session); + if (session.pendingOutputBytes > 0 && session.exitDeadline === null) { + session.exitDeadline = setTimeout(() => { + session.exitDeadline = null; + session.pendingOutput = []; + session.pendingOutputBytes = 0; + this.maybePublishExit(session); + }, EXIT_OUTPUT_DEADLINE_MS); + } } private publishHeartbeat(): void { diff --git a/apps/server/src/features/terminal/sessions/terminal-replay-buffer.ts b/apps/server/src/features/terminal/sessions/terminal-replay-buffer.ts index f34560f4b..7ab6f6971 100644 --- a/apps/server/src/features/terminal/sessions/terminal-replay-buffer.ts +++ b/apps/server/src/features/terminal/sessions/terminal-replay-buffer.ts @@ -37,6 +37,8 @@ export interface TerminalHydration { interface RetainedChunk { readonly outputSeq: bigint; readonly data: Uint8Array; + /** Cumulative appended bytes through this chunk, including evicted prefixes. */ + readonly endBytes: number; } interface RetainedCheckpoint { @@ -56,7 +58,11 @@ export function replayBytesForScrollback(scrollback: number): number { /** Byte-bounded output retention and checkpoint selection for one shell session. */ export class TerminalReplayBuffer { private readonly chunks: RetainedChunk[] = []; + /** Index of the oldest retained chunk; eviction advances it instead of shifting. */ + private head = 0; private retainedBytes = 0; + private evictedBytes = 0; + private appendedBytes = 0; private latestOutputSeq = 0n; private checkpoint: RetainedCheckpoint | null = null; @@ -73,7 +79,8 @@ export class TerminalReplayBuffer { throw new Error("Terminal replay output exceeds the batch bound"); } const retained = Uint8Array.from(data); - this.chunks.push({ outputSeq, data: retained }); + this.appendedBytes += retained.byteLength; + this.chunks.push({ outputSeq, data: retained, endBytes: this.appendedBytes }); this.retainedBytes += retained.byteLength; this.latestOutputSeq = outputSeq; this.evictToCapacity(); @@ -87,7 +94,7 @@ export class TerminalReplayBuffer { checkpoint.data.byteLength < 1 || checkpoint.data.byteLength > TERMINAL_MAX_CHECKPOINT_BYTES || baseOutputSeq > this.latestOutputSeq || - (this.chunks.length > 0 && baseOutputSeq + 1n < this.chunks[0]!.outputSeq) + (this.head < this.chunks.length && baseOutputSeq + 1n < this.chunks[this.head]!.outputSeq) ) { return "rejected"; } @@ -138,7 +145,7 @@ export class TerminalReplayBuffer { readonly checkpointRequested: boolean; readonly checkpoint: RetainedCheckpoint | null; } { - const retainedFromSeq = this.chunks[0]?.outputSeq ?? this.latestOutputSeq; + const retainedFromSeq = this.chunks[this.head]?.outputSeq ?? this.latestOutputSeq; const checkpointRequested = input.checkpointSeq !== null; const checkpoint = checkpointRequested && this.checkpoint?.baseOutputSeq === input.checkpointSeq ? this.checkpoint @@ -146,7 +153,7 @@ export class TerminalReplayBuffer { return { retainedFromSeq, requestedWasEvicted: - this.chunks.length > 0 && input.requestedAfterSeq + 1n < retainedFromSeq, + this.head < this.chunks.length && input.requestedAfterSeq + 1n < retainedFromSeq, checkpointRequested, checkpoint, }; @@ -246,35 +253,50 @@ export class TerminalReplayBuffer { } private copyChunksAfter(outputSeq: bigint): ReadonlyArray { - return Object.freeze( - this.chunks - .filter((chunk) => chunk.outputSeq > outputSeq) - .map((chunk) => Object.freeze({ - outputSeq: chunk.outputSeq.toString(), - data: Uint8Array.from(chunk.data), - })), - ); + const first = this.chunks[this.head]; + if (!first) return Object.freeze([]); + const start = Math.max(this.head, this.head + Number(outputSeq - first.outputSeq) + 1); + const output: TerminalReplayChunk[] = []; + for (let index = start; index < this.chunks.length; index += 1) { + const chunk = this.chunks[index]!; + output.push(Object.freeze({ + outputSeq: chunk.outputSeq.toString(), + data: Uint8Array.from(chunk.data), + })); + } + return Object.freeze(output); } private bytesAfter(outputSeq: bigint): number { - return this.chunks.reduce( - (total, chunk) => total + (chunk.outputSeq > outputSeq ? chunk.data.byteLength : 0), - 0, - ); + const first = this.chunks[this.head]; + if (!first || outputSeq < first.outputSeq) return this.retainedBytes; + const chunk = this.chunks[this.head + Number(outputSeq - first.outputSeq)]; + if (!chunk) return 0; + return this.retainedBytes - (chunk.endBytes - this.evictedBytes); } private evictToCapacity(): void { - while (this.retainedBytes > this.capacityBytes && this.chunks.length > 0) { - const removed = this.chunks.shift(); - if (removed) this.retainedBytes -= removed.data.byteLength; + while (this.retainedBytes > this.capacityBytes && this.head < this.chunks.length) { + const removed = this.chunks[this.head]!; + this.retainedBytes -= removed.data.byteLength; + this.evictedBytes += removed.data.byteLength; + this.head += 1; + } + if (this.head === this.chunks.length) { + this.chunks.length = 0; + this.head = 0; + } else if (this.head >= 1024 && this.head * 2 >= this.chunks.length) { + // Compact only when the ghost prefix is large enough to matter; small heads amortize. + this.chunks.splice(0, this.head); + this.head = 0; } } private invalidateUnusableCheckpoint(): void { if ( this.checkpoint && - ((this.chunks.length > 0 && - this.checkpoint.baseOutputSeq + 1n < this.chunks[0]!.outputSeq) || + ((this.head < this.chunks.length && + this.checkpoint.baseOutputSeq + 1n < this.chunks[this.head]!.outputSeq) || this.checkpoint.data.byteLength + this.bytesAfter(this.checkpoint.baseOutputSeq) > TERMINAL_MAX_REPLAY_BYTES) ) { diff --git a/apps/server/src/features/terminal/sessions/terminal-session-runtime.ts b/apps/server/src/features/terminal/sessions/terminal-session-runtime.ts index 376774361..02c4fd99f 100644 --- a/apps/server/src/features/terminal/sessions/terminal-session-runtime.ts +++ b/apps/server/src/features/terminal/sessions/terminal-session-runtime.ts @@ -768,7 +768,7 @@ export class ModernTerminalSessionRuntime implements TerminalSessionRuntime { const data = Buffer.from(event.dataBase64, "base64"); record.replay.append(sequence, data); record.receivedOutputSeq = sequence; - this.publishHeadless({ kind: "output", sessionId: record.sessionId, data: Uint8Array.from(data) }); + this.publishHeadless({ kind: "output", sessionId: record.sessionId, data }); this.publishAttachedOutput(record, sequence, data); } catch { this.failSession(record, "protocol-failure"); @@ -787,7 +787,7 @@ export class ModernTerminalSessionRuntime implements TerminalSessionRuntime { hostGeneration: record.hostGeneration, attachmentEpoch: record.attachment.epoch.toString(), outputSeq: sequence.toString(), - data: Uint8Array.from(data), + data, }); }