trails/apps/planner/app/lib/yjs-server.test.ts
Ullrich Schäfer e34a06ff5a
fix(planner): raise WS frame cap above doc cap (fixes sync reconnect loop)
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) <noreply@anthropic.com>
2026-07-14 08:58:24 +02:00

104 lines
3.5 KiB
TypeScript

import { describe, it, expect } from "vitest";
import {
countActiveSessions,
docByteSize,
getOrCreateDoc,
deleteDoc,
MAX_MESSAGE_BYTES,
MAX_DOC_BYTES,
} from "./yjs-server.ts";
describe("countActiveSessions", () => {
it("is 0 for an empty iterable (regression: prod gauge saw -182 drift)", () => {
expect(countActiveSessions([])).toBe(0);
});
it("counts a single session with one client as 1", () => {
expect(countActiveSessions([{ sessionId: "s1" }])).toBe(1);
});
it("deduplicates multiple clients on the same session", () => {
expect(
countActiveSessions([
{ sessionId: "s1" },
{ sessionId: "s1" },
{ sessionId: "s1" },
]),
).toBe(1);
});
it("counts distinct sessions across clients", () => {
expect(
countActiveSessions([
{ sessionId: "s1" },
{ sessionId: "s2" },
{ sessionId: "s1" },
{ sessionId: "s3" },
]),
).toBe(3);
});
it("doesn't go negative under the reconnect pattern that broke the old inc/dec logic", () => {
// Simulate: client connects, client disconnects, reconnects,
// disconnects again — five times over. The old code would have
// decremented once per cycle (because `docs` stayed cached, so
// the re-connect's `isNewSession` check skipped inc). Here we
// derive from live state, so each empty snapshot is 0, not -N.
for (let i = 0; i < 5; i++) {
expect(countActiveSessions([{ sessionId: "s1" }])).toBe(1); // reconnect
expect(countActiveSessions([])).toBe(0); // disconnect
}
});
});
describe("doc-size caps", () => {
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", () => {
expect(docByteSize("no-such-session")).toBe(0);
});
it("docByteSize tracks growth and stays below MAX_DOC_BYTES for a realistic route", () => {
const sid = "size-test";
const doc = getOrCreateDoc(sid);
try {
const empty = docByteSize(sid);
// Push a few hundred waypoints — typical multi-day route.
const arr = doc.getArray<{ lat: number; lon: number }>("waypoints");
doc.transact(() => {
for (let i = 0; i < 500; i++) {
arr.push([{ lat: 52 + i / 1000, lon: 13 + i / 1000 }]);
}
});
const filled = docByteSize(sid);
expect(filled).toBeGreaterThan(empty);
// Several MB is still well under the cap — abuse needs to be much bigger.
expect(filled).toBeLessThan(MAX_DOC_BYTES);
} finally {
deleteDoc(sid);
}
});
it("docByteSize crosses MAX_DOC_BYTES when fed enough garbage (smoke test for the guard)", () => {
const sid = "size-cap-test";
const doc = getOrCreateDoc(sid);
try {
// Yjs string updates compress, but raw bytes in a Y.Text approach
// the underlying state size. Push a ~6MB string in one go.
const txt = doc.getText("blob");
const chunk = "x".repeat(64 * 1024); // 64 KB per insert
doc.transact(() => {
for (let i = 0; i < 100; i++) txt.insert(i * chunk.length, chunk);
});
expect(docByteSize(sid)).toBeGreaterThan(MAX_DOC_BYTES);
} finally {
deleteDoc(sid);
}
});
});