Slow Clients and Lost Connections · practice
Let Each Viewer Send Its Own
You have an outbox that keeps only the newest snapshot and wakes whoever waits on it, and no viewer uses one. broadcast still sends to every viewer while apply_score holds the board lock, so the scorekeeper still waits for the slowest viewer. This lesson moves every send onto the viewer’s own thread. The library already gives each connection a thread, the one that runs viewer_handler, and until now that thread only watched for the close.
The new shape
Four changes, and the registry changes meaning:
board["viewers"]maps each viewer’sconnection.idto its outbox, no longer to its connection;add_viewer(board, connection)makes an outbox, and inside one hold of the board lock offers it the current snapshot and registers it; then it returns the outbox;broadcast(board, snapshot)offers the snapshot to every outbox in the registry and sends nothing;viewer_handler: wait for the outbox, send what it holds, and look at the connection when nothing came.
Only a viewer’s own thread ever sends to that viewer. The picture shows two viewers, A and S, at the moment the scorekeeper makes version 3, while S is still busy with a slow send of version 1. In Lesson 1’s picture, the scorekeeper waited for S; compare. Follow each arrow down: which thread does each send, and what is waiting in S’s outbox?

In words: the scorekeeper’s thread only drops the new snapshot into each viewer’s one-slot outbox, which takes no time, and replies. Each viewer’s thread wakes, takes the newest snapshot from its own outbox and sends it. While S’s slow send runs, newer offers are coalesced in S’s slot: it holds one snapshot, the newest. In the picture, S’s thread is still sending version 1, and version 3 has replaced version 2 in S’s outbox, so S skips version 2 and no other thread waits for it.
add_viewer keeps Chapter 7’s rule about the join: read the snapshot, offer it and register the outbox inside one hold of the board lock. Read the snapshot before taking the lock, or offer it after releasing the lock, and a score can slip in between. Then an older join snapshot overwrites the newer one that broadcast just offered, and the viewer never hears about that score.
Order by construction
Versions reach a viewer in order without any extra care, because one thread sends to it, and each snapshot that thread sends is the newest it found. The next one it takes can only be newer. The join snapshot may itself be replaced before the thread sends it. That is fine: whatever the viewer receives first is still a whole board.
Updates 1 and 2 arrive before a new viewer's thread sends anything. What does the viewer receive first?
A thread that must look
Here is the loop that goes inside viewer_handler’s try, after outbox = add_viewer(board, connection):
while True:
snapshot = wait_and_take(outbox, CHECK_EVERY)
if snapshot is None:
notice_close(connection)
else:
connection.send(scoreboard_messages.encode_message(snapshot))
CHECK_EVERY is 0.5 s. When a snapshot comes, the event wakes the thread at once and it sends. When half a second passes with nothing, the thread calls notice_close(connection), which is connection.recv(timeout=0) inside try, with except TimeoutError: pass. A timeout of 0 checks without waiting. If the connection is open and quiet, recv raises TimeoutError at once and notice_close returns. If a message from the viewer is waiting, recv returns it and it is dropped, one per idle check. If the connection has closed, recv raises ConnectionClosed, which ends the loop through the except ConnectionClosed: you already have, and the finally block removes the viewer.
Why look at all? A thread that only waits on its event never learns that its connection closed. Worse, the library’s shutdown() closes every open connection and then waits, with no time limit, until every handler has returned. A local test with a handler that never touched its connection again found shutdown() still waiting after 3 s; it would never return. With the idle check, a closed viewer’s handler notices within half a second and returns. That half second is the only delay the loop adds, and only for noticing a close; an update wakes the thread at once.
Why does the viewer loop call notice_close when wait_and_take returns None?
Why the copy now matters
Since Chapter 7, snapshot_message has put board["scores"].copy() in each snapshot. Until now the copy made little difference, because v1 sent each snapshot before the board could change again. Now a snapshot can wait in an outbox while apply_score keeps changing the board. Without the copy, the waiting snapshot’s "scores" would be the board’s own
snapshot_message stops copying scores. What can a viewer be sent?
Your task
In scoreboard_server.py:
write
notice_close(connection):connection.recv(timeout=0)insidetry, andexcept TimeoutError: pass;rewrite
add_viewerto make an outbox, offer itsnapshot_message(board)and register it underconnection.idinside onewith board["lock"]:, and return it;rewrite
broadcastto offer the snapshot to every outbox inboard["viewers"];rewrite
viewer_handler: keep itstry,except ConnectionClosed: passandfinally: remove_viewer(board, connection), and inside thetryputoutbox = add_viewer(board, connection)and the loop above;delete
watch_until_closed, which nothing calls any more.
remove_viewer stays as it is.
Run
Press Run. The Lesson 3 section gives viewer_handler a stand-in viewer, and during the server’s first send to it starts a scorekeeper thread that scores three times, joined with a 0.5 s timeout. With v1 the scorekeeper finished 0 of 3 updates in that time, because the first send held the board lock, and the viewer was sent versions [0, 1, 2, 3]. A finished version, run on the site, printed:
Lesson 3: a busy viewer and a scorekeeper
while the server was sending the viewer its first snapshot, the scorekeeper finished 3 of 3 updates
the viewer was sent versions [0, 3]
The scorekeeper no longer waits for the viewer, and the viewer skipped versions 1 and 2, because version 3 replaced them while it was busy. The run now takes about half a second longer, the stand-in viewer’s idle checks. Then press Submit.
Lesson 4 turns to a viewer that is not slow but gone.
Task
In scoreboard_server.py:
Write
notice_close(connection): callconnection.recv(timeout=0)insidetry, withexcept TimeoutError: pass. A closed connection’sConnectionClosedmust get out.Rewrite
add_viewer(board, connection): make an outbox withmake_outbox(); inside onewith board["lock"]:, offer itsnapshot_message(board)and store it asboard["viewers"][connection.id]; return the outbox. It sends nothing.Rewrite
broadcast(board, snapshot): offersnapshotto every outbox inboard["viewers"]. It sends nothing, and it does not take the board lock, whichapply_scorealready holds.Rewrite
viewer_handler(connection, board): keep thetry,except ConnectionClosed: passandfinally: remove_viewer(board, connection). Inside thetry, calloutbox = add_viewer(board, connection), thenforever: snapshot = wait_and_take(outbox, CHECK_EVERY); when it isNone, callnotice_close(connection); otherwise sendscoreboard_messages.encode_message(snapshot).Delete
watch_until_closed. Leaveremove_vieweras it is.
The idle check is required: a viewer thread that only waits on its event never learns that its connection closed, and the server’s shutdown() waits for every handler to return.
Press Run: the Lesson 3 section prints the scorekeeper finished 3 of 3 updates and the viewer was sent versions [0, 3].
Then press Submit. The check replaces the threading viewer_handler on a stand-in viewer that joins, sees two quiet idle checks and closes, once for every moment at which another thread could run (each lock taken or released, each send, each idle check), with one score, and then three, arriving at that moment. Each time, the viewer’s first message must be a snapshot, its versions must only increase, each snapshot’s scores must match its version, the newest version at its last idle check must reach it, nothing may be sent while a lock is held, and the registry must be empty at the end. It also checks notice_close on its own, broadcast with three viewers registered, and four endings: a close by RuntimeError from recv, and a viewer from which nothing more ever arrives. Making the outbox inside or outside the board lock is fine, and so are acquire() and release() in place of with.