The barrier is only as fast as the member that never answers
Several clients each resolve their own copy of the same item, at their own speed. When one of them cannot keep up, holding the rest with it takes a rendezvous: the server announces who it is still waiting on, each named client answers when it has the item, and only when the last answer lands does anybody hear anything.
// adapted — names generalized; the message constants are placeholders
private suspend fun answerBarrier() {
room.map { it.waitingFor to it.currentItem?.id }
.distinctUntilChanged()
.collect { (waitingFor, itemId) ->
val state = room.value
if (itemId.isNullOrBlank() || !state.inRoom) return@collect
if (state.selfId !in waitingFor) return@collect
// bufferedness, not playing: the barrier asks whether the item is loaded, and playback
// is exactly what it is holding back.
if (player.bufferedPercentage >= READY_BUFFER_PERCENT) reportReady(itemId)
}
}
The whole model rests on one uncomfortable property: this is a liveness dependency on every other participant. Everything below follows from that.
Traps
Answering on "playing" is a deadlock, and it looks like a hang. Playback is what the barrier is holding back, so a client that waits to be playing before reporting ready waits for something the barrier will never let happen — and because the barrier holds everyone, the whole room stops, including the member driving it. Answer on the loaded/buffered signal instead, at a low threshold: a few percent proves the stream resolved, which is the actual question. Waiting for a large buffer just makes the slowest device slower without making the start any more reliable.
A client that does not implement the answer at all freezes the group silently. There is no error, no timeout on the client side, and nothing in the logs of the members who are behaving correctly — they are simply told to wait and they wait. This is the failure mode to look for first when a shared session stops working after a new participant joins, and it is why the answer belongs in its own collector started unconditionally, never inside a branch that some role or setting can skip.
Only answer when you are named. The announcement carries the list of members still outstanding;
answering while not in it is at best noise and at worst confuses a server that tracks the set by
membership. The selfId !in waitingFor guard is one line and it also makes the collector safe to
run on a re-announcement, which arrives every time somebody else answers.
Key the answer to the item being waited for, not to "the current item". Send back the id from
the announcement. If the local item changed in the meantime, the answer is about the wrong thing and
the server keeps waiting — and re-deriving the id from local state is exactly how that happens. The
same reasoning applies to the distinctUntilChanged key above: it carries both the outstanding list
and the item id, because either changing is a new question.
Silence with no explanation reads as a hang, so name the members. Store ids on the wire and resolve them to display names at the edge: "waiting for " is the only phrasing that tells a user what is happening and who to nudge. A bare spinner during a barrier is indistinguishable from a crash, and users respond by force-quitting — which removes the member the group was waiting for and makes the barrier appear to "fix itself", hiding the real cause.
Clear both halves when the barrier completes. The outstanding list and any per-member flag
derived from it are two pieces of state, and the completion message must reset both. Leaving the
per-member flag set paints one participant as permanently loading, which is a bug report about the
wrong thing entirely. Deriving the flag from the list at read time (id in waitingFor) avoids the
whole class.
Make the answer idempotent rather than one-shot. The announcement is re-broadcast every time somebody else answers, so the collector fires repeatedly with a shrinking list — and that is fine, because the membership guard stops answering as soon as you are removed from it. The tempting optimisation is a local "already answered for this item" flag, which reintroduces the freeze the moment a first answer is lost in transit: the flag says done, the barrier says waiting, and neither side moves. Let re-answering be cheap and let the list be the authority.
A barrier is not a substitute for position sync, and which gap each one owns is a property of the
server rather than of your client. In the arrangement this was mined from, the item change
clears the outstanding set server-side, so the barrier never gates a change at all: the loading
gap at a transition is absorbed by publishing the position and correcting it for flight time —
position-based-group-sync — and the barrier engages only when a device stalls part-way through an
item that is already running. Read the server's own handling instead of assuming, because the client
code is agnostic and cannot tell you which arrangement you are in. Getting it backwards makes every
transition wait on the slowest member, which is the cost the position correction exists to avoid.
Verifying it
The barrier crosses three layers — protocol, the answering collector, and the UI that explains it — so verify it as three greps rather than by reading the answering function again:
grep -rniE 'waitingFor|bufferReady|isBuffering' --include='*.kt' . | grep -v '/build/' | cut -d: -f1 | sort -u
grep -rn 'bufferedPercentage' --include='*.kt' . | grep -v '/build/'
grep -rniE 'waitingFor' --include='*.kt' . | grep -v '/build/' | grep -iE 'name|username|displayName'
The first is the census of everything that participates: expect the protocol payloads, the state machine, the domain model, the answering collector and at least one presentation file. A missing presentation file is the "silent hang" defect — the barrier exists and nothing tells the user about it. The second finds every reader of the loaded signal; the one inside the answering collector is the barrier's, and if that grep shows the collector reading a playing flag instead, stop and fix it before anything else. The third should show the id→name resolution, and it is worth confirming it lives on the model both the state machine and the UI can see rather than being re-derived in a composable.
Then run the only test that actually exercises the property: join with two clients and put one of them on a deliberately slow or blocked network at an item change. The fast client must show the slow one by name and start when it answers. Kill the slow client instead of letting it answer, and watch what the fast one does — if it waits forever with no message, the barrier has no give in it and the group depends on every member behaving, which is worth knowing before users find out.