From 4fb3dfc5e94a8420f57f48b28af8f3c2246bd21f Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:35:35 +0200 Subject: [PATCH 1/6] net-drop: wire + app trace for deployed builds, B->A premise before any drop The suite's shim now records, per page: every RTCPeerConnection's real ICE / connection state, every data channel's open/close with sent / delivered / held counts (a peerjs channel's label is its conn id), the selected ICE pair's candidate types, each DataConnection's sends and 'data' events by message type, and what the send / receive / role gates answer about the other peer. Printed with NETDROP_TRACE=1, or when B's edit never reaches A. A new premise checks that B's edit reaches A before any drop (the lock alone is a protocol-floor message). Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/e2e/net-drop.test.cjs | 199 +++++++++++++++++++++++++++++++++++- 1 file changed, 194 insertions(+), 5 deletions(-) diff --git a/tests/e2e/net-drop.test.cjs b/tests/e2e/net-drop.test.cjs index 79958318..98274e1f 100644 --- a/tests/e2e/net-drop.test.cjs +++ b/tests/e2e/net-drop.test.cjs @@ -23,21 +23,65 @@ const h = require('./helpers.cjs'); const DROP_MS = Number(process.env.NETDROP_MS || 5000); +/** net-drop-1: print the wire trace for every phase, not only a failing one */ +const TRACE = !!process.env.NETDROP_TRACE; /** src/lib/peerDrop.js ICE_DROP_GRACE_MS */ const ICE_DROP_GRACE_MS = 4000; -/** runs in the page before any app code: the hold switch + fake ICE state */ +/** runs in the page before any app code: the hold switch + fake ICE state + the WIRE TRACE (net-drop-1: + * every peer connection's real ICE/connection state, every data channel's open/close and its sent / + * delivered / held message counts by label — a peerjs channel's label is its conn's connectionId, so + * "which conn does each send use, and does anything read it" is answerable on a deployed build) */ function dropShim() { - const D = (window.__drop = { holding: false, queue: /** @type {Function[]} */ ([]), pcs: /** @type {RTCPeerConnection[]} */ ([]), held: 0 }); + const D = (window.__drop = { + holding: false, + queue: /** @type {Function[]} */ ([]), + pcs: /** @type {RTCPeerConnection[]} */ ([]), + held: 0, + log: /** @type {string[]} */ ([]), + /** label -> {pc, sent, recv, held, state} */ + dcs: /** @type {Record} */ ({}) + }); + const realIce = Object.getOwnPropertyDescriptor(RTCPeerConnection.prototype, 'iceConnectionState')?.get; + // @ts-ignore + D.note = (/** @type {string} */ what) => { + D.log.push(new Date().toISOString().slice(14, 23) + ' ' + what); + if (D.log.length > 600) D.log.splice(0, 100); + }; + const dcRow = (/** @type {any} */ dc) => (D.dcs[dc.label] ??= { pc: dc.__pc ?? '?', sent: 0, recv: 0, held: 0, state: 'new' }); + const send = RTCDataChannel.prototype.send; + RTCDataChannel.prototype.send = function (/** @type {any} */ data) { + const row = dcRow(this); + row.sent++; + if (this.readyState !== 'open') D.note(`SEND on ${this.label} (pc${row.pc}) while ${this.readyState}`); + return send.call(this, data); + }; + const watchDc = (/** @type {any} */ dc, /** @type {number} */ n, /** @type {string} */ how) => { + dc.__pc = n; + const row = dcRow(dc); + row.pc = n; + D.note(`dc ${dc.label} ${how} on pc${n} (${dc.readyState})`); + for (const ev of ['open', 'close', 'closing', 'error']) + dc.addEventListener(ev, () => { + row.state = ev; + D.note(`dc ${dc.label} pc${n} ${ev} sent=${row.sent} recv=${row.recv} held=${row.held}`); + }); + }; const wrap = (/** @type {Function} */ fn) => function (/** @type {any} */ event) { // @ts-ignore const self = this; + const row = dcRow(self); if (D.holding) { D.held++; - D.queue.push(() => fn.call(self, event)); + row.held++; + D.queue.push(() => { + row.recv++; + return fn.call(self, event); + }); return; } + row.recv++; return fn.call(self, event); }; const proto = RTCDataChannel.prototype; @@ -70,7 +114,20 @@ function dropShim() { // @ts-ignore window.RTCPeerConnection = function (/** @type {any[]} */ ...args) { const pc = new Real(...args); - D.pcs.push(pc); + const n = D.pcs.push(pc) - 1; + const policy = args[0]?.iceTransportPolicy ?? 'all'; + const servers = (args[0]?.iceServers ?? []).map((/** @type {any} */ s) => [].concat(s.urls).map((u) => String(u).split(':')[0]).join('+')).join(','); + D.note(`pc${n} new policy=${policy} servers=${servers || '-'}`); + const cdc = pc.createDataChannel.bind(pc); + pc.createDataChannel = (/** @type {any[]} */ ...a) => { + // @ts-ignore + const dc = cdc(...a); + watchDc(dc, n, 'created'); + return dc; + }; + pc.addEventListener('datachannel', (e) => watchDc(e.channel, n, 'inbound')); + pc.addEventListener('iceconnectionstatechange', () => D.note(`pc${n} ice=${realIce?.call(pc)}${D.holding ? ' (held)' : ''}`)); + pc.addEventListener('connectionstatechange', () => D.note(`pc${n} conn=${pc.connectionState}`)); return pc; }; window.RTCPeerConnection.prototype = Real.prototype; @@ -147,6 +204,125 @@ h.run(async () => { const bLocks = async () => (await store(A, 'lockedObjects')).filter((/** @type {any} */ x) => x[0] === B.id).length; const lockBox = await create(B, 'box'); await h.eventually(() => store(A, 'lockedObjects'), (l) => l.some((/** @type {any} */ x) => x[0] === B.id && x[1] === lockBox), "premise: A sees B's lock"); + // net-drop-1: B -> A worked BEFORE any drop (on a deployed build the lock alone, a protocol-floor + // message, could not tell a direction lost to the heal from one that never carried content) + await h.eventually(() => has(A, lockBox), (v) => v, "premise: B's edit reaches A before any drop"); + + /** net-drop-1: both pages' wire logs, each conn's channel counts and the selected ICE pair's + * candidate types (host / srflx / relay), printed — the deployed-build diagnosis */ + async function wireTrace(/** @type {string} */ tag, /** @type {string} */ why) { + for (const [p, other] of [[A, B.id], [B, A.id]]) { + const t = await p.page + .evaluate(async (/** @type {string} */ pid) => { + // @ts-ignore + const D = window.__drop; + /** @type {any} */ let pc; + window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + const c = pc.connections[pid]; + const pairs = []; + for (const [n, rpc] of D.pcs.entries()) { + if (rpc.signalingState === 'closed') continue; + try { + const rows = []; + (await rpc.getStats()).forEach((/** @type {any} */ r) => rows.push(r)); + const tr = rows.find((r) => r.type === 'transport' && r.selectedCandidatePairId); + const pair = tr && rows.find((r) => r.id === tr.selectedCandidatePairId); + const kind = (/** @type {string} */ id) => rows.find((r) => r.id === id)?.candidateType ?? '?'; + pairs.push(`pc${n}: ${pair ? kind(pair.localCandidateId) + '->' + kind(pair.remoteCandidateId) + ' ' + pair.state : 'no pair'}`); + } catch (e) { + pairs.push(`pc${n}: stats ${String(e).slice(0, 40)}`); + } + } + return { + app: c ? `connections[peer] = ${c.connectionId} open=${c.open} dc=${c.dataChannel?.label}/${c.dataChannel?.readyState}` : 'connections[peer] = none', + dcs: Object.entries(D.dcs).map(([l, r]) => `${l} pc${r.pc} ${r.state} sent=${r.sent} recv=${r.recv} held=${r.held}`), + pairs, + log: D.log.slice(-220).concat(Object.entries(D.quiet ?? {}).map(([k, n]) => `counted ${k} x${n}`)) + }; + }, other) + .catch((/** @type {any} */ e) => ({ app: 'err ' + e, dcs: [], pairs: [], log: [] })); + console.log(` ${tag} GATES ${p === A ? 'A' : 'B'} about the other:\n ` + (await gates(p, other))); + console.log(` ${tag} WIRE ${p === A ? 'A' : 'B'} (${p.id}) — ${why}\n ${t.app}\n ${t.pairs.join(' | ')}\n ` + t.dcs.join('\n ') + '\n --\n ' + t.log.join('\n ')); + } + } + + /** net-drop-1: the APP half of the trace — every DataConnection's sends and 'data' events by message + * type (pose/clock streams only counted), patched on peerjs's shared prototype so a conn made by a + * heal is covered too */ + async function appTrace(/** @type {any} */ p) { + await p.page.evaluate(() => { + // @ts-ignore + const D = window.__drop; + /** @type {any} */ let pc; + window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + const any = Object.values(pc.connections)[0]; + let proto = Object.getPrototypeOf(any); + while (proto && !Object.prototype.hasOwnProperty.call(proto, 'send')) proto = Object.getPrototypeOf(proto); + if (!proto || proto.__ndTraced) return; + proto.__ndTraced = true; + const noisy = new Set(['camera', 'vrhands', 'clockping', 'clockpong', 'cursor', 'cursors', 'pointer', 'ping', 'pong', 'pose', 'move', 'batch']); + D.quiet = {}; + const note = (/** @type {string} */ dir, /** @type {any} */ c, /** @type {any} */ d) => { + const t = String(d?.type ?? (d && d.__peerData ? 'peerjs' : typeof d)); + if (noisy.has(t)) { + const k = dir + ' ' + t + ' ' + c.peer + ' ' + c.connectionId; + D.quiet[k] = (D.quiet[k] ?? 0) + 1; + return; + } + D.note(`APP ${dir} ${t} ${dir === 'out' ? 'to' : 'from'} ${c.peer} on ${c.connectionId}${c.open ? '' : ' (NOT OPEN)'}`); + }; + const send = proto.send; + proto.send = function (/** @type {any} */ d, /** @type {any[]} */ ...rest) { + note('out', this, d); + return send.call(this, d, ...rest); + }; + const emit = proto.emit; + proto.emit = function (/** @type {string} */ ev, /** @type {any[]} */ ...args) { + if (ev === 'data') note('in', this, args[0]); + return emit.call(this, ev, ...args); + }; + }); + } + await appTrace(A); + await appTrace(B); + + /** net-drop-1: what each side's gates answer about the other right now */ + async function gates(/** @type {any} */ p, /** @type {string} */ other) { + return p.page + .evaluate(async (/** @type {string} */ pid) => { + const S = window.__stores; + /** @type {any} */ let pc; + S.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + const get = (/** @type {any} */ st) => { + let v; + st?.subscribe?.((/** @type {any} */ x) => (v = x))(); + return v; + }; + const out = []; + const safe = (/** @type {string} */ k, /** @type {() => any} */ f) => { + try { + out.push(k + '=' + JSON.stringify(f())); + } catch (e) { + out.push(k + '=ERR ' + String(e).slice(0, 50)); + } + }; + safe('withholdFrom(create)', () => pc.withholdFrom(pid, { type: 'create' })); + safe('canApply(create)', () => S.cloudHooks?.canApply?.(pid, 'create')); + safe('canApplyByRoom(create)', () => S.peerScenes?.canApplyByRoom?.(pid, 'create')); + safe('gateHolds', () => S.sessions?.gateHolds?.(pid)); + safe('amPrivate', () => S.peerScenes?.amPrivate?.()); + safe('myScene', () => S.peerScenes?.myScene?.()); + safe('peerScenes', () => Object.fromEntries(Object.entries(get(S.peerScenes?.peerScenes) ?? {}))); + safe('sessionHost', () => get(S.connectionState?.sessionHost)); + safe('rolesInfo', () => { + const r = get(S.cloudHooks?.rolesInfo); + return r ? { myRole: r.myRole, amAdmin: r.amAdmin, theirs: r.roleOf?.(pid) } : null; + }); + safe('viewer', () => S.objectPermissions?.isViewer?.()); + return out.join('\n '); + }, other) + .catch((/** @type {any} */ e) => 'err ' + e); + } /** @param {'quiet' | 'hard'} shape */ async function drop(shape) { @@ -181,6 +357,17 @@ h.run(async () => { let cut = false; for (const t = Date.now(); !cut && Date.now() - t < 3000; await A.page.waitForTimeout(150)) cut = !(await signalingUp(A)) && !(await signalingUp(B)); // peerjs marks itself down on the socket's close event + if (!cut || TRACE) + for (const p of [A, B]) + console.log( + ` ${tag} signaling at the cut, ${p === A ? 'A' : 'B'}: ` + + (await p.page.evaluate(() => { + /** @type {any} */ let pc; + window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + const q = pc.peer; + return `open=${q?.open} disconnected=${q?.disconnected} destroyed=${q?.destroyed} ws=${q?._socket?._socket?.readyState ?? '-'} id=${q?.id}`; + })) + ); if (shape === 'hard') { await A.page.evaluate((/** @type {string} */ id) => { /** @type {any} */ let pc; @@ -200,7 +387,7 @@ h.run(async () => { window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); const c = pc.connections[pid]; const age = (/** @type {any} */ x) => (x?.__dialedAt ? Math.round((Date.now() - x.__dialedAt) / 100) / 10 + 's' : '-'); - return `sig=${pc.peer?.open ? 1 : 0} rec=${pc.reconnecting.get(pid) ?? '-'} conn=${c ? (c.open ? 'open' : 'neg ' + age(c)) : 'none'} in=${pc.inboundDials?.has(pid) ? 'neg ' + age(pc.inboundDials.get(pid)) : '-'} opened=${pc.openedPeers.has(pid) ? 1 : 0}`; + return `sig=${pc.peer?.open ? 1 : 0} rec=${pc.reconnecting.get(pid) ?? '-'} conn=${c ? (c.open ? 'open' : 'neg ' + age(c)) + ' ' + c.connectionId : 'none'} in=${pc.inboundDials?.has(pid) ? 'neg ' + age(pc.inboundDials.get(pid)) : '-'} opened=${pc.openedPeers.has(pid) ? 1 : 0}`; }, id) .catch((/** @type {any} */ e) => 'err ' + String(e?.message ?? e).slice(0, 60)); const watch = async () => { @@ -241,11 +428,13 @@ h.run(async () => { } h.check(lost.length === 0, `${tag} each peer kept the other in its roster, and B's lock stood, throughout (${lost.slice(0, 2).join(', ') || 'never lost'})`); const healedBoth = (await openTo(A, B.id)) && (await openTo(B, A.id)); + if (TRACE) await wireTrace(tag, 'after the heal'); if (!healedBoth) console.log(` ${tag} timeline (A=${A.id} B=${B.id}; the lower id dials):\n ` + timeline.join('\n ')); h.check(healedBoth, `${tag} the conn is open both ways again`); h.check((await disconnectToasts(A, toastsA)) === 0 && (await disconnectToasts(B, toastsB)) === 0, `${tag} no "disconnected" toast on either side`); await h.eventually(() => has(B, duringA), (v) => v, `${tag} A's edit made during the drop reaches B`, 15000); await h.eventually(() => has(A, duringB), (v) => v, `${tag} B's edit made during the drop reaches A`, 15000); + if (!TRACE && !(await has(A, duringB))) await wireTrace(tag, "B's edit never reached A"); const afterB = await create(B, 'cone'); await h.eventually(() => has(A, afterB), (v) => v, `${tag} an edit after the drop replicates`, 10000); await h.eventually(async () => (await signalingUp(A)) && (await signalingUp(B)), (v) => v, `${tag} both pages are back on the signaling server`, 20000); From 8a2148b9a16e383c74ab97b6d0c48af9a62a0d30 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:42:27 +0200 Subject: [PATCH 2/6] net: our peer id outlives a signaling drop (deployed net-drop B->A loss) peerjs Peer.disconnect() - every drop of the signaling socket - sets peer.id to null until the socket re-registers. The cloud roles plugin reads its own id from getPeers().peer.id, so for the length of a drop it counted itself alone, wiped its role map and the host it joined; after the heal a joiner read itself as a VIEWER (the host still had it as an editor and, its open set unchanged, never re-sent the map), and core's creation gate kept every edit local. Only deployed builds load the plugin, which is why net-drop was green on a dev server and red on production 1.43.2 / 1.44.0 and every preview. peerDrop.keepPeerId gives the Peer an own id getter: the live id, else the id it was created with. peerjs bookkeeping (_id, _lastServerId, open, disconnected) is untouched. reconnectDial keeps its old no-dial-while-disconnected behaviour. net-drop: own id readable at the cut (red on v1.44.0), roles unchanged by the heal (with a plugin). Unit: keepPeerId against the real peerjs Peer. Co-Authored-By: Claude Opus 5.5 (1M context) --- src/lib/peerDrop.js | 27 ++++++++++++++++++++ src/lib/peerHandler.svelte.js | 8 +++--- tests/e2e/net-drop.test.cjs | 40 +++++++++++++++++++++++++++++ tests/unit/peerDrop.test.js | 48 ++++++++++++++++++++++++++++++++++- 4 files changed, 119 insertions(+), 4 deletions(-) diff --git a/src/lib/peerDrop.js b/src/lib/peerDrop.js index 59f627f1..3ba46731 100644 --- a/src/lib/peerDrop.js +++ b/src/lib/peerDrop.js @@ -102,3 +102,30 @@ export function quietPeerError(type, { lastLeaveAt, reconnecting, now }) { export function unavailablePeerId(message) { return String(message ?? '').match(/peer\s+([0-9a-z][0-9a-z_-]{2,})\s*$/i)?.[1] ?? ''; } + +/** + * net-drop-1: OUR ID OUTLIVES A SIGNALING DROP. peerjs's `Peer.disconnect()` — run on every + * drop of the signaling socket — sets `peer.id` to null until the socket re-registers, while every + * data conn, lock, roster row and plugin keys on that id, and it comes back unchanged (`reconnect()` + * re-registers the id it remembered; a rebuild reuses `PeerConnection.myId`). Everything asking + * "who am I" during the gap read null: the cloud roles plugin counted itself ALONE, wiped its role + * map, and a joiner came back from a healed drop as a VIEWER whose every edit stayed local (the + * net-drop B -> A loss on deployed builds); in core an own avatar, own locks and own shared-library + * rows read as somebody else's. An own getter on THIS Peer answers the live id while there is one, + * else the id it was created with; peerjs's own bookkeeping (`_id`, `_lastServerId`, `open`, + * `disconnected`) is untouched, so "is the signaling link up" is still `open`, never `id`. + * @template T @param {T} peer @param {string} id @returns {T} + */ +export function keepPeerId(peer, id) { + if (!peer || !id) return peer; + /** @type {(() => any) | undefined} */ + let live; + for (let o = Object.getPrototypeOf(peer); o && !live; o = Object.getPrototypeOf(o)) live = Object.getOwnPropertyDescriptor(o, 'id')?.get; + Object.defineProperty(peer, 'id', { + configurable: true, + get() { + return (live ? live.call(this) : null) ?? id; + } + }); + return peer; +} diff --git a/src/lib/peerHandler.svelte.js b/src/lib/peerHandler.svelte.js index e8b9957e..8066f1bf 100644 --- a/src/lib/peerHandler.svelte.js +++ b/src/lib/peerHandler.svelte.js @@ -37,7 +37,7 @@ import { registerDebugHook } from '$lib/debugHooks'; import { routePerfLive, perfLivePeerGone } from '$lib/perf/liveWire.js'; // 34 PF: the live perf stream (an import-free leaf) import { noteWireError } from '$lib/wireErrors'; import { composeAdmitNotice, shortPeer } from '$lib/admitNotice'; -import { ICE_DROP_GRACE_MS, LINK_POLL_MS, strikesNeeded, quietPeerError, unavailablePeerId, consentCounters, linkSilence } from '$lib/peerDrop'; +import { ICE_DROP_GRACE_MS, LINK_POLL_MS, strikesNeeded, quietPeerError, unavailablePeerId, consentCounters, linkSilence, keepPeerId } from '$lib/peerDrop'; import { createDialBackQueue } from '$lib/dialBackQueue'; // scatter-sync: room edits held for an opening approval dial-back import { noteWire } from '$lib/sceneBudget'; import { applyAnnotation, applyAnnotationsSnapshot, sendAnnotations } from '$lib/annotationsHandler'; @@ -380,7 +380,8 @@ export class PeerConnection { // 36-export: an exported game is single-player and opens NO signaling socket — a stub // Peer that never opens (`connect()` answers undefined, which every dial site already // guards, the signaling-down rule), so `$peers.peer.id` and every send stay valid - this.peer = exportMode ? /** @type {Peer} */ (offlinePeer(this.myId)) : new Peer(this.myId, options); + // net-drop-1: the id stays readable through a signaling drop (peerDrop.keepPeerId) + this.peer = exportMode ? /** @type {Peer} */ (offlinePeer(this.myId)) : keepPeerId(new Peer(this.myId, options), this.myId); }; // 27-F (audit H2): THE recreate ritual, in one place. Four callers had their own @@ -2156,7 +2157,8 @@ export class PeerConnection { * Called from the window's timers and when the signaling link comes back. * @param {string} peerId */ reconnectDial(peerId) { - if (this.connections[peerId] || !(this.peer.id < peerId)) return; + // a Peer off the signaling server cannot dial (its id still reads, keepPeerId): the `open` handler redials + if (this.connections[peerId] || this.peer.disconnected || !(this.peer.id < peerId)) return; const conn = this.peer.connect(peerId, dialOptions()); if (!conn) return; // the signaling link is down: the next tick (or its return) redials /** @type {any} */ (conn).__dialedAt = Date.now(); diff --git a/tests/e2e/net-drop.test.cjs b/tests/e2e/net-drop.test.cjs index 98274e1f..c7ec9662 100644 --- a/tests/e2e/net-drop.test.cjs +++ b/tests/e2e/net-drop.test.cjs @@ -17,6 +17,12 @@ // 2. HARD (a network CHANGE): the same, plus the peer connection is closed outright at the start, so // the B5 reconnect window has to redial — through a signaling server it cannot reach until the // drop ends. The window is ~15.5 s (checks at 0.5/1/2/4/8 s), so a 5 s drop must fit inside it. +// net-drop-1 (deployed builds): with a cloud ROLES plugin loaded — every Pages deploy, or a dev server run +// with E2E_CLOUD_PLUGIN=https://theprototype.pages.dev/cloud-plugin.js — the joiner came back from the heal +// a VIEWER and its edits never left it: peerjs nulls `peer.id` while the signaling socket is down, the +// plugin read "no id, one peer" as alone and wiped its role map. Two checks hold the fix +// (peerDrop.keepPeerId): the own id at the cut (no plugin needed; red on v1.44.0) and roles unchanged by +// the heal (with a plugin; B read viewer on v1.44.0 + the production plugin, 4 B -> A checks red). // Counterfactual: NETDROP_MS=20000 outlasts the window — phase 2 goes red (teardown + toast; measured // e2e/v10cf). Phase 1 stays green at 20 s, by design: a QUIET drop never breaks the data channel, so it // proves the conn carries on, not the window's length. @@ -324,6 +330,17 @@ h.run(async () => { .catch((/** @type {any} */ e) => 'err ' + e); } + /** each side's own role and its role for the other, from the rolesInfo bridge (null without a plugin) */ + async function roles() { + const one = (/** @type {any} */ p, /** @type {string} */ other) => + p.page.evaluate((/** @type {string} */ pid) => { + /** @type {any} */ let r; + window.__stores.cloudHooks?.rolesInfo?.subscribe((/** @type {any} */ v) => (r = v))(); + return r ? `${r.myRole}/${r.roleOf?.(pid)}` : null; + }, other); + return { a: await one(A, B.id), b: await one(B, A.id) }; + } + /** @param {'quiet' | 'hard'} shape */ async function drop(shape) { const tag = shape.toUpperCase() + ` ${DROP_MS / 1000}s drop:`; @@ -337,6 +354,7 @@ h.run(async () => { pc.connections[id].__th1 = 'before'; }, other); } + const rolesBefore = await roles(); // ---- the network goes away for (const p of [A, B]) await p.page.context().setOffline(true); for (const p of [A, B]) { @@ -357,6 +375,20 @@ h.run(async () => { let cut = false; for (const t = Date.now(); !cut && Date.now() - t < 3000; await A.page.waitForTimeout(150)) cut = !(await signalingUp(A)) && !(await signalingUp(B)); // peerjs marks itself down on the socket's close event + // net-drop-1: who we are does not change while the signaling link is down. peerjs nulls `peer.id` + // on every drop (Peer.disconnect); the cloud roles plugin then counted itself alone, wiped its role + // map, and B came back from the heal a VIEWER whose edits never left (deployed builds only — a dev + // server has no plugin). Counterfactual: red on v1.44.0 (both read null), green with keepPeerId. + const ids = []; + for (const p of [A, B]) + ids.push( + await p.page.evaluate(() => { + /** @type {any} */ let pc; + window.__stores.peers.subscribe((/** @type {any} */ v) => (pc = v))(); + return pc.peer?.id ?? null; + }) + ); + h.check(!cut || (ids[0] === A.id && ids[1] === B.id), `${tag} each page still reads its own peer id while the signaling link is down (${ids.join(', ')})`); if (!cut || TRACE) for (const p of [A, B]) console.log( @@ -428,6 +460,14 @@ h.run(async () => { } h.check(lost.length === 0, `${tag} each peer kept the other in its roster, and B's lock stood, throughout (${lost.slice(0, 2).join(', ') || 'never lost'})`); const healedBoth = (await openTo(A, B.id)) && (await openTo(B, A.id)); + // with a roles plugin loaded (every deployed build; E2E_CLOUD_PLUGIN on a dev server), the heal + // changes nobody's role — on either side + const rolesNow = await roles(); + if (rolesBefore.a || rolesBefore.b) + h.check( + JSON.stringify(rolesNow) === JSON.stringify(rolesBefore), + `${tag} roles unchanged by the heal (before ${JSON.stringify(rolesBefore)}, after ${JSON.stringify(rolesNow)})` + ); if (TRACE) await wireTrace(tag, 'after the heal'); if (!healedBoth) console.log(` ${tag} timeline (A=${A.id} B=${B.id}; the lower id dials):\n ` + timeline.join('\n ')); h.check(healedBoth, `${tag} the conn is open both ways again`); diff --git a/tests/unit/peerDrop.test.js b/tests/unit/peerDrop.test.js index 781e6b47..8d9fa813 100644 --- a/tests/unit/peerDrop.test.js +++ b/tests/unit/peerDrop.test.js @@ -8,8 +8,10 @@ import { strikesNeeded, consentCounters, linkSilence, - LINK_SILENT_MS + LINK_SILENT_MS, + keepPeerId } from '../../src/lib/peerDrop.js'; +import { Peer } from 'peerjs'; // F-3: a dropped peer leaves Users within seconds and a leave never toasts "Connection error". describe('F-3 peer drop policy', () => { @@ -72,3 +74,47 @@ describe('F-3 peer drop policy', () => { expect(linkSilence({ responses: 5, requests: 5, at: 0 }, null, 60000).silent).toBe(false); }); }); + +// net-drop-1: peerjs's Peer.disconnect() (every signaling drop) sets `id` to null until the socket +// re-registers, and everything that asks "who am I" read null for the length of the drop — the +// cloud roles plugin then counted itself ALONE, wiped its role map, and a joiner came back from a +// healed drop as a viewer whose every edit stayed local (net-drop on deployed builds, B -> A red). +describe('keepPeerId: our id survives a signaling drop', () => { + /** the real peerjs Peer, disconnected the way a dropped socket disconnects it */ + function droppedPeer(/** @type {boolean} */ kept) { + const peer = Object.create(Peer.prototype); + Object.assign(peer, { _id: 'abc12', _lastServerId: null, _disconnected: false, _open: true, _destroyed: false }); + peer._socket = { close() {} }; + peer.emit = () => true; + if (kept) keepPeerId(peer, 'abc12'); + expect(peer.id).toBe('abc12'); + peer.disconnect(); + expect(peer.disconnected).toBe(true); + return peer; + } + it('counterfactual: an unwrapped peerjs Peer reads null while disconnected', () => { + expect(droppedPeer(false).id).toBe(null); + }); + it('a wrapped Peer keeps reading its id through the drop, and peerjs still reconnects on it', () => { + const peer = droppedPeer(true); + expect(peer.id).toBe('abc12'); + // peerjs keeps its own bookkeeping: the reconnect re-registers the id it remembered + expect(peer._lastServerId).toBe('abc12'); + const started = []; + peer._socket = { start: (/** @type {string} */ id) => started.push(id), close() {} }; + peer._options = {}; + peer.reconnect(); + expect(started).toEqual(['abc12']); + expect(peer.id).toBe('abc12'); + }); + it('the live id wins over the remembered one, and a missing peer or id is left alone', () => { + const peer = Object.create(Peer.prototype); + peer._id = 'zzz99'; + keepPeerId(peer, 'abc12'); + expect(peer.id).toBe('zzz99'); + expect(keepPeerId(null, 'abc12')).toBe(null); + const bare = { id: 'x' }; + expect(keepPeerId(bare, '')).toBe(bare); + expect(bare.id).toBe('x'); + }); +}); From 51630e43a364ba86ea8f1302514522c67d5bec6a Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:56:32 +0200 Subject: [PATCH 3/6] peerDrop test: type the started list (svelte-check ratchet back to 287/39) Co-Authored-By: Claude Opus 5.5 (1M context) --- tests/unit/peerDrop.test.js | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/peerDrop.test.js b/tests/unit/peerDrop.test.js index 8d9fa813..053698b4 100644 --- a/tests/unit/peerDrop.test.js +++ b/tests/unit/peerDrop.test.js @@ -100,6 +100,7 @@ describe('keepPeerId: our id survives a signaling drop', () => { expect(peer.id).toBe('abc12'); // peerjs keeps its own bookkeeping: the reconnect re-registers the id it remembered expect(peer._lastServerId).toBe('abc12'); + /** @type {string[]} */ const started = []; peer._socket = { start: (/** @type {string} */ id) => started.push(id), close() {} }; peer._options = {}; From 64292d40ec4c87af3ca925e63b7761128b58ea12 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Mon, 26 Oct 2026 09:56:57 +0200 Subject: [PATCH 4/6] CLAUDE.md: peer.id survives a signaling drop; run two-peer suites with E2E_CLOUD_PLUGIN for the deployed condition Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE-index.md | 539 ++++++++++++++++++++++++------------------------ CLAUDE.md | 14 ++ 2 files changed, 284 insertions(+), 269 deletions(-) diff --git a/CLAUDE-index.md b/CLAUDE-index.md index 830b840b..32755080 100644 --- a/CLAUDE-index.md +++ b/CLAUDE-index.md @@ -2,7 +2,7 @@ Generated by `node scripts/claude-index.cjs` (never edit; on a merge conflict, run it again). Grep this file for a word, then read CLAUDE.md from that line (`Read CLAUDE.md offset= limit=60`). -7068 lines indexed. +7082 lines indexed. 1 # CLAUDE.md — theprototype.app @@ -363,274 +363,275 @@ Grep this file for a word, then read CLAUDE.md from that line (`Read CLAUDE.md o 4233 - **flowbite `Button disabled` styling can go stale until the component 4240 - #14 file-shape traps 4261 - Connection "connected" state = `$peers.openedPeers`, NOT `userdata.length` - 4266 - A fixed overlay anchored under the Connect pill can't use a `position:fixed` - 4272 - Headless e2e can't reach a signaling server (peer.open stays false, `peer.connect` - 4275 - Svelte 5.5x runes-mode: a MULTI-CODE `svelte-ignore` comment only honors the FIRST - 4281 - Svelte 5 forbids mixing `on:click` and `onclick` **per component** — match the file's - 4288 - a11y warnings count against the baseline too. A `
` you make keyboard-focusable - 4296 - Editing svelte files by exact-match is whitespace-sensitive; the files use TABS. - 4300 - Module-level `store.subscribe(cb)` runs cb SYNCHRONOUSLY at module eval — any - 4310 - svelte-awesome-color-picker 4.x (runes rewrite, #15-C) - 4318 - Toasts (#15-L/P) - 4328 - PWA (#15-N) - 4336 - **A component's scoped `