presence: fix stale peer selections after delete, select-all lag, ghost-peer removal (findings group Y)
- 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AScTR39aqyrY5i3ZFHmnMn
This commit is contained in:
parent
d069b7be80
commit
7a9aeb5181
13 changed files with 935 additions and 26 deletions
|
|
@ -1 +1 @@
|
|||
Subproject commit f14401b95d66c4997f66b6b0f7755745f4c59294
|
||||
Subproject commit 4573a7c2707d891fc2107db24b23023c0dae849f
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
};
|
||||
|
||||
|
|
|
|||
145
web/standalone/src/wasm/collab/presence-select-all-lag.test.ts
Normal file
145
web/standalone/src/wasm/collab/presence-select-all-lag.test.ts
Normal file
|
|
@ -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 } });
|
||||
});
|
||||
});
|
||||
|
|
@ -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<string, string>();
|
||||
|
|
@ -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<typeof setTimeout> | undefined;
|
||||
let hfPending: Partial<PresenceState> = {};
|
||||
let hfLastAt = 0;
|
||||
const hfInterval = () => Math.min(HF_MAX_INTERVAL_MS, (selectionBytes / HF_BUDGET_BYTES_PER_SEC) * 1000);
|
||||
const patchHighFrequency = (fields: Partial<PresenceState>) => {
|
||||
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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue