A Conversation on Loopback · practice
Close Both Ways
After Lesson 1, every conversation ends with one side hanging up. The server cannot tell a client that finished from a client whose program crashed, and neither side can say why it stopped.
The closing handshake
RFC 6455 describes it in sections 1.4, 5.5.1 and 7.1.1:
One side, either one, sends a close frame:
8, a 2-byte code such as 1000 (“normal closure”), and an optional reason in . The other side answers with a close frame of its own. After sending a close, an endpoint sends no more data.
Once both closes have been exchanged, in the normal case the server closes the
first, and the client waits for it.
The picture shows both orders, the client first at the top and the server first below it. In each half, look at which side closes the TCP connection, and at what the client’s last recv returns.

In words, the top half: the client sends a masked close with code 1000 and the reason “done”; the server answers with an unmasked close carrying the same code and reason, then closes the TCP connection; the client’s next recv returns b"", and only then is the conversation over.
The RFC says the answering close “typically” echoes the code it received. This course echoes the code and the reason, which is exactly what the websockets library you meet in Chapter 6 does.
Why the server closes TCP first: RFC 6455 section 7.1.1 asks for it so that the server, not the client, holds the TIME_WAIT state, a short period during which the side that closed first keeps a record of the finished connection. That one sentence of TCP is all this lesson needs.
Both close frames have been exchanged. In the normal case, who closes the TCP connection first?
Two helpers
close_frame(event, *, mask_key=None) builds the answer to a close event. read_message reports a close as {"type": "close", "code": ..., "reason": ...}, so the answer is ws_frames.close_payload(event["code"], event["reason"]) in a close frame, masked when a mask_key is given, because a client’s answer must be masked like every client frame. A close 1000 “bye” is answered with these bytes:
88 05 03 e8 62 79 65
88 is a final close frame, 05 the payload length, 03 e8 the code 1000, and the last three bytes are “bye”.
wait_for_close(read_exact, role) reads events with ws_frames.read_message(read_exact, role=role) and drops each one until a close arrives, then returns its code. It is how a side that has already sent its close waits for the answer: anything else that arrives is data the peer sent before it saw the close, and it is read and thrown away.
Client first
This is the order in Run’s well-behaved conversation. After the last echo, client_conversation:
sends a masked close:
client_frame(ws_frames.OP_CLOSE, ws_frames.close_payload(1000, "done"), random_bytes=random_bytes);calls
wait_for_close(read_exact, "client")for the server’s answer and keeps its code;calls
sock.recv(1)once more, which returnsb""when the server has closed TCP;returns the echoes and that code.
Step 3 uses sock.recv directly, not read_exact, because here b"" is the expected ending, not an error.
On the server’s side, serve_conversation checks each event before replying: a close is answered with sock.sendall(close_frame(event)), and the {"messages": n, "code": event["code"]}. It reads nothing more. When it returns, the with block around the connection in the wiring closes the
Server first
The server starts the closing handshake when it reaches its limit, as in the lower half of the picture. After the echo that makes max_messages, it sends close 1000 with the reason “message limit reached”, calls wait_for_close(read_exact, "server"), and returns {"messages": n, "code": 1000}.
A client may have sent another text before the server’s close reached it. wait_for_close reads that text and drops it, because a server that has sent its close sends nothing more.
The client must be ready for this too. While it waits for an echo, a close may arrive instead. Then it answers with close_frame(event, mask_key=random_bytes(4)), calls sock.recv(1) for the end of the connection, and returns the echoes it has, with the close’s code.
The server has sent its close at the limit, and a text frame from the client arrives before the client's close. What does the server do?
The code nobody sends
A close frame may have an empty payload, 88 00 from a server. parse_close_payload reports it as code 1005, which RFC 6455 reserves as a label for “no close_payload(1005) raises close_frame checks for 1005 first and answers an empty close with an empty close.
A client's close frame has an empty payload. What does the server's close carry?
Run
Press Run. The output after this lesson:
A conversation:
client: {'echoes': ['hello', 'café ✓', 'third message'], 'code': 1000}
server: {'messages': 3, 'code': 1000}
server thread still running: False
A rude client:
client: the server closed the connection without a close frame
server: stopped by ValueError: (1002, 'the frame breaks the masking rule')
server thread still running: False
Both sides now report 1000: the conversation ended because both agreed it was over. The rude client is unchanged; that is Lesson 3’s.
Then press Submit. It runs close_frame and wait_for_close on their own, gives serve_conversation stand-in clients that close in each of the ways above, runs client_conversation against a stand-in server that answers its close and one that closes first, and has your client and your server close a conversation together.
A well-behaved peer now gets a clean ending. A peer that breaks the rules still kills the server’s thread with an exception, and the next lesson makes the server answer it with a close of its own.
Task
Write two loopback_demo.py. Submit names the first new function that still raises NotImplementedError.
close_frame(event, *, mask_key=None): the close that answers a close event, with the same code and reason, built withws_frames.close_payloadandws_frames.encode_frame(ws_frames.OP_CLOSE, ..., mask_key=mask_key). For code 1005, answer with an empty payload.wait_for_close(read_exact, role): read events withws_frames.read_message(read_exact, role=role), drop everything that is not a close, and return the close’s code.serve_conversation: answer a close event withclose_frame(event)and return{"messages": n, "code": event["code"]}at once. Atmax_messages, send close 1000 (reason “message limit reached”), callwait_for_close(read_exact, "server"), and return{"messages": n, "code": 1000}. Read nothing after the client’s close.client_conversation: after the last echo, send a masked close 1000 withclient_frame, get the server’s code fromwait_for_close(read_exact, "client"), callsock.recv(1)once more for the end of the connection, and return the echoes with that code. If a close arrives instead of an echo, answer it withclose_frame(event, mask_key=random_bytes(4)), callsock.recv(1), and return the echoes so far with the close’s code.
Press Run: both sides of the first conversation should report 1000.
Then press Submit. The check never opens a close_frame with codes 1000, 1012 and 4000, an empty close, and a wait_for_close with serve_conversation stand-in clients that close after a max_messages=2, each delivered whole, one byte at a time and two bytes at a time, and then quiet without closing, so a server that reads past the client’s close waits forever and fails. It runs client_conversation against a stand-in server that answers your close, and one that closes first after one echo, and checks that your client reads until the server closes the connection. Finally your client and your server talk to each other through stand-ins, once with the client closing and once with the server closing first, and must agree byte for byte.