0%

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 and its points are 1 to 10. Inside one with board["lock"]: block, it does four things in order:

  1. check the team: if message["team"] is not a key of board["scores"], raise ValueError("unknown team") before anything changes;

  2. add the points to that team’s score;

  3. add 1 to board["version"];

  4. build the new snapshot with snapshot_message(board), pass it to broadcast(board, snapshot), and return it.

The is for Lesson 6, where the scorekeeper’s handler answers each score with the new snapshot. Returning the snapshot made inside the hold matters: if the caller read the board again after the lock was released, another score might already have changed it, and the scorekeeper would be told about a board its score did not produce. The team check lives here and not in 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 around each send and go on to the next viewer.

The gone viewer should also leave the registry. The obvious line, removing it inside the , fails, because a must not change size while a for loop walks over it:

Try it

So collect the ids of failed viewers in a during the loop, and remove them after it. Walking the registry with .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 then received version 1. B’s send failed, so B kept only its join snapshot and left the registry, and C still heard the update. Then press Submit.

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 , in this order. Submit names the first one that still raises NotImplementedError.

  1. broadcast(board, snapshot): the caller already holds the board lock, so take no lock here. Encode the snapshot once with scoreboard_messages.encode_message, send the text to every connection in board["viewers"], and catch ConnectionClosed around each send, adding that viewer’s id to a . After the , remove every id in the list from board["viewers"].

  2. apply_score(board, message): inside one with board["lock"]: block, raise ValueError("unknown team") if message["team"] is not on the board, before changing anything; otherwise add message["points"] to that team’s score, add 1 to board["version"], make the new snapshot with snapshot_message(board), call broadcast(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 and leave the board, the viewers and the lock as they were. Every change to the board and every send must happen while the lock is held, and the lock must be free afterwards. A board with no viewers must still change, and 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.