0%

One Server, Many Clients · practice

Keep a Registry of Viewers

After Lesson 2 a viewer receives its snapshot, and then the server forgets it. When a score arrives, nothing knows which viewers to tell. This lesson keeps a registry of connected viewers, closes a race at the moment a viewer joins, and makes sure every viewer leaves the registry however its connection ends.

A registry keyed by id

The registry is board["viewers"], a . The key is connection.id, a the library gives every connection, unique to it; the value is the connection itself, so Lesson 4 can call send on each one. The id is the key rather than the connection because a stand-in connection built with types.SimpleNamespace, the kind the demo and the tests use, cannot itself be a , and an id always can.

add_viewer(board, connection) stores the connection and sends its join snapshot. remove_viewer(board, connection) takes it out again. Between those two calls, the viewer’s handler is inside watch_until_closed, and any other thread that looks in the registry finds it there. Looking means walking the dict, for example with .items(), which gives each id with its connection:

for viewer_id, viewer in board["viewers"].items():
    print(viewer_id, viewer.request.path)

The registry lives on the board, next to the scores, for the same reason the scores do: every that needs it receives the board, and there is no global for a stray thread to change behind your back.

The race at the door

Several threads use the board at once: one per viewer, and the scorekeeper’s. Joining takes three steps: read the board into a snapshot, register, and send the snapshot. The picture follows the viewer’s thread on the left and the scorekeeper’s on the right, time running down; the top half has no lock. Find the moment version 6 goes out: is the viewer registered yet?

Without one lock hold, a score broadcast between the viewer's read and its registration misses the viewer, which shows version 5 until the next score; with the read, registration and send inside one with board lock block, the score waits and reaches the registered viewer.

In words: the viewer’s thread reads version 5; the scorekeeper’s thread applies a score, making version 6, and sends it to every registered viewer; only then does the viewer register and send its version 5 snapshot. It shows version 5 and hears nothing more until the next score.

Move the score one step later and a different thing goes wrong: the viewer reads version 5 and registers, version 6 is broadcast to it, and then the version 5 snapshot it read earlier is sent, after the newer one.

You met the answer in Networks with Python: a lock held around a read and a write, so that no other thread can act between them. Here the read, the registration and the send all go inside one with board["lock"]: block in add_viewer. That is the lower half of the picture: the scorekeeper waits for the lock, then applies and sends version 6. The scorekeeper’s thread will take the same lock to apply a score (Lesson 4), so a score happens either entirely before the viewer joins, and the snapshot already contains it, or entirely after, and the viewer is registered to hear it. remove_viewer takes the lock too.

A viewer reads the snapshot inside the lock, releases it, then registers inside a second hold. What can go wrong?

finally: cleanup that always runs

A viewer must leave the registry however its handler ends: a clean close, a connection that dropped, a send that failed, or a bug that raised something unexpected. Python has a block for exactly that. This course needs it now, so here it is; Python Fundamentals II covers it in full.

A try statement can end with finally:. Its block runs on every way out of the try: after the last line, after a , and while an passes through, which then carries on after the finally block.

def show(how):
    try:
        if how == "return":
            return "returned"
        if how == "raise":
            raise RuntimeError("broken")
        print("reached the end")
    finally:
        print("finally ran")

Read it with each of three . show("end") prints both lines. show("return") prints finally ran and then returns. show("raise") prints finally ran and then the RuntimeError reaches the caller.

What does this print?

def step():
    try:
        print("try")
        return
    finally:
        print("finally")

step()
print("after")

Run it to check your answer:

Try it

The viewer handler becomes:

def viewer_handler(connection, board):
    try:
        add_viewer(board, connection)
        watch_until_closed(connection)
    except ConnectionClosed:
        pass
    finally:
        remove_viewer(board, connection)

add_viewer sits inside the try because its own send can find the connection already gone; then except ConnectionClosed: catches that, and the finally still removes the viewer. That clause also keeps a normal ending out of the library’s log. Any other exception is not caught: it passes through finally, which cleans up, and reaches the library, which logs it and closes the connection with 1011. That is what you want for a real bug. Catching every exception here would hide it, and the viewer would leave without anyone learning why.

Nothing follows the try statement in the handler. Once the finally block has run, the handler returns, and the library finishes closing the connection.

remove_viewer uses board["viewers"].pop(connection.id, None). The second argument is what pop returns when the key is missing, and with it a missing key is not an error. The key can be missing: from Lesson 4 on, a broadcast removes a viewer whose send failed, and that viewer’s handler then reaches its own finally.

add_viewer raises ConnectionClosedError because the viewer left at once. Which lines run?

Holding a lock while sending

add_viewer sends the join snapshot while it holds the lock. That keeps the order simple and certain: nothing can change the board until the snapshot is on its way. The price is that every other thread waits for that send, and Chapter 8 measures what that costs and pays it.

Hold the lock only inside add_viewer and remove_viewer, never around watch_until_closed: a handler that held it while waiting for the viewer would stop every score until that viewer left.

Run

Press Run. A finished version, run on the site, printed:

Lesson 3: the registry
    while A is connected, the registry holds: ['A']
    after A's handler returned, the registry holds: []
    after B failed at its first send, the registry holds: []

The section gives viewer A a script in which one recv call looks at the registry while A is connected, then ends the connection with a clean close. Viewer A is in the registry while it is connected and gone once its handler returns. Viewer B’s join send fails; the except clause catches it and finally removes B, so the error line from Lesson 2 is gone. Then press Submit.

The registry knows every viewer now. The next lesson changes the board and sends each change to all of them.

Task

In scoreboard_server.py, write two and rewrite a third, in this order. Submit names the first one that is not done yet.

  1. add_viewer(board, connection): inside one with board["lock"]: block, store the connection with board["viewers"][connection.id] = connection and send it scoreboard_messages.encode_message(snapshot_message(board)).

  2. remove_viewer(board, connection): inside with board["lock"]:, call board["viewers"].pop(connection.id, None).

  3. viewer_handler(connection, board): in a try block, call add_viewer(board, connection) and then watch_until_closed(connection); add except ConnectionClosed: with pass; and call remove_viewer(board, connection) in a finally block.

Keep import threading and use board["lock"]; make no lock of your own.

Press Run and read the Lesson 3 section, then press Submit. The check watches the board lock instead of running threads. It runs a viewer’s join once to count the moments where another thread could act (each time the lock is taken or released, and each send), then once more per moment with a score from the scorekeeper’s thread applied exactly there. Each time, the viewer’s first message must be a snapshot, its versions must only go up, each snapshot’s points must match its version, the newest version before the viewer left must have reached it, and the registry must be empty at the end. The join send must happen while the lock is held, and no recv while it is held. It then ends a viewer in six ways: a clean close, a connection with no close , a join send that fails, a RuntimeError from recv, a recv that would wait forever, and a viewer that a broadcast had already removed. Each must leave the registry empty and the lock free, and a closed connection must not escape the handler. A viewer that leaves must not remove another. acquire() and release() in a try and finally of your own are as good as with.