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

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

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

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