Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 34 additions & 4 deletions plugins/social/src/social.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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' }
Expand All @@ -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,
Expand Down
39 changes: 39 additions & 0 deletions plugins/social/test/social.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) ...
Expand All @@ -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) => {
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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')
Expand Down Expand Up @@ -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 })
Expand Down Expand Up @@ -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')
Expand Down
Loading