From e34a06ff5a4e46a834be85f8ac63d7659a2422f2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ullrich=20Sch=C3=A4fer?= Date: Tue, 14 Jul 2026 08:58:24 +0200 Subject: [PATCH] fix(planner): raise WS frame cap above doc cap (fixes sync reconnect loop) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Yjs sync WebSocket had MAX_MESSAGE_BYTES = 256 KB (per frame) but MAX_DOC_BYTES = 5 MB (per session doc). A full-state sync frame carries the entire doc, so any session doc between 256 KB and 5 MB was allowed to exist yet could never sync: the client's sync frame tripped the 256 KB per-frame cap, the server closed it (1008), the client reconnected, resent the same oversized frame, and looped forever — the UI froze, the connection flapped Verbunden/Verbinde, and the network tab filled with 101 reconnects. Real routes hit this fast because the BRouter geometry is stored in the doc: a 4-waypoint, 77 km cycling route was already 305 KB. Fix: the per-frame cap must be >= the doc cap (a full-doc sync must fit in one frame). Set MAX_MESSAGE_BYTES = MAX_DOC_BYTES + 256 KB overhead; the 5 MB doc cap stays the real size/abuse guard. Updated the test that had codified the inverted ordering. Verified: planner typecheck + lint clean, yjs-server 9/9, planner suite 183/183. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/planner/app/lib/yjs-server.test.ts | 9 ++++++--- apps/planner/app/lib/yjs-server.ts | 18 ++++++++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/planner/app/lib/yjs-server.test.ts b/apps/planner/app/lib/yjs-server.test.ts index e5eb4f4..308d0e6 100644 --- a/apps/planner/app/lib/yjs-server.test.ts +++ b/apps/planner/app/lib/yjs-server.test.ts @@ -52,9 +52,12 @@ describe("countActiveSessions", () => { }); describe("doc-size caps", () => { - it("MAX_MESSAGE_BYTES + MAX_DOC_BYTES are positive and ordered sanely", () => { - expect(MAX_MESSAGE_BYTES).toBeGreaterThan(0); - expect(MAX_DOC_BYTES).toBeGreaterThan(MAX_MESSAGE_BYTES); + it("the per-frame cap can carry a full-doc sync (message cap >= doc cap)", () => { + expect(MAX_DOC_BYTES).toBeGreaterThan(0); + // A full-state sync frame contains the entire doc, so a per-frame cap + // below the doc cap makes docs between the two sizes unsyncable (reconnect + // loop). The frame cap must be at least the doc cap, plus protocol room. + expect(MAX_MESSAGE_BYTES).toBeGreaterThanOrEqual(MAX_DOC_BYTES); }); it("docByteSize returns 0 for an unknown session", () => { diff --git a/apps/planner/app/lib/yjs-server.ts b/apps/planner/app/lib/yjs-server.ts index de48bfb..f917812 100644 --- a/apps/planner/app/lib/yjs-server.ts +++ b/apps/planner/app/lib/yjs-server.ts @@ -11,13 +11,19 @@ import { plannerActiveSessions, plannerConnectedClients } from "./metrics.server const messageSync = 0; const messageAwareness = 1; -// Bound the per-WebSocket-message size and per-session doc size. Both -// guards exist to stop a single connected client from filling memory -// (or the persisted `yjs_state` blob) without limit. Picked generously -// — a multi-day route with hundreds of waypoints is well under 100 KB -// of serialized Yjs state — but small enough to refuse abuse. -export const MAX_MESSAGE_BYTES = 256 * 1024; // 256 KB per WS frame +// Bound the per-session doc size — the real abuse guard, stopping a client +// from filling memory or the persisted `yjs_state` blob without limit. A +// route with hundreds of waypoints is well under this once its BRouter +// geometry is stored in the doc, which is what actually drives the size. export const MAX_DOC_BYTES = 5 * 1024 * 1024; // 5 MB per session doc +// Per-WebSocket-frame cap. It MUST exceed the doc cap: a full-state sync +// (initial load, or any reconnect) puts the entire doc into a single frame, +// so a frame limit below MAX_DOC_BYTES makes any legal-but-larger-than-the- +// -frame doc permanently unsyncable — the client's sync frame is rejected, +// it reconnects, resends the same frame, and loops forever (observed at the +// old 256 KB cap: a 305 KB / 4-waypoint route froze in a reconnect storm). +// The doc cap remains the size limit; this just leaves room to transmit it. +export const MAX_MESSAGE_BYTES = MAX_DOC_BYTES + 256 * 1024; // doc + protocol overhead // WebSocket close code for policy-violation responses (1008 is the // standard RFC 6455 code for "received a message that violates policy"). const POLICY_VIOLATION = 1008;