diff --git a/plugins/social/src/social.js b/plugins/social/src/social.js index 3d25ec99a..1d3941e1f 100644 --- a/plugins/social/src/social.js +++ b/plugins/social/src/social.js @@ -3996,7 +3996,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp const mine = (snapshot?.members ?? hosted?.participants ?? []).find(member => locals.has(norm(member.handle))) return cleanText(mine?.ownerName || mine?.owner_name, '') || 'Someone' } - function faceSnapshotPast(snapshot, hosted) { + // #1606: one snapshot that lists no peer is not proof the peers left. Right after face-start + // the member list lags behind the seats, and a single dropped or half-read response looks the + // same. An open room ends only when two snapshots in a row agree that every other home is gone, + // or when a snapshot carries the leave itself; anything less than that must not close the room. + // `peerGone` is the caller's verdict from #1606: two snapshots in a row with no other home, or a + // snapshot carrying the leave itself. One peerless snapshot on its own never ends a room. + function faceSnapshotPast(snapshot, hosted, peerGone = false) { const status = snapshot?.status || hosted?.face?.status if (status === 'closed' || status === 'left') return { status } // Once the room is left or closed here, a later poll must not reopen it (#182): the peer's @@ -4008,14 +4014,13 @@ export function createSocial(service, config = {}, { client: given, api: givenAp if (held === 'left' || held === 'closed') return { status: held } const expiresAt = snapshot?.expiresAt || hosted?.face?.expiresAt const locals = new Set(bringable().map(bot => bot.handle).filter(Boolean)) - const previousRemotes = (hosted?.participants ?? []).filter(p => p.leftSeq === undefined && p.remote === true) const currentRemotes = (snapshot?.members ?? []).filter(member => member.handle && !locals.has(norm(member.handle))) // Every other home left an open room: it has ended here, including when the next snapshot // falls back to the waiting lobby (candidate, startedAt omitted). A room that was only ever // a candidate lobby stays one until its window runs out (#1072, #304). Checked before the // expired-candidate return so a started room still leaves on Bot Social. const wasOpen = held === 'open' || Boolean(hosted?.face?.startedAt) - if ((status === 'open' || wasOpen) && previousRemotes.length > 0 && currentRemotes.length === 0) return { status: 'closed', peersGone: true } + if ((status === 'open' || wasOpen) && peerGone) return { status: 'closed', peersGone: true } if (status === 'candidate' && expiresAt) { const at = Date.parse(expiresAt) if (Number.isFinite(at) && at <= now()) return { status: 'closed' } @@ -4033,16 +4038,41 @@ export function createSocial(service, config = {}, { client: given, api: givenAp function neverShared(hosted) { return !(hosted?.participants ?? []).some(p => p.remote === true) } + function facePeersGone(snapshot, hosted, locals) { + const roomId = snapshot?.roomId + const mine = new Set(locals.map(bot => bot.handle).filter(Boolean).map(norm)) + const here = new Set() + for (const member of snapshot?.members ?? []) { + const handle = norm(member?.handle) + if (handle && !mine.has(handle)) here.add(handle) + } + const known = facePeers.get(roomId) ?? new Set() + for (const p of hosted?.participants ?? []) { + if (p.leftSeq === undefined && p.remote === true && p.handle) known.add(norm(p.handle)) + } + for (const handle of here) known.add(handle) + facePeers.set(roomId, known) + const left = (snapshot?.events ?? []).some(event => event?.type === 'member.left') + const misses = here.size > 0 ? 0 : (facePeerMisses.get(roomId) ?? 0) + (known.size > 0 ? 1 : 0) + facePeerMisses.set(roomId, misses) + return known.size > 0 && here.size === 0 && (left || misses >= 2) + } + // #1606: the seats a snapshot lists, and how many snapshots in a row each room has gone without + // them. Right after face-start the member list lags behind the seats, so one snapshot with no + // other home proves nothing; the room ends on the second agreeing snapshot or on the leave itself. + const facePeers = new Map() + const facePeerMisses = new Map() async function applyFaceSnapshot(snapshot, { asBot } = {}) { if (!snapshot?.roomId || !flybot.rooms?.upsertFace) return undefined const attested = await serverAttests() const locals = bringable() const hosted = flybot.rooms.ofFace?.(snapshot.roomId) + const gone = snapshot.status === 'closed' || snapshot.status === 'left' ? false : facePeersGone(snapshot, hosted, locals) const previousLive = new Set((hosted?.participants ?? []) .filter(p => p.leftSeq === undefined) .map(p => norm(p.handle || p.id)) .filter(Boolean)) - const past = faceSnapshotPast(snapshot, hosted) + const past = faceSnapshotPast(snapshot, hosted, gone) const title = faceRoomTitle(snapshot, { code: settings.faceRooms[snapshot.roomId]?.joinCode, room: hosted }) const room = await flybot.rooms.upsertFace({ roomId: snapshot.roomId, diff --git a/plugins/social/test/social.test.js b/plugins/social/test/social.test.js index 8817494e8..025652cbb 100644 --- a/plugins/social/test/social.test.js +++ b/plugins/social/test/social.test.js @@ -2587,6 +2587,9 @@ test('when the last peer leaves, the face room moves to Past rooms', async (t) = // #304: the title keeps the number it was joined by, through polls and the end. assert.match(ctx.flybot.rooms.ofFace('frm_123456').name, / · 123456$/) room.members = room.members.filter(member => member.handle !== '@coder.bob.home') + // #1606: one snapshot with no peer listed is not proof everyone left; two agreeing snapshots are. + await call('flybot/social/face-rooms', { poll: true }) + assert.equal(ctx.flybot.rooms.ofFace('frm_123456').face.status, 'open', 'one peerless snapshot keeps the room open') const listed = (await call('flybot/social/face-rooms', { poll: true })).value const past = listed.rooms.find(item => item.faceRoomId === 'frm_123456') // This home did not leave: the room ended around it, so it reads Closed, not Left (#304) ... @@ -2609,6 +2612,29 @@ test('when the last peer leaves, the face room moves to Past rooms', async (t) = assert.equal(again.endedAt, past.endedAt) }) +// #1606: face-start seats the peer here at once while the server's member list still lags; one +// peerless snapshot must not end the room (it flipped to closed 1-3 s after face-start). +test('#1606: one snapshot with no peer listed does not close an open face room', async (t) => { + const { ctx, call, server } = await faceSocial(t) + const room = server.face.peer('160616') + await call('flybot/social/face-join', { code: '160616' }) + room.status = 'open' + room.started_at = new Date().toISOString() + await call('flybot/social/face-rooms', { poll: true }) + assert.equal(ctx.flybot.rooms.ofFace('frm_160616').face.status, 'open') + // A snapshot whose member list has not caught up: the room must stay open and keep its seat. + room.members = [] + const first = (await call('flybot/social/face-rooms', { poll: true })).value.rooms.find(item => item.faceRoomId === 'frm_160616') + assert.equal(first.status, 'open', 'one peerless snapshot keeps the room open') + assert.equal(ctx.flybot.rooms.ofFace('frm_160616').face.status, 'open') + assert.equal(room.status, 'open', 'the room was not left on Bot Social') + assert.equal(server.calls.some(c => c.name === 'REST POST /frm_160616/leave'), false, 'no face-leave for one snapshot') + // Two snapshots in a row that agree are a peer leave: the room ends and this home leaves it. + await call('flybot/social/face-rooms', { poll: true }) + assert.equal(ctx.flybot.rooms.ofFace('frm_160616').face.status, 'closed') + assert.ok(server.calls.some(c => c.name === 'REST POST /frm_160616/leave')) +}) + // #1103: B left room 739184 and A's pane showed both "SelfB1025 left the room" and the // banner "SelfB1025 left the room. Room closed." The closed line already names who left. test('#1103: a peer leave that closes the room posts no second leave line', async (t) => { @@ -2730,6 +2756,10 @@ test('#304: a failed automatic leave is tried again on the next poll', async (t) room.members.push({ bot_id: 'bot_peer', handle: '@coder.bob.home', display_name: 'Coder', owner_name: 'Bob', published: false, label: 'Coder · Bob' }) await call('flybot/social/face-rooms', { poll: true }) room.members = room.members.filter(member => member.handle !== '@coder.bob.home') + // #1606: the first peerless snapshot keeps the room open and leaves nothing. + await call('flybot/social/face-rooms', { poll: true }) + assert.equal(leaves.length, 0, 'one peerless snapshot does not leave') + assert.equal(ctx.flybot.rooms.ofFace('frm_123456').face.status, 'open') await call('flybot/social/face-rooms', { poll: true }) assert.equal(leaves.length, 1) assert.equal(room.status, 'open', 'the first leave failed') @@ -2793,6 +2823,9 @@ test('#1072: an open face room whose next snapshot is a waiting lobby closes ins delete room.started_at room.expires_at = new Date(Date.now() + 10 * 60_000).toISOString() room.members = room.members.filter(member => member.handle !== '@coder.bob.home') + // #1606: one peerless snapshot keeps it open, the second ends it. + await call('flybot/social/face-rooms', { poll: true }) + assert.equal(ctx.flybot.rooms.ofFace('frm_123456').face.status, 'open') const listed = (await call('flybot/social/face-rooms', { poll: true })).value const past = listed.rooms.find(item => item.faceRoomId === 'frm_123456') const hosted = ctx.flybot.rooms.ofFace('frm_123456') @@ -2973,6 +3006,9 @@ test('#182: a peer leaving closes the room here and a later poll does not reopen await call('flybot/social/face-rooms', { poll: true }) assert.equal(ctx.flybot.rooms.ofFace('frm_123456').face.status, 'open') body.members = body.members.filter(member => member.handle !== '@chief.bob.home') + // #1606: the first peerless snapshot keeps the room open; the second agreeing one closes it. + await call('flybot/social/face-rooms', { poll: true }) + assert.equal(ctx.flybot.rooms.ofFace('frm_123456').face.status, 'open', 'one peerless snapshot keeps the room open') await call('flybot/social/face-rooms', { poll: true }) assert.equal(ctx.flybot.rooms.ofFace('frm_123456').face.status, 'closed') await call('flybot/social/face-rooms', { poll: true }) @@ -10272,6 +10308,9 @@ test('#458: a room stays open while two or more people are in it; it closes when await call('flybot/social/face-rooms', { poll: true }) assert.equal(ctx.flybot.rooms.ofFace('frm_444444').face.status, 'open', 'Bob left; Carol and I are still here') room.members = room.members.filter(member => member.handle !== carol.handle) + // #1606: two agreeing peerless snapshots, not one. + await call('flybot/social/face-rooms', { poll: true }) + assert.equal(ctx.flybot.rooms.ofFace('frm_444444').face.status, 'open', 'one peerless snapshot keeps the room open') await call('flybot/social/face-rooms', { poll: true }) assert.equal(ctx.flybot.rooms.ofFace('frm_444444').face.status, 'closed', 'only I am left') const past = (await call('flybot/social/face-rooms', { poll: false })).value.rooms.find(item => item.faceRoomId === 'frm_444444')