mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-24 02:02:42 +00:00
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
This commit is contained in:
@@ -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)),
|
||||
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user