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
|
|
@ -96,6 +96,7 @@
|
|||
"kicadCollabApply", "kicadCollabApplyItems",
|
||||
"kicadCollabSnapshot", "kicadCollabSnapshotItems",
|
||||
"kicadCollabPresenceStart", "kicadCollabSetRemote",
|
||||
"kicadCollabSetRemoteCursors",
|
||||
"kicadCollabSetPins", "kicadCollabSetStyle",
|
||||
"kicadCollabSetViewport", "kicadCollabFitViewport",
|
||||
"kicadCollabReleaseSelection", "kicadSetColorTheme",
|
||||
|
|
|
|||
244
tests/kicad/presence-stale-after-delete.spec.ts
Normal file
244
tests/kicad/presence-stale-after-delete.spec.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
__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<void> {
|
||||
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<Mod> }).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([]);
|
||||
});
|
||||
100
tests/web/presence-ghost-diag.spec.ts
Normal file
100
tests/web/presence-ghost-diag.spec.ts
Normal file
|
|
@ -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<Page> {
|
||||
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();
|
||||
});
|
||||
124
tests/web/presence-ghost-peer.spec.ts
Normal file
124
tests/web/presence-ghost-peer.spec.ts
Normal file
|
|
@ -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<Page> {
|
||||
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<number> {
|
||||
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();
|
||||
});
|
||||
}
|
||||
|
|
@ -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<KIGFX::VIEW_OVERLAY> chipOverlay;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> 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<KIGFX::VIEW_OVERLAY> cursorOverlay;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> cursorChipOverlay;
|
||||
std::shared_ptr<KIGFX::VIEW_OVERLAY> 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,
|
||||
|
|
|
|||
|
|
@ -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<SCH_ITEM*>& 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).
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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<BOARD_ITEM*>& 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).
|
||||
|
|
|
|||
|
|
@ -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