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:
Gergő Törcsvári 2026-08-28 16:14:44 +02:00
commit 7a9aeb5181
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
13 changed files with 935 additions and 26 deletions

View file

@ -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();
}

View file

@ -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));
};

View 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 } });
});
});

View file

@ -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);
},
};