From e7c5ef98b28768280f0caa4f99aeabc1304bbe8b Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 7 Oct 2026 08:34:53 +0200 Subject: [PATCH] fix(client): reject a message over the daemon's size limit instead of reconnecting in a loop The daemon closes the WebSocket with 1009 when a message exceeds its 100 MiB limit. A workspace or agent creation that loses its connection mid-request stays pending and, after reconnecting, finds no record of itself on the daemon (the oversized frame was never read), so it resubmits the same frame and the connection drops again every couple of seconds until the app reloads. The client now checks each outbound frame against the same limit, which protocol exports and the daemon sets explicitly, and fails that request with a size error while the connection stays up. Refs #5501 --- packages/client/src/daemon-client.ts | 22 +++++++ .../protocol/src/websocket-message-limit.ts | 2 + packages/server/package.json | 2 +- .../daemon-e2e/oversized-message.e2e.test.ts | 58 +++++++++++++++++++ .../server/src/server/websocket-server.ts | 2 + 5 files changed, 85 insertions(+), 1 deletion(-) create mode 100644 packages/protocol/src/websocket-message-limit.ts create mode 100644 packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index f900fe41e5..1fc580cd25 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -13,6 +13,7 @@ import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, @@ -1132,6 +1133,25 @@ function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number return frame.byteLength; } +function assertTransportFrameFits(frame: string | Uint8Array | ArrayBuffer): void { + if (typeof frame !== "string") { + if (frame.byteLength > MAX_WEBSOCKET_MESSAGE_BYTES) + throw messageTooLargeError(frame.byteLength); + return; + } + // UTF-8 needs at most three bytes per UTF-16 unit, so only a string near the limit is measured. + if (frame.length * 3 <= MAX_WEBSOCKET_MESSAGE_BYTES) return; + const size = new TextEncoder().encode(frame).byteLength; + if (size > MAX_WEBSOCKET_MESSAGE_BYTES) throw messageTooLargeError(size); +} + +function messageTooLargeError(size: number): Error { + const toMiB = (bytes: number) => (bytes / (1024 * 1024)).toFixed(1); + return new Error( + `Message is too large to send (${toMiB(size)} MiB). The limit is ${toMiB(MAX_WEBSOCKET_MESSAGE_BYTES)} MiB, so send fewer or smaller attachments.`, + ); +} + function describeInboundTransportFrame( frame: unknown, rawBytes: Uint8Array | null, @@ -1767,6 +1787,8 @@ export class DaemonClient { if (!this.transport) { throw new DaemonConnectionError("Transport not connected"); } + // The daemon closes the connection on an oversized message, and creations resubmit on reconnect. + assertTransportFrameFits(frame); const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", { kind: typeof frame === "string" ? "text" : "binary", size: String(getTransportFrameSize(frame)), diff --git a/packages/protocol/src/websocket-message-limit.ts b/packages/protocol/src/websocket-message-limit.ts new file mode 100644 index 0000000000..05a095003d --- /dev/null +++ b/packages/protocol/src/websocket-message-limit.ts @@ -0,0 +1,2 @@ +/** Largest WebSocket message the daemon accepts. A bigger one closes the connection with 1009. */ +export const MAX_WEBSOCKET_MESSAGE_BYTES = 100 * 1024 * 1024; diff --git a/packages/server/package.json b/packages/server/package.json index b80941d56f..5576a2e713 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -89,7 +89,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", + "test:integration": "vitest run --maxWorkers=1 src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts && vitest run --maxWorkers=1 src/server/daemon-e2e/git-operations.e2e.test.ts -t \"merges into\" && vitest run --maxWorkers=1 src/server/session.create-agent-worktree-autoarchive.e2e.test.ts -t \"creates a worktree and auto-archives both\" && vitest run --maxWorkers=1 src/server/agent/providers/opencode-bridge.local.e2e.test.ts -t \"versioned runtime\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts b/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts new file mode 100644 index 0000000000..fc68fe44f4 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/oversized-message.e2e.test.ts @@ -0,0 +1,58 @@ +import { describe, test, expect, beforeEach, afterEach } from "vitest"; +import { mkdtempSync, rmSync } from "fs"; +import { tmpdir } from "os"; +import path from "path"; +import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; + +let ctx: DaemonTestContext; +let cwd: string; + +beforeEach(async () => { + ctx = await createDaemonTestContext(); + cwd = mkdtempSync(path.join(tmpdir(), "daemon-e2e-")); +}); + +afterEach(async () => { + await ctx.cleanup(); + rmSync(cwd, { recursive: true, force: true }); +}, 60000); + +describe("a message larger than the daemon accepts", () => { + const oversizedImage = { data: "A".repeat(101 * 1024 * 1024), mimeType: "image/png" }; + + test("fails the creation and keeps the connection up", async () => { + const statuses: string[] = []; + const unsubscribe = ctx.client.subscribeConnectionStatus((state) => { + statuses.push(state.status); + }); + + await expect( + ctx.client.createAgent({ + provider: "claude", + cwd, + title: "Oversized first message", + initialPrompt: "Describe these screens.", + images: [oversizedImage], + }), + ).rejects.toThrow(/too large/); + unsubscribe(); + + expect(statuses).toEqual(["connected"]); + expect((await ctx.client.fetchAgents()).entries).toEqual([]); + }, 30000); + + test("fails the send and keeps the connection up", async () => { + const agent = await ctx.client.createAgent({ provider: "claude", cwd, title: "Agent" }); + const statuses: string[] = []; + const unsubscribe = ctx.client.subscribeConnectionStatus((state) => { + statuses.push(state.status); + }); + + await expect( + ctx.client.sendMessage(agent.id, "Describe these screens.", { images: [oversizedImage] }), + ).rejects.toThrow(/too large/); + unsubscribe(); + + expect(statuses).toEqual(["connected"]); + }, 30000); +}); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 219e04b0c2..5e93681a66 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -33,6 +33,7 @@ import { } from "./messages.js"; import { asUint8Array, decodeBinaryFrame } from "@getpaseo/protocol/binary-frames/index"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; +import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import type { HostnamesConfig } from "./hostnames.js"; import { isHostnameAllowed } from "./hostnames.js"; import { @@ -822,6 +823,7 @@ export class VoiceAssistantWebSocketServer { const wss = new WebSocketServer({ server, path: "/ws", + maxPayload: MAX_WEBSOCKET_MESSAGE_BYTES, handleProtocols: (protocols) => selectWebSocketProtocol(protocols, password), verifyClient: ({ req }, callback) => { this.verifyWsUpgrade(