From 7a9aeb518124e5b6bcd1138326956fb2ae40bd8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20T=C3=B6rcsv=C3=A1ri?= Date: Fri, 28 Aug 2026 16:14:44 +0200 Subject: [PATCH] presence: fix stale peer selections after delete, select-all lag, ghost-peer removal (findings group Y) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - wasm core: CORE::onDocChanged() from both collab listeners (local commit AND remote apply) repaints peers' shapes from the live document + re-checks the local selection post-settle; PresenceStart registers the bridge listener - wasm core: cursors on their own overlay trio; shapes repaint only when the non-cursor snapshot changes; new kicadCollabSetRemoteCursors (cursor-only update, PEER.id) in both TUs + merged editor + JSPI mutator allowlist - presence.ts: size-aware trailing throttle for cursor/viewport publishes (128 KB/s budget) + parsed-peers memo; presence-kicad.ts: cursor-only push when the shape signature is unchanged - gateway.ts: honor the `gone` control (removeAwarenessStates) - specs: kicad stale-after-delete gate, web ghost-peer timing (+diag), unit select-all budget (pcbjam-shared → 4573a7c) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01AScTR39aqyrY5i3ZFHmnMn --- scripts/common/shims/jspi-scheduler.js | 1 + .../kicad/presence-stale-after-delete.spec.ts | 244 ++++++++++++++++++ tests/web/presence-ghost-diag.spec.ts | 100 +++++++ tests/web/presence-ghost-peer.spec.ts | 124 +++++++++ wasm/bindings/collab_presence_core.h | 203 +++++++++++++-- wasm/bindings/eeschema_embind.cpp | 25 ++ wasm/bindings/kicad_editor_embind.cpp | 8 + wasm/bindings/pcbnew_embind.cpp | 20 ++ web/pcbjam-shared | 2 +- web/standalone/src/wasm/collab/gateway.ts | 13 + .../src/wasm/collab/presence-kicad.ts | 20 ++ .../collab/presence-select-all-lag.test.ts | 145 +++++++++++ web/standalone/src/wasm/collab/presence.ts | 56 +++- 13 files changed, 935 insertions(+), 26 deletions(-) create mode 100644 tests/kicad/presence-stale-after-delete.spec.ts create mode 100644 tests/web/presence-ghost-diag.spec.ts create mode 100644 tests/web/presence-ghost-peer.spec.ts create mode 100644 web/standalone/src/wasm/collab/presence-select-all-lag.test.ts diff --git a/scripts/common/shims/jspi-scheduler.js b/scripts/common/shims/jspi-scheduler.js index 9c8a5c8..e72bba0 100644 --- a/scripts/common/shims/jspi-scheduler.js +++ b/scripts/common/shims/jspi-scheduler.js @@ -96,6 +96,7 @@ "kicadCollabApply", "kicadCollabApplyItems", "kicadCollabSnapshot", "kicadCollabSnapshotItems", "kicadCollabPresenceStart", "kicadCollabSetRemote", + "kicadCollabSetRemoteCursors", "kicadCollabSetPins", "kicadCollabSetStyle", "kicadCollabSetViewport", "kicadCollabFitViewport", "kicadCollabReleaseSelection", "kicadSetColorTheme", diff --git a/tests/kicad/presence-stale-after-delete.spec.ts b/tests/kicad/presence-stale-after-delete.spec.ts new file mode 100644 index 0000000..562438c --- /dev/null +++ b/tests/kicad/presence-stale-after-delete.spec.ts @@ -0,0 +1,244 @@ +import type { Page } from "@playwright/test"; +import { settledShot } from "../e2e/utils/element-tracker"; +import { test, expect } from "./fixtures"; + +/** + * Findings group Y (Y-1/Y-3, "selection kept on deleted element") — regression + * gate. Written red on 2026-08-28, green with the fix the same day. + * + * Was: the presence overlay (collab_presence_core.h) was cleared + redrawn + * ONLY on kicadCollabSetRemote / SetPins / SetStyle / zoom. A document change + * — the remote apply of a peer's delete, or a local delete — never scheduled + * a redraw, so a peer's selection box / cross-app ghost kept being painted at + * the dead item's last bbox until some unrelated awareness change arrived. + * Now: every collab-listener trigger (local commit AND remote apply) queues + * CORE::onDocChanged() on the apply coroutine — shapes repaint from the live + * document and the local selection is re-checked post-settle. + * + * Second half: the LOCAL selection emit used to be triggered only by canvas + * LEFT_UP / RIGHT_UP / KEY_UP / wheel events (plus a listener piggyback that + * could run before the commit finished). A delete reaching the commit without + * such an event (menu, toolbar, context menu, programmatic/remote release) + * left the dead uuids published for every peer. onDocChanged() re-checks + * after the commit body completes. + */ + +const WIRE1 = "22222222-0000-0000-0000-000000000001"; +const WIRE2 = "22222222-0000-0000-0000-000000000002"; +const SAMPLE_SCH = `(kicad_sch +\t(version 20260306) +\t(generator "eeschema") +\t(generator_version "9.0") +\t(uuid "11111111-1111-1111-1111-111111111111") +\t(paper "A4") +\t(lib_symbols) +\t(wire (pts (xy 50.8 50.8) (xy 101.6 50.8)) (stroke (width 0) (type default)) (uuid "${WIRE1}")) +\t(wire (pts (xy 50.8 76.2) (xy 101.6 76.2)) (stroke (width 0) (type default)) (uuid "${WIRE2}")) +\t(sheet_instances (path "/" (page "1"))) +) +`; + +type FS = { mkdirTree(p: string): void; writeFile(p: string, d: string): void }; +type Mod = { + kicadOpenFile(p: string): unknown; + kicadCollabPresenceStart(): void; + kicadCollabSetRemote(j: string): void; + kicadCollabGetSelection(): string; + kicadCollabTestSelectByUuid(id: string): boolean; + kicadCollabTestSelectFirst(): string; + kicadCollabTestRemoveItem(id: string): boolean; + kicadCollabTestListItems(): string; +}; +type W = { + FS: FS; + Module: Mod; + kicadCollab?: Record; + __selEmits?: string[][]; +}; + +async function galPanel(page: Page) { + const glId = await page.evaluate(() => { + const visible = Array.from(document.querySelectorAll('[id^="glcanvas-"]')) + .map((c) => c as HTMLCanvasElement) + .find( + (c) => + window.getComputedStyle(c).display !== "none" && + c.getBoundingClientRect().width > 0, + ); + return visible?.id ?? null; + }); + expect(glId, "no visible GAL panel found").toBeTruthy(); + return page.locator(`#${glId}`); +} + +async function bootAndOpen(page: Page): Promise { + await page.goto("/kicad/eeschema.html"); + await expect(page.locator("#canvas")).toBeVisible({ timeout: 90000 }); + await page.waitForFunction(() => !!window.wxElementRegistry, null, { timeout: 90000 }); + await page.waitForFunction( + () => { + const m = (window as unknown as { Module?: Partial }).Module; + return ( + typeof m?.kicadOpenFile === "function" && + typeof m?.kicadCollabSetRemote === "function" && + typeof m?.kicadCollabTestRemoveItem === "function" + ); + }, + null, + { timeout: 90000 }, + ); + await page.waitForFunction( + () => + !!window.wxElementRegistry && + window.wxElementRegistry + .findAll({ visible: true }) + .some((e) => /Frame$/.test(e.typeName) || (e.name || "").endsWith("Frame")), + null, + { timeout: 90000 }, + ); + await page.evaluate( + ({ content }) => { + const w = window as unknown as W; + const dir = "/home/kicad/documents"; + try { + w.FS.mkdirTree(dir); + } catch { + /* exists */ + } + const p = `${dir}/stale.kicad_sch`; + w.FS.writeFile(p, content); + w.Module.kicadOpenFile(p); + }, + { content: SAMPLE_SCH }, + ); + await expect + .poll(() => page.title(), { timeout: 60000, intervals: [500] }) + .toMatch(/stale/i); + await page.evaluate(() => { + const w = window as unknown as W; + w.__selEmits = []; + w.kicadCollab = { + ...w.kicadCollab, + onSelection: (json: string) => w.__selEmits!.push(JSON.parse(json)), + }; + w.Module.kicadCollabPresenceStart(); + }); +} + +const setRemote = (page: Page, snapshot: unknown) => + page.evaluate( + (s) => (window as unknown as W).Module.kicadCollabSetRemote(JSON.stringify(s)), + snapshot, + ); + +const removeItem = (page: Page, id: string) => + page.evaluate((u) => (window as unknown as W).Module.kicadCollabTestRemoveItem(u), id); + +const lastEmit = (page: Page) => + page.evaluate(() => (window as unknown as W).__selEmits!.at(-1) ?? null); + +for (const kind of ["selection", "xsel"] as const) { + test(`remote ${kind} outline is dropped when the item is deleted (redraw on doc change)`, async ({ + page, + }) => { + await bootAndOpen(page); + const canvas = await galPanel(page); + const baseline = await settledShot(canvas); + + // bob (same room, or a pcbnew tab via cross-app xsel) has WIRE1 selected. + const peer = { + id: "bob", + name: "bob", + color: "#ef4444", + cursor: null, + selection: kind === "selection" ? [WIRE1] : [], + ...(kind === "xsel" ? { xsel: [WIRE1] } : {}), + }; + await setRemote(page, { peers: [peer] }); + await expect + .poll(async () => !(await canvas.screenshot()).equals(baseline), { + timeout: 15000, + intervals: [500], + message: "peer outline never painted", + }) + .toBe(true); + + // WIRE1 is deleted through a real SCH_COMMIT — the same path a remote + // apply (peer deleted it) or a local menu delete takes. + expect(await removeItem(page, WIRE1)).toBe(true); + await page.waitForTimeout(1500); + const afterDelete = await settledShot(canvas); + + // Ground truth for "what the overlay should look like now": force the + // overlay to rebuild from the SAME peer snapshot — WIRE1 no longer + // resolves, so nothing is drawn for it. + await setRemote(page, { peers: [peer] }); + await page.waitForTimeout(1500); + const afterRepush = await settledShot(canvas); + + expect( + afterDelete.equals(afterRepush), + `${kind}: overlay still shows bob's outline on the deleted wire (no redraw on doc change)`, + ).toBe(true); + }); +} + +test("local emit: a commit-path delete of the selected item publishes the empty selection", async ({ + page, +}) => { + await bootAndOpen(page); + const id = await page.evaluate(() => + (window as unknown as W).Module.kicadCollabTestSelectFirst(), + ); + expect(id).toBeTruthy(); + await expect.poll(() => lastEmit(page), { timeout: 10000 }).toEqual([id]); + + // Delete WITHOUT a canvas key/mouse event (menu / toolbar / context-menu / + // remote-release shaped): real SCH_COMMIT, item leaves the selection. + expect(await removeItem(page, id)).toBe(true); + await expect + .poll( + () => + page.evaluate(() => + JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()), + ), + { timeout: 10000, message: "the commit should have dropped the item from the live selection" }, + ) + .toEqual([]); + + // Peers learn the selection is gone (was: the last emit stayed [id] — + // no LEFT_UP/RIGHT_UP/KEY_UP/wheel closed the delete). + await expect + .poll(() => lastEmit(page), { timeout: 5000, intervals: [250] }) + .toEqual([]); +}); + +test("local emit: keyboard Delete on the canvas does publish the empty selection (control)", async ({ + page, +}) => { + await bootAndOpen(page); + const canvas = await galPanel(page); + const box = await canvas.boundingBox(); + expect(box).toBeTruthy(); + // Give the wx canvas keyboard focus with a click on empty sheet space (this + // clears any selection — the programmatic select comes after). + await page.mouse.click(box!.x + box!.width * 0.5, box!.y + box!.height * 0.95); + await page.locator("#canvas").focus().catch(() => {}); + const id = await page.evaluate(() => + (window as unknown as W).Module.kicadCollabTestSelectFirst(), + ); + expect(id).toBeTruthy(); + await expect.poll(() => lastEmit(page), { timeout: 10000 }).toEqual([id]); + + await page.keyboard.press("Delete"); + await expect + .poll( + () => + page.evaluate(() => + JSON.parse((window as unknown as W).Module.kicadCollabGetSelection()), + ), + { timeout: 10000, message: "Delete key never deleted the selected item (focus?)" }, + ) + .toEqual([]); + await expect.poll(() => lastEmit(page), { timeout: 5000, intervals: [250] }).toEqual([]); +}); diff --git a/tests/web/presence-ghost-diag.spec.ts b/tests/web/presence-ghost-diag.spec.ts new file mode 100644 index 0000000..5771927 --- /dev/null +++ b/tests/web/presence-ghost-diag.spec.ts @@ -0,0 +1,100 @@ +import { test, expect, type Page, type BrowserContext } from '@playwright/test'; +import { openOverlayMenu } from './overlay-menu'; + +/** Diagnostic companion of presence-ghost-peer.spec.ts: records bob's inbound + * awareness entries (clientId/clock) after alice's socket is cut, so we can + * tell "alice keeps getting re-published" from "bob never times her out". */ + +const SCOPE = 'default'; +const ROUTE = 'demo.kicad_wks'; +const TITLE = /demo — Drawing Sheet Editor/i; + +const HOOK = ` + (() => { + const Orig = window.WebSocket; + const list = []; + window.__wsList = list; + window.__cutAll = false; + window.__awLog = []; + const rv = (b, p) => { let r = 0, m = 1; for (;;) { const x = b[p++]; r += (x & 127) * m; if (x < 128) return [r, p]; m *= 128; } }; + const decode = (u8) => { + try { + let p = 0, v; + [v, p] = rv(u8, p); const ch = v; + [v, p] = rv(u8, p); const type = v; + if (type !== 1) return { ch, type }; + [v, p] = rv(u8, p); + [v, p] = rv(u8, p); const n = v; + const ents = []; + for (let i = 0; i < n; i++) { + let cid, clk, len; + [cid, p] = rv(u8, p); [clk, p] = rv(u8, p); [len, p] = rv(u8, p); + const s = new TextDecoder().decode(u8.subarray(p, p + len)); p += len; + let user = null; try { user = JSON.parse(s)?.user?.id ?? (s === 'null' ? 'REMOVED' : '?'); } catch {} + ents.push({ cid, clk, user }); + } + return { ch, type, ents }; + } catch (e) { return { err: String(e) }; } + }; + window.WebSocket = new Proxy(Orig, { + construct(target, args) { + const ws = new target(...args); + list.push({ ws, url: String(args[0]), t: Date.now() }); + ws.addEventListener('message', async (ev) => { + let d = ev.data; + if (d instanceof Blob) d = await d.arrayBuffer(); + if (d instanceof ArrayBuffer) window.__awLog.push({ t: Date.now(), ...decode(new Uint8Array(d)) }); + }); + if (window.__cutAll) { ws.send = () => {}; setTimeout(() => ws.close(), 0); } + return ws; + }, + }); + window.__wsCut = (close) => { + window.__cutAll = true; + let n = 0; + for (const { ws } of list) { + if (ws.readyState !== 1) continue; + ws.send = () => {}; n++; + if (close) ws.close(); + } + return n; + }; + })(); +`; + +async function bootAs(context: BrowserContext, user: string): Promise { + const page = await context.newPage(); + await page.addInitScript(HOOK); + await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`); + await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 }); + await expect.poll(() => page.title(), { timeout: 120000, intervals: [1000] }).toMatch(TITLE); + return page; +} + +test.skip(!process.env.PRESENCE_DIAG, 'diagnostic only — PRESENCE_DIAG=1 to run'); +for (const close of [true, false]) test(`diag: what bob receives after alice ${close ? 'closes uncleanly' : 'goes half-open'}`, async ({ browser }) => { + test.setTimeout(400000); + const ctxA = await browser.newContext(); + const ctxB = await browser.newContext(); + const alice = await bootAs(ctxA, 'alice'); + const bob = await bootAs(ctxB, 'bob'); + await openOverlayMenu(bob); + await expect(bob.locator('[data-presence-user="alice"]')).toBeVisible({ timeout: 30000 }); + + const tCut = Date.now(); + const n = await alice.evaluate((c) => (window as any).__wsCut(c), close); + console.log(`[diag] cut ${n} alice sockets at ${tCut}`); + + const before = await bob.evaluate((since) => (window as any).__awLog.filter((e: any) => e.type === 1 && e.t < since).flatMap((e: any) => e.ents.map((x: any) => `${new Date(e.t).toISOString().slice(14, 19)} ch${e.ch} cid=${x.cid} clk=${x.clk} ${x.user}`)), tCut); + console.log(`[diag] bob inbound awareness BEFORE cut (last 12):\n ${before.slice(-12).join('\n ')}`); + for (let i = 1; i <= 4; i++) { + await bob.waitForTimeout(10000); + const present = await bob.locator('[data-presence-user="alice"]').count(); + const aliceWs = await alice.evaluate(() => (window as any).__wsList.map((w: any) => ({ url: w.url.slice(0, 60), state: w.ws.readyState, t: w.t }))); + const log = await bob.evaluate((since) => (window as any).__awLog.filter((e: any) => e.t >= since), tCut); + const aw = log.filter((e: any) => e.type === 1).flatMap((e: any) => e.ents.map((x: any) => `${new Date(e.t).toISOString().slice(14, 19)} ch${e.ch} cid=${x.cid} clk=${x.clk} ${x.user}`)); + console.log(`[diag] +${i * 10}s alice-in-roster=${present} aliceSockets=${JSON.stringify(aliceWs)} bobInboundAwareness(since cut)=\n ${aw.join('\n ')}`); + } + await ctxA.close(); + await ctxB.close(); +}); diff --git a/tests/web/presence-ghost-peer.spec.ts b/tests/web/presence-ghost-peer.spec.ts new file mode 100644 index 0000000..e2b045f --- /dev/null +++ b/tests/web/presence-ghost-peer.spec.ts @@ -0,0 +1,124 @@ +import { test, expect, type Page, type BrowserContext } from '@playwright/test'; +import { openOverlayMenu } from './overlay-menu'; + +/** + * REPRO (findings: "when user A disconnects, user B still sees them minutes + * later"). Two users (separate browser contexts) on demo.kicad_wks over the + * real sync stack (VITE_YJS_PROVIDER=partykit → apps/sync gateway). alice's + * websocket is then broken in two ways and bob's roster is timed: + * + * - HALF-OPEN: alice's outbound frames are dropped but the socket stays up + * (laptop sleep / wifi drop before TCP notices). The gateway sees no close + * → no tombstone; bob is left with the 30s y-protocols awareness timeout. + * - UNCLEAN CLOSE: outbound dropped, then the socket is closed (no null-state + * broadcast reaches the server). Locally (wrangler, no hibernation) the + * gateway's in-memory clientID table tombstones alice; on a hibernated DO + * that table is gone (see gateway-hub-tombstone-hibernation.test.ts) and + * this degenerates into the half-open case. + * + * Requires the partykit/gateway stack (apps/sync :3055) — skipped on the BC + * provider, where there is no server to lose the departure. + * + * MEASURED 2026-08-28 (local wrangler, serial): half-open → 30s (y-protocols + * awareness timeout, the only fallback); unclean close → tombstone within + * ~10ms, roster clears immediately. The "minutes" ghost was NOT reproduced + * locally; see docs/features/findings/groups/Y-multiplayer-presence.md. + */ + +// Both tests use the SAME demo project and the same user slugs, so they must +// never overlap: a parallel worker's alice keeps this worker's roster alive +// (that is exactly how the first run of this spec "reproduced" a 180s ghost). +test.describe.configure({ mode: 'serial' }); + +const SCOPE = 'default'; +const ROUTE = 'demo.kicad_wks'; +const TITLE = /demo — Drawing Sheet Editor/i; + +const WS_HOOK = ` + (() => { + const Orig = window.WebSocket; + const list = []; + window.__wsList = list; + window.__cutAll = false; + window.WebSocket = new Proxy(Orig, { + construct(target, args) { + const ws = new target(...args); + list.push({ ws, url: String(args[0]), t: Date.now() }); + // Once cut, the "network" stays down: any reconnect attempt dies too. + if (window.__cutAll) { ws.send = () => {}; setTimeout(() => ws.close(), 0); } + return ws; + }, + }); + window.__wsCut = (close) => { + window.__cutAll = true; + let n = 0; + for (const { ws } of list) { + if (ws.readyState !== 1) continue; + ws.send = () => {}; + n++; + if (close) ws.close(); + } + return n; + }; + window.__wsReport = () => list.map((w) => ({ url: w.url.slice(0, 50), state: w.ws.readyState, t: w.t })); + })(); +`; + +async function bootAs(context: BrowserContext, user: string): Promise { + const page = await context.newPage(); + await page.addInitScript(WS_HOOK); + await page.goto(`/${SCOPE}/projects/demo/${ROUTE}?user=${user}`); + await expect(page.locator('#canvas')).toBeVisible({ timeout: 120000 }); + await expect + .poll(() => page.title(), { timeout: 120000, intervals: [1000] }) + .toMatch(TITLE); + return page; +} + +async function timeUntilGone(page: Page, user: string, budgetMs: number): Promise { + const t0 = Date.now(); + await expect(page.locator(`[data-presence-user="${user}"]`)).toHaveCount(0, { + timeout: budgetMs, + }); + return Date.now() - t0; +} + +for (const mode of ['half-open', 'unclean-close'] as const) { + test(`alice ${mode}: bob's roster drops alice promptly`, async ({ browser }) => { + test.setTimeout(420000); + const ctxA = await browser.newContext(); + const ctxB = await browser.newContext(); + const alice = await bootAs(ctxA, 'alice'); + const bob = await bootAs(ctxB, 'bob'); + await openOverlayMenu(bob); + await expect(bob.locator('[data-presence-user="alice"]')).toBeVisible({ timeout: 30000 }); + + const wsCount = await alice.evaluate( + (close) => (window as unknown as { __wsCut(c: boolean): number }).__wsCut(close), + mode === 'unclean-close', + ); + test.skip(wsCount === 0, 'no live websocket on alice — BC stack?'); + + const t0 = Date.now(); + const seen: string[] = []; + let ms = -1; + while (Date.now() - t0 < 180000) { + const present = await bob.locator('[data-presence-user="alice"]').count(); + seen.push(`+${Math.round((Date.now() - t0) / 1000)}s:${present}`); + if (present === 0) { ms = Date.now() - t0; break; } + await bob.waitForTimeout(5000); + } + const sockets = await alice.evaluate(() => (window as unknown as { __wsReport(): unknown }).__wsReport()); + // eslint-disable-next-line no-console + console.log(`[ghost-peer] ${mode}: roster timeline ${seen.join(' ')} | alice sockets ${JSON.stringify(sockets)}`); + if (ms < 0) ms = Date.now() - t0; + test.info().annotations.push({ type: 'ghost-ms', description: `${mode}: ${ms}ms` }); + // eslint-disable-next-line no-console + console.log(`[ghost-peer] ${mode}: bob dropped alice after ${ms}ms (ws cut: ${wsCount})`); + // "promptly" = inside the awareness timeout with slack; the roster spec + // budgets 20s for a clean leave. + expect(ms, `${mode}: alice lingered on bob's roster for ${ms}ms`).toBeLessThan(45000); + await ctxA.close(); + await ctxB.close(); + }); +} diff --git a/wasm/bindings/collab_presence_core.h b/wasm/bindings/collab_presence_core.h index 46d61ef..0cf7b93 100644 --- a/wasm/bindings/collab_presence_core.h +++ b/wasm/bindings/collab_presence_core.h @@ -46,6 +46,7 @@ namespace pcbjam_presence { struct PEER { + std::string id; // awareness identity (cursor-only updates key on it) std::string name; KIGFX::COLOR4D color; bool hasCursor = false; @@ -135,9 +136,19 @@ struct CORE // chips — see the depth-layering note in collab_presence_style.h. std::shared_ptr chipOverlay; std::shared_ptr textOverlay; + // Cursors live on their own overlay trio (findings Y-4): a peer's 20 Hz + // cursor tick repaints these only; the selection/xsel/pin shapes above + // are repainted only when a selection, lock, pin or zoom changes. + std::shared_ptr cursorOverlay; + std::shared_ptr cursorChipOverlay; + std::shared_ptr cursorTextOverlay; bool started = false; bool redrawScheduled = false; + bool shapesDirty = false; + bool cursorsDirty = false; + bool docChangeScheduled = false; + std::string lastShapeSig; // setRemote dedupe: shapes vs cursor-only change bool selCheckScheduled = false; std::string lastSelectionJson; // dedupe: emit only when the payload changed long long lastCursorEmitMs = 0; @@ -328,9 +339,11 @@ struct CORE // ── remote render ───────────────────────────────────────────────────── - // Repaint the remote-peers overlay. Runs in CallAfter + COROUTINE via the + // Repaint the remote-peers overlays. Runs in CallAfter + COROUTINE via the // apply queue — serialized with the applies, same constraint as every - // other view mutation from JS. + // other view mutation from JS. Two groups, each repainted only when + // dirty: SHAPES (selection boxes, cross-app ghosts, comment pins — the + // expensive part: per-item resolve + outline geometry) and CURSORS. void redrawOverlay() { redrawScheduled = false; @@ -359,9 +372,20 @@ struct CORE if( !textOverlay ) textOverlay = makePresenceTextOverlay( view ); - overlay->Clear(); - chipOverlay->Clear(); - textOverlay->Clear(); + if( !cursorOverlay ) + { + cursorOverlay = view->MakeOverlay(); + cursorOverlay->SetDepthOffset( PRESENCE_SHAPES_DEPTH_OFFSET ); + } + + if( !cursorChipOverlay ) + { + cursorChipOverlay = view->MakeOverlay(); + cursorChipOverlay->SetDepthOffset( PRESENCE_CHIPS_DEPTH_OFFSET ); + } + + if( !cursorTextOverlay ) + cursorTextOverlay = makePresenceTextOverlay( view ); // Screen-constant sizing: px → world units, so cursors/outline widths // don't scale with zoom. MUST go through the GAL matrix @@ -369,36 +393,72 @@ struct CORE // px-per-IU, and under-sizes the drawing by ~7 orders of magnitude. double px = view->ToWorld( 1.0 ); - for( const PEER& peer : peers ) + bool shapes = shapesDirty; + bool cursors = cursorsDirty; + shapesDirty = false; + cursorsDirty = false; + + if( shapes ) { - KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color ); + overlay->Clear(); + chipOverlay->Clear(); + textOverlay->Clear(); - // Selection boxes + cross-app ghosts: editor-specific resolution. - drawPeerShapes( *this, fr, peer, color, px ); + for( const PEER& peer : peers ) + { + KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color ); - if( peer.hasCursor ) - drawCursor( overlay.get(), chipOverlay.get(), textOverlay.get(), peer.cursor, - peer.name, color, px, style ); + // Selection boxes + cross-app ghosts: editor-specific resolution. + drawPeerShapes( *this, fr, peer, color, px ); + } + + // Comment pin dots (0005) — on the CHIPS layer so selection fills + // can't reject their fragments (see drawPin). + for( const PIN& pin : pins ) + { + KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color ); + drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, pin.unread, px, style ); + } + + view->Update( overlay.get() ); + view->Update( chipOverlay.get() ); + view->Update( textOverlay.get() ); } - // Comment pin dots (0005) — on the CHIPS layer so selection fills - // can't reject their fragments (see drawPin). - for( const PIN& pin : pins ) + if( cursors ) { - KIGFX::COLOR4D color = peerColor( style, pin.name, pin.color ); - drawPin( chipOverlay.get(), pin.pos, color, pin.resolved, pin.unread, px, style ); + cursorOverlay->Clear(); + cursorChipOverlay->Clear(); + cursorTextOverlay->Clear(); + + for( const PEER& peer : peers ) + { + if( !peer.hasCursor ) + continue; + + KIGFX::COLOR4D color = peerColor( style, peer.name, peer.color ); + drawCursor( cursorOverlay.get(), cursorChipOverlay.get(), cursorTextOverlay.get(), + peer.cursor, peer.name, color, px, style ); + } + + view->Update( cursorOverlay.get() ); + view->Update( cursorChipOverlay.get() ); + view->Update( cursorTextOverlay.get() ); } - view->Update( overlay.get() ); - view->Update( chipOverlay.get() ); - view->Update( textOverlay.get() ); + if( !shapes && !cursors ) + return; + // The canvas repaints on its own only with focus/input — force it, // exactly as the cross-probe flash does. fr->GetCanvas()->ForceRefresh(); } - void scheduleRedraw() + void scheduleRedraw( bool aShapes = true, bool aCursors = true ) { + shapesDirty = shapesDirty || aShapes; + cursorsDirty = cursorsDirty || aCursors; + if( redrawScheduled ) return; @@ -411,6 +471,39 @@ struct CORE pcbjam_collab::runOnCoroutine( fr, [this]() { redrawOverlay(); } ); } + /** The document changed (local commit OR remote apply — findings Y-1/Y-3): + * peers' selection boxes may now sit on deleted/moved items, and the + * local selection may have lost items with no closing canvas event. + * Queued on the apply coroutine so it runs AFTER the commit/apply body + * that raised it: repaint the shapes from the live document and re-check + * the local selection. Coalesced per settle. */ + void onDocChanged() + { + if( docChangeScheduled ) + return; + + EDA_DRAW_FRAME* fr = frame(); + + if( !fr ) + return; + + docChangeScheduled = true; + + pcbjam_collab::runOnCoroutine( fr, [this]() + { + docChangeScheduled = false; + + if( !peers.empty() || !pins.empty() ) + { + shapesDirty = true; + cursorsDirty = true; + redrawOverlay(); + } + + checkSelection(); + } ); + } + // ── JS entry-point bodies ───────────────────────────────────────────── /** kicadCollabPresenceStart: install the input hooks on the GAL canvas @@ -491,6 +584,7 @@ struct CORE for( const json& p : j.value( "peers", json::array() ) ) { PEER peer; + peer.id = p.value( "id", "" ); peer.name = p.value( "name", "" ); peer.color = parsePeerColor( p.value( "color", "" ) ); @@ -528,10 +622,75 @@ struct CORE parsedLocks[ KIID( wxString::FromUTF8( uuid.c_str() ) ) ] = l.value( "name", "" ); } + // Shapes signature (everything but cursors): an unchanged one means + // this push is a cursor tick — repaint cursors only. + std::string sig; + + for( const PEER& peer : parsed ) + { + sig += peer.id + '\x1f' + peer.name + '\x1f' + peer.color.ToCSSString().ToStdString() + '\x1e'; + + for( const KIID& k : peer.selection ) + sig += pcbjam_collab::toUtf8( k.AsString() ) + ','; + + sig += '\x1e'; + + for( const KIID& k : peer.xsel ) + sig += pcbjam_collab::toUtf8( k.AsString() ) + ','; + + sig += '\x1d'; + } + + for( const auto& [id, name] : parsedLocks ) + sig += pcbjam_collab::toUtf8( id.AsString() ) + '=' + name + ';'; + + bool shapesChanged = sig != lastShapeSig; + lastShapeSig = std::move( sig ); + peers = std::move( parsed ); locks = std::move( parsedLocks ); start(); - scheduleRedraw(); + scheduleRedraw( shapesChanged, true ); + } + + /** kicadCollabSetRemoteCursors (findings Y-4): `{cursors:[{id,cursor:{x,y}|null}]}` + * for peers of the last full snapshot — updates their cursors and repaints + * the cursor overlays only. Unknown ids are ignored (the next full + * snapshot introduces them). */ + void setRemoteCursors( const std::string& aJson ) + { + json j = json::parse( aJson, nullptr, /*allow_exceptions*/ false ); + + if( j.is_discarded() ) + return; + + bool changed = false; + + for( const json& c : j.value( "cursors", json::array() ) ) + { + std::string id = c.is_object() ? c.value( "id", "" ) : ""; + + for( PEER& peer : peers ) + { + if( peer.id != id ) + continue; + + if( c.contains( "cursor" ) && c["cursor"].is_object() ) + { + peer.hasCursor = true; + peer.cursor = VECTOR2D( c["cursor"].value( "x", 0.0 ), c["cursor"].value( "y", 0.0 ) ); + } + else + { + peer.hasCursor = false; + } + + changed = true; + } + } + + if( changed ) + scheduleRedraw( false, true ); } /** kicadCollabSetPins (0005): comment pins — `{pins:[{id,name,x,y, diff --git a/wasm/bindings/eeschema_embind.cpp b/wasm/bindings/eeschema_embind.cpp index dccbfc1..e8a1bdc 100644 --- a/wasm/bindings/eeschema_embind.cpp +++ b/wasm/bindings/eeschema_embind.cpp @@ -650,6 +650,12 @@ void scheduleSheetSave( SCH_SHEET* aSheet ) // too (delete, paste) with no closing canvas event — the trigger below also // piggybacks a selection re-check. Defined in the presence section further down. void schedulePresenceSelCheck(); +// Findings Y-1/Y-3: ANY document change (local commit or remote apply) repaints +// the peers' overlay from the live document and re-checks the local selection, +// post-settle on the apply coroutine. Runs BEFORE the applying-remote early +// return below — the remote apply is exactly the case that left peers' boxes on +// deleted items. +void schedulePresenceDocChanged(); class COLLAB_LISTENER : public SCHEMATIC_LISTENER { @@ -684,6 +690,8 @@ private: // symbol — noteDirty), then coalesce into one post-settle flush. void trigger( const std::vector& aItems ) { + schedulePresenceDocChanged(); + if( s_applyingRemote ) return; @@ -829,6 +837,11 @@ void schedulePresenceSelCheck() presenceCore().scheduleSelCheck(); } +void schedulePresenceDocChanged() +{ + presenceCore().onDocChanged(); +} + } // namespace @@ -1839,6 +1852,11 @@ extern "C" void kicadCollabOnSave( const char* aPath ) // sheet navigation, so one install serves the whole session. void schCollabPresenceStart() { + // Presence needs the document listener too (findings Y-1/Y-3: doc changes + // repaint peers' shapes + re-check the local selection) — collab normally + // registers it first via snapshot/apply, but presence must not depend on + // that ordering (test harnesses, presence-only sessions). + ensureBridge(); presenceCore().start(); } @@ -1850,6 +1868,12 @@ void schCollabSetRemote( std::string aJson ) presenceCore().setRemote( aJson ); } +// JS → C++: cursor-only update for the peers of the last snapshot (findings Y-4). +void schCollabSetRemoteCursors( std::string aJson ) +{ + presenceCore().setRemoteCursors( aJson ); +} + // JS → C++ (collab-presence 0005): comment pin dots (same wire as pcbnew). void schCollabSetPins( std::string aJson ) { @@ -2241,6 +2265,7 @@ EMSCRIPTEN_BINDINGS(eeschema) { // Presence (collab-presence 0003) — shared names with pcbnew's 0002 set. function("kicadCollabPresenceStart", &schCollabPresenceStart); function("kicadCollabSetRemote", &schCollabSetRemote); + function("kicadCollabSetRemoteCursors", &schCollabSetRemoteCursors); function("kicadCollabSetPins", &schCollabSetPins); function("kicadCollabSetViewport", &schCollabSetViewport); // Follow-user (collab-presence 0008). diff --git a/wasm/bindings/kicad_editor_embind.cpp b/wasm/bindings/kicad_editor_embind.cpp index 4cfb1aa..92c7af0 100644 --- a/wasm/bindings/kicad_editor_embind.cpp +++ b/wasm/bindings/kicad_editor_embind.cpp @@ -73,6 +73,7 @@ int pcbCollabTestUndoDepth(); // Presence (collab-presence 0002) + comment pins/panning (0005). void pcbCollabPresenceStart(); void pcbCollabSetRemote( std::string aJson ); +void pcbCollabSetRemoteCursors( std::string aJson ); void pcbCollabSetPins( std::string aJson ); void pcbCollabSetViewport( double aCx, double aCy ); // Follow-user (collab-presence 0008). @@ -120,6 +121,7 @@ int schCollabTestUndoDepth(); // Presence (collab-presence 0003 — eeschema counterparts) + pins (0005). void schCollabPresenceStart(); void schCollabSetRemote( std::string aJson ); +void schCollabSetRemoteCursors( std::string aJson ); void schCollabSetPins( std::string aJson ); void schCollabSetViewport( double aCx, double aCy ); // Follow-user (collab-presence 0008). @@ -491,6 +493,11 @@ static void collabSetRemote( std::string aJson ) pcbEditorActive() ? pcbCollabSetRemote( aJson ) : schCollabSetRemote( aJson ); } +static void collabSetRemoteCursors( std::string aJson ) +{ + pcbEditorActive() ? pcbCollabSetRemoteCursors( aJson ) : schCollabSetRemoteCursors( aJson ); +} + static void collabSetPins( std::string aJson ) { pcbEditorActive() ? pcbCollabSetPins( aJson ) : schCollabSetPins( aJson ); @@ -658,6 +665,7 @@ EMSCRIPTEN_BINDINGS(kicad_editor) { // Presence (collab-presence 0002/0003) + comment pins/panning (0005). function("kicadCollabPresenceStart", &collabPresenceStart); function("kicadCollabSetRemote", &collabSetRemote); + function("kicadCollabSetRemoteCursors", &collabSetRemoteCursors); function("kicadCollabSetPins", &collabSetPins); function("kicadCollabSetViewport", &collabSetViewport); // Live color-theme switch (comments-ux 0002 F4). diff --git a/wasm/bindings/pcbnew_embind.cpp b/wasm/bindings/pcbnew_embind.cpp index 10b49fb..976ab9f 100644 --- a/wasm/bindings/pcbnew_embind.cpp +++ b/wasm/bindings/pcbnew_embind.cpp @@ -1006,6 +1006,10 @@ void scheduleFlush() // (delete, paste) with no closing canvas event — piggyback a selection re-check // on the collab listener trigger. Defined in the presence section below. void schedulePresenceSelCheck(); +// Findings Y-1/Y-3: ANY document change (local commit or remote apply) repaints +// the peers' overlay from the live board and re-checks the local selection — +// runs BEFORE the applying-remote early return in trigger(). +void schedulePresenceDocChanged(); // ChangeSource: the native BOARD_LISTENER is just a trigger — the actual change set comes from // the post-settle snapshot diff above. Skipped while applying a remote delta (no echo); doApply @@ -1034,6 +1038,8 @@ private: // may be freed before the flush runs), then coalesce into one flush. void trigger( const std::vector& aItems ) { + schedulePresenceDocChanged(); + if( s_applyingRemote ) return; @@ -1454,6 +1460,11 @@ void schedulePresenceSelCheck() presenceCore().scheduleSelCheck(); } +void schedulePresenceDocChanged() +{ + presenceCore().onDocChanged(); +} + } // namespace @@ -1685,6 +1696,8 @@ std::string pcbCollabGetPos( std::string aId ) // run POST-event via CallAfter (the selection tool acts on the same event after us). void pcbCollabPresenceStart() { + // Presence needs the board listener too (findings Y-1/Y-3) — see eeschema. + ensureBridge(); presenceCore().start(); } @@ -1696,6 +1709,12 @@ void pcbCollabSetRemote( std::string aJson ) presenceCore().setRemote( aJson ); } +// JS → C++: cursor-only update for the peers of the last snapshot (findings Y-4). +void pcbCollabSetRemoteCursors( std::string aJson ) +{ + presenceCore().setRemoteCursors( aJson ); +} + // JS → C++ (collab-presence 0005): comment pin dots — `{pins:[{id,x,y,color, // resolved}]}`, world IU coords resolved by the TS side from the ydoc anchors. // Snapshot semantics like SetRemote: cleared + fully redrawn each push. @@ -2897,6 +2916,7 @@ EMSCRIPTEN_BINDINGS(pcbnew) { // with 0003 (the merged image dispatches pcb-only until then). function("kicadCollabPresenceStart", &pcbCollabPresenceStart); function("kicadCollabSetRemote", &pcbCollabSetRemote); + function("kicadCollabSetRemoteCursors", &pcbCollabSetRemoteCursors); function("kicadCollabSetPins", &pcbCollabSetPins); function("kicadCollabSetViewport", &pcbCollabSetViewport); // Follow-user (collab-presence 0008). diff --git a/web/pcbjam-shared b/web/pcbjam-shared index f14401b..4573a7c 160000 --- a/web/pcbjam-shared +++ b/web/pcbjam-shared @@ -1 +1 @@ -Subproject commit f14401b95d66c4997f66b6b0f7755745f4c59294 +Subproject commit 4573a7c2707d891fc2107db24b23023c0dae849f diff --git a/web/standalone/src/wasm/collab/gateway.ts b/web/standalone/src/wasm/collab/gateway.ts index 3ba6bc9..a76032c 100644 --- a/web/standalone/src/wasm/collab/gateway.ts +++ b/web/standalone/src/wasm/collab/gateway.ts @@ -441,6 +441,19 @@ export class GatewayDocFacade implements YjsProvider { for (const cb of this.filesCbs) cb(msg.seq, msg.changes); return; } + if (msg.t === "gone") { + // A peer connection died; the gateway names its awareness clients. The + // clock-ordered binary tombstone may have been rejected (stale clock + // after a hibernation wake) — this removal is authoritative. A live + // client with one of these ids simply re-appears on its next update. + const remote = msg.clients.filter( + (id) => id !== this.doc.clientID && this.awareness.getStates().has(id), + ); + if (remote.length > 0) { + removeAwarenessStates(this.awareness, remote, "gateway-gone"); + } + return; + } // touched for (const cb of this.touchedCbs) cb(); } diff --git a/web/standalone/src/wasm/collab/presence-kicad.ts b/web/standalone/src/wasm/collab/presence-kicad.ts index 858b32e..2f7c5bb 100644 --- a/web/standalone/src/wasm/collab/presence-kicad.ts +++ b/web/standalone/src/wasm/collab/presence-kicad.ts @@ -36,6 +36,10 @@ export interface PresenceKicadModule { /** 0008: fit a leader's world rect (center + half-extents, IU) into this * canvas — contain semantics. Absent on older wasm builds. */ kicadCollabFitViewport?(cx: number, cy: number, halfW: number, halfH: number): void; + /** Findings Y-4: cursor-only update `{cursors:[{id, cursor}]}` for peers + * already known from the last full snapshot — the wasm side repaints only + * the cursor overlays, not every selection outline. Absent on older wasm. */ + kicadCollabSetRemoteCursors?(json: string): void; } export interface PresenceKicadWindow { @@ -212,6 +216,11 @@ export function bindKicadPresence(opts: { } // awareness → C++ ------------------------------------------------------------ + // The last full snapshot's "shape" signature (everything except cursors): + // while it is unchanged, awareness changes are cursor ticks and go through + // the cheap cursor-only entry point (findings Y-4 — a select-all peer's + // 20 Hz cursor must not re-resolve + redraw 3000 outlines per tick). + let lastShapeSig = ""; const pushRemote = () => { const peers = presence.peers(); const snapshot: { @@ -264,6 +273,17 @@ export function bindKicadPresence(opts: { xsel, }); } + const shapeSig = JSON.stringify({ + peers: snapshot.peers.map((p) => [p.id, p.name, p.color, p.selection, p.xsel ?? null]), + locks: snapshot.locks, + }); + if (shapeSig === lastShapeSig && mod.kicadCollabSetRemoteCursors) { + mod.kicadCollabSetRemoteCursors( + JSON.stringify({ cursors: snapshot.peers.map((p) => ({ id: p.id, cursor: p.cursor })) }), + ); + return; + } + lastShapeSig = shapeSig; mod.kicadCollabSetRemote(JSON.stringify(snapshot)); }; diff --git a/web/standalone/src/wasm/collab/presence-select-all-lag.test.ts b/web/standalone/src/wasm/collab/presence-select-all-lag.test.ts new file mode 100644 index 0000000..c09fdf2 --- /dev/null +++ b/web/standalone/src/wasm/collab/presence-select-all-lag.test.ts @@ -0,0 +1,145 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import * as Y from "yjs"; +import { Awareness, encodeAwarenessUpdate } from "y-protocols/awareness"; +import { createPresence, resetPresenceColorClaims, type PresencePeer } from "./presence"; +import { bindKicadPresence, type PresenceKicadWindow } from "./presence-kicad"; + +/** + * REPRO (findings: "if user A selects everything, user B gets slow/laggy"). + * + * Two amplifications stack on a big selection: + * 1. WIRE — awareness is a single JSON blob per client. Every cursor tick + * (≤20/s) re-encodes the WHOLE state, selection included, so N selected + * items cost ~N×40 bytes per tick on the wire, per peer. + * 2. BRIDGE — every awareness change (any peer's cursor move) rebuilds the + * full `kicadCollabSetRemote` snapshot (all peers' full selections) and + * the wasm side clears + redraws EVERY selection outline (pcbnew even + * recomputes hulls / polygons per item). The 30ms trailing throttle only + * coalesces bursts; a steady 20Hz cursor stream still means ~20 full + * redraws of N outlines per second on B. + */ + +const uuids = (n: number): string[] => + Array.from({ length: n }, (_, i) => `${i.toString(16).padStart(8, "0")}-0000-0000-0000-000000000000`); + +afterEach(() => { + vi.useRealTimers(); + resetPresenceColorClaims(); +}); + +describe("select-all amplification", () => { + it("WIRE: one second of cursor motion after select-all stays inside the publish budget", () => { + vi.useFakeTimers(); + const doc = new Y.Doc(); + const awareness = new Awareness(doc); + const presence = createPresence({ + awareness, + user: { id: "alice", name: "alice", color: "#000" }, + tool: "eeschema", + }); + let bytes = 0; + let updates = 0; + awareness.on("update", ({ updated }: { updated: number[] }) => { + if (!updated.includes(awareness.clientID)) return; + updates++; + bytes += encodeAwarenessUpdate(awareness, [awareness.clientID]).byteLength; + }); + + const drive = () => { + bytes = 0; + updates = 0; + for (let i = 0; i < 20; i++) { + presence.setCursor({ x: i, y: i }); + vi.advanceTimersByTime(50); + } + vi.advanceTimersByTime(2500); + return { bytes, updates }; + }; + + presence.setSelection([]); + const small = drive(); + // No selection: every tick publishes as before (unthrottled). + expect(small.updates).toBe(20); + + presence.setSelection(uuids(3000)); // select-all on a mid-size board + const big = drive(); + // eslint-disable-next-line no-console + console.log(`awareness cursor motion/s: empty sel ${small.updates}×=${small.bytes}B, 3000-item sel ${big.updates}×=${big.bytes}B`); + // Was 20 × 117 KB ≈ 2.3 MB/s. The size-aware trailing throttle keeps a + // select-all peer's cursor traffic near the 128 KB/s budget while the + // last cursor position still lands (trailing edge). + expect(big.bytes).toBeLessThan(400 * 1024); + expect(big.updates).toBeGreaterThanOrEqual(1); + expect(big.updates).toBeLessThan(6); + expect((awareness.getLocalState() as { cursor: { x: number } }).cursor.x).toBe(19); + presence.destroy(); + awareness.destroy(); + }); + + it("BRIDGE: every peer cursor move re-pushes all N selection uuids to wasm", () => { + vi.useFakeTimers(); + const setRemote = vi.fn<(json: string) => void>(); + const setCursors = vi.fn<(json: string) => void>(); + const mod = { + kicadCollabPresenceStart: vi.fn(), + kicadCollabSetRemote: setRemote, + kicadCollabSetRemoteCursors: setCursors, + kicadCollabGetViewport: vi.fn(() => '{"cx":0,"cy":0,"scale":1,"w":800,"h":600}'), + kicadCollabGetSelection: vi.fn(() => "[]"), + }; + const subscribers = new Set<(p: PresencePeer[]) => void>(); + let peers: PresencePeer[] = []; + const presence = { + peers: () => peers, + clients: () => peers, + self: () => ({ userId: "bob", clientId: 100 }), + subscribe(cb: (p: PresencePeer[]) => void) { + subscribers.add(cb); + return () => subscribers.delete(cb); + }, + setCursor: vi.fn(), + setSelection: vi.fn(), + setViewport: vi.fn(), + colorOf: () => "#000", + destroy: vi.fn(), + }; + const win: PresenceKicadWindow = {}; + bindKicadPresence({ mod, win, presence }); + setRemote.mockClear(); + + const selection = uuids(3000); + const alice = (cursor: { x: number; y: number }): PresencePeer => ({ + clientId: 1, + user: { id: "alice", name: "alice", color: "#000" }, + tool: "eeschema", + cursor, + selection, + updatedAt: Date.now(), + }); + + // alice moves her mouse for one second at the 20Hz emit cadence. + for (let i = 0; i < 20; i++) { + peers = [alice({ x: i, y: i })]; + for (const cb of subscribers) cb(peers); + vi.advanceTimersByTime(50); + } + vi.advanceTimersByTime(100); + + const pushes = setRemote.mock.calls.length; + const bytes = setRemote.mock.calls.reduce((s, [json]) => s + json.length, 0); + const uuidsPushed = setRemote.mock.calls.reduce( + (s, [json]) => s + (JSON.parse(json) as { peers: { selection: string[] }[] }).peers[0]!.selection.length, + 0, + ); + // eslint-disable-next-line no-console + console.log(`bridge: ${pushes} full setRemote pushes (${bytes}B JSON, ${uuidsPushed} uuids), ${setCursors.mock.calls.length} cursor-only pushes per second of cursor motion`); + + // Cursor-only changes must not re-ship (and the wasm side re-resolve + + // redraw) the unchanged 3000-item selection 20×/s: ONE full snapshot + // introduces the selection, the ticks ride the cursor-only entry point. + expect(uuidsPushed).toBeLessThan(selection.length * 2); + expect(setCursors.mock.calls.length).toBeGreaterThanOrEqual(15); + const last = JSON.parse(setCursors.mock.calls.at(-1)![0]) as { cursors: { id: string; cursor: { x: number } }[] }; + expect(last.cursors[0]).toEqual({ id: "alice", cursor: { x: 19, y: 19 } }); + }); +}); diff --git a/web/standalone/src/wasm/collab/presence.ts b/web/standalone/src/wasm/collab/presence.ts index dd9674b..f8fe956 100644 --- a/web/standalone/src/wasm/collab/presence.ts +++ b/web/standalone/src/wasm/collab/presence.ts @@ -70,6 +70,16 @@ export interface PresenceHandle { // so one user keeps one color everywhere in a session; a second tab of the // same user ADOPTS the existing color instead of claiming a new one. +/** Cursor/viewport publish budget: bytes of published selection per second + * of high-frequency traffic. 128 KB/s ⇒ a 3000-item selection (~117 KB) + * publishes cursors at ~1 Hz; anything under ~6 KB (150 items) is not + * throttled at all (the wasm side already caps cursor emits at 20 Hz). */ +const HF_BUDGET_BYTES_PER_SEC = 128 * 1024; +const HF_MAX_INTERVAL_MS = 2000; +/** Below this the wasm emit cadence (50 ms) already bounds the rate — publish + * synchronously, exactly as before. */ +const HF_MIN_INTERVAL_MS = 50; + // Per-user claims in this JS context (one user per tab in production; the // map keeps multi-client unit tests deterministic). const g_claims = new Map(); @@ -187,6 +197,36 @@ export function createPresence(opts: { awareness.setLocalState({ ...current, ...fields, updatedAt: Date.now() }); }; + // Select-all lag (findings Y-4): awareness ships the WHOLE state on every + // change, so with a large selection each 20 Hz cursor/viewport tick + // re-sends the selection (3000 items ≈ 117 KB per tick). Rate the + // high-frequency fields by payload size: the bigger the published + // selection, the longer the trailing interval between cursor/viewport + // publishes (a small selection is unthrottled; select-all ≈ 1 Hz). + let selectionBytes = 2; + let hfTimer: ReturnType | undefined; + let hfPending: Partial = {}; + let hfLastAt = 0; + const hfInterval = () => Math.min(HF_MAX_INTERVAL_MS, (selectionBytes / HF_BUDGET_BYTES_PER_SEC) * 1000); + const patchHighFrequency = (fields: Partial) => { + hfPending = { ...hfPending, ...fields }; + const wait = hfInterval(); + if (wait < HF_MIN_INTERVAL_MS) { + hfPending = {}; + patch(fields); + return; + } + if (hfTimer) return; + const due = Math.max(0, hfLastAt + wait - Date.now()); + hfTimer = setTimeout(() => { + hfTimer = undefined; + hfLastAt = Date.now(); + const p = hfPending; + hfPending = {}; + patch(p); + }, due); + }; + patch({ user, tool: opts.tool, @@ -249,7 +289,12 @@ export function createPresence(opts: { } }; + // Parsed-clients memo: peers()/clients() are both read on every awareness + // change by the bridge, and each zod parse of a big selection is costly. + // States only change through awareness events, so one parse per change. + let clientsMemo: PresencePeer[] | null = null; function clients(): PresencePeer[] { + if (clientsMemo) return clientsMemo; const out: PresencePeer[] = []; for (const [clientId, raw] of awareness.getStates()) { if (clientId === awareness.clientID) continue; @@ -257,7 +302,8 @@ export function createPresence(opts: { if (!parsed.success) continue; out.push({ ...parsed.data, clientId }); } - return out.sort((a, b) => a.clientId - b.clientId); + clientsMemo = out.sort((a, b) => a.clientId - b.clientId); + return clientsMemo; } function peers(): PresencePeer[] { @@ -275,6 +321,7 @@ export function createPresence(opts: { const subscribers = new Set<(peers: PresencePeer[]) => void>(); const onChange = () => { + clientsMemo = null; resolveCollision(); if (!subscribers.size) return; const snapshot = peers(); @@ -302,13 +349,14 @@ export function createPresence(opts: { return () => subscribers.delete(cb); }, setCursor(pos) { - patch({ cursor: pos }); + patchHighFrequency({ cursor: pos }); }, setSelection(uuids) { + selectionBytes = JSON.stringify(uuids).length; patch({ selection: uuids }); }, setViewport(rect) { - patch({ viewport: rect }); + patchHighFrequency({ viewport: rect }); }, colorOf(userId) { if (userId === user.id) return user.color; @@ -331,6 +379,8 @@ export function createPresence(opts: { } awareness.off("change", onChange); subscribers.clear(); + if (hfTimer) clearTimeout(hfTimer); + hfTimer = undefined; awareness.setLocalState(null); }, };