One Server, Many Clients · practice
Broadcast Every Change
The registry finds every connected viewer, and the board still never changes. This lesson writes the change: apply_score adds a score’s points to the board, and broadcast sends the new board to every viewer.
One change, one hold
apply_score(board, message) receives a score envelope that parse_message has already checked, so its team is a with board["lock"]: block, it does four things in order:
check the team: if
message["team"]is not a key ofboard["scores"], raiseValueError("unknown team")before anything changes;add the points to that team’s score;
add 1 to
board["version"];build the new snapshot with
snapshot_message(board), pass it tobroadcast(board, snapshot), and return it.
The check_message, because only the board knows which teams are playing.
All four steps share one hold for the reason of Lesson 3. The join takes the same lock, so a viewer joins either before the change, and is registered to receive it, or after it, and its snapshot already contains it.
One encode, many sends
broadcast(board, snapshot) is called by apply_score while the lock is held, so it must not take the lock itself: a thread that takes a lock it already holds waits for itself forever. It encodes the snapshot once with scoreboard_messages.encode_message, then sends that same text to each viewer in board["viewers"]. Every viewer gets the same bytes, so there is no reason to encode them again for each one. Encoding also checks the envelope, once, before anything is sent to anyone.
A viewer that is gone
A viewer can vanish at any time: a closed laptop, a lost network. The registry does not know until something tries to use the connection. Then send raises ConnectionClosed, which you met in Chapter 6. One gone viewer must not cost the others their update, so catch the
The gone viewer should also leave the registry. The obvious line, removing it inside the for loop walks over it:
So collect the ids of failed viewers in a .items() gives you each id and its connection together:
failed = []
for viewer_id, viewer in board["viewers"].items():
try:
viewer.send(text)
except ConnectionClosed:
failed.append(viewer_id)
Looking each connection up with board["viewers"][viewer_id] works just as well. When that viewer’s own handler later reaches its finally block, its remove_viewer finds the id gone, and pop with a default makes that harmless:
What does this print?
d = {"a": 1}
print(d.pop("b", None), d.pop("a", None), d)
In broadcast, the ids came from the registry a moment earlier, so plain pop(viewer_id) is fine there.
What happens to the gone viewer’s handler? Its thread is still in watch_until_closed. Its next recv raises ConnectionClosed too, the handler leaves through except and finally, and remove_viewer finds nothing to remove. Two threads both try to remove the viewer, and whichever comes second does nothing.
Three viewers are registered and the second one's send raises ConnectionClosedError. What does broadcast do?
Order for free, for now
Every send to a viewer now happens while some thread holds the board lock: the join snapshot inside add_viewer, and every update inside apply_score. Only one thread holds the lock at a time, so the sends to any one viewer happen one after another in the order the board changed. A viewer can never be sent version 7 before version 6.
Why can a viewer never receive version 7 before version 6 in this chapter's server?
That order comes at a price. While one viewer’s send is in progress, the lock is held, so every other viewer, the next score and every new join wait for it. Sending under a lock is a deliberate simplification for this chapter, and it is not how a busy server should work. Chapter 8 measures what it costs and removes it.
Run
Press Run. The Lesson 4 section registers three viewers, makes B’s send fail, and applies one score. A finished version, run on the site, printed:
Lesson 4: one score, three viewers, one of them gone
viewer A received versions [0, 1]
viewer B received versions [0]
viewer C received versions [0, 1]
registry after the update: ['A', 'C']
Each viewer received its join snapshot, version 0. A and
Join, registry and broadcast all work now, and only the demo and Submit have tried them. The next lesson has you write tests of your own that prove each one.
Task
In scoreboard_server.py, write two NotImplementedError.
broadcast(board, snapshot): the caller already holds the board lock, so take no lock here. Encode the snapshot once withscoreboard_messages.encode_message, send the text to every connection inboard["viewers"], and catchConnectionClosedaround each send, adding that viewer’s id to a. After the , remove every id in the list from board["viewers"].apply_score(board, message): inside onewith board["lock"]:block, raiseValueError("unknown team")ifmessage["team"]is not on the board, before changing anything; otherwise addmessage["points"]to that team’s score, add 1 toboard["version"], make the new snapshot withsnapshot_message(board), callbroadcast(board, snapshot), and return the snapshot.
Press Run and read the Lesson 4 section, then press Submit. The check watches the board lock and records every write to the board’s version and scores. It registers four stand-in viewers, the second of which has gone, and applies a score you have not seen: it expects the new snapshot back, each of the other three viewers to receive it exactly once, and the registry to keep those three in their order. Two scores in a row must reach every viewer as versions 1 and 2. A score for Bats must raise broadcast called on its own with the lock held must reach every viewer that has not gone. Iterating over board["viewers"].copy(), catching Exception instead of ConnectionClosed, and encoding once per viewer are all accepted.