Chapter 5 · practice
A Conversation on Loopback
Exchange Messages After the Handshake
Your 127.0.0.1, exchanging messages until one of them says the conversation is over, with every wait bounded.
The project tree has changed. loopback_demo.py is new, and it is the file you edit all chapter. ws_handshake.py and ws_frames.py are the course’s finished versions of the Chapter 2 handshake and the Chapter 4 codec, so this chapter starts from known files whatever your own code looked like; nothing in this chapter edits them. The Chapter 4 demo, fakes.py and the frame tests have done their job and are gone.
What is already written
Open loopback_demo.py and read the supplied
make_read_exact(reader)returns aread_exact(n)over a real. It is the receive buffer from Networks with Python behind the seam decode_framehas taken since Chapter 3: it starts with whatever bytes the handshake left inreader["buffer"], callsrecvuntil it holdsnbytes, and raisesConnectionErrorif the peer closes first.serve_connection(conn)runs the server’s handshake and thenserve_conversation.client_connection(sock, host, path, texts)runs the client’s handshake and thenclient_conversation.run_once(talk)is the same wiring you wrote in Chapter 2’s project: a listener on127.0.0.10 with a 2-second timeout, the server on a thread, the client in the main thread with its own 2-second timeout, and a join with a timeout. It prints what each side returned, or the that stopped it, instead of raising.
main runs run_once twice, once with a well-behaved client and once with a rude one, and only when you run the file, because of the if __name__ == "__main__": line at the bottom. Lesson 5 replaces this single-conversation wiring with one of your own that serves several clients.
The server’s half
ws_frames.read_message(read_exact, role="server") reads one frame and returns it as an event, a small {"type": "text", "data": "hello"} or {"type": "ping", "data": b"p?"}. With role="server" it insists that every frame is masked, as every client frame must be.
The server’s rule is one event in, at most one frame out, and server_reply(event) holds it:
text comes back as text and binary as binary, with the same data, unmasked, because a server never
; a
gets a pong carrying the same payload, unmasked; a pong gets nothing: return
b"".
serve_conversation(sock, read_exact, *, max_messages=MAX_MESSAGES) server_reply(event) with sock.sendall, and count it if it was text or binary. Sending b"" sends nothing, so the pong case needs no special branch. Once max_messages messages have been echoed, stop and return {"messages": n, "code": None}. The code stays None for now, because nothing in this lesson ends a conversation with a code; Lesson 2 fills it in.
The limit counts messages, not frames. A ping is the peer asking “are you still there?”, and answering it is part of keeping the connection healthy, not part of the conversation’s content.
max_messages is 3. A client sends a ping and three texts. How many echoes does the server send?
The client’s half
Every frame a client sends is masked with a fresh 4-byte key. client_frame(opcode, payload, *, random_bytes=os.urandom) builds one: ask random_bytes(4) for a key and return ws_frames.encode_frame(opcode, payload, mask_key=key). In a real program random_bytes is os.urandom; a test can pass a function that returns known bytes instead, the same keyword-only seam client_handshake uses for its key.
The key must be new for every frame. Chapter 4 showed why masking exists: a script in a browser must not be able to choose the exact bytes that cross the network, because a proxy or cache on the path could mistake bytes that look like
Why does client_frame ask for a new key for every frame?
client_conversation(sock, read_exact, texts, *, random_bytes=os.urandom) talks to the server:
Send a masked ping with a payload of your choice, such as
b"are you there?", and read the reply withws_frames.read_message(read_exact, role="client").If the reply is not a pong carrying that same payload, raise
ValueError(1002, "the server did not answer the ping"), the same two-form the codec uses for a broken rule. For each text, send it with
client_frame(ws_frames.OP_TEXT, text, random_bytes=random_bytes), read the echo, and keep its"data".Return
{"echoes": [...], "code": None}.
Pass random_bytes on to every client_frame call, so the one function the caller gave you supplies every key.
Run, and an ending that is not one
Press Run. Here is the output after this lesson:
A conversation:
client: {'echoes': ['hello', 'café ✓', 'third message'], 'code': None}
server: stopped by ConnectionError: the peer closed the connection
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
The client got all three echoes, the check mark in café ✓ included. The server’s “stopped by” line is the expected result of this lesson, not a mistake. The client finished its texts and left its with block, which closed its socket. The server, still in its loop, called recv, got b"", and read_exact raised ConnectionError. From the server’s side, a client that finished and a client that crashed look exactly the same.
The rude client sends one unmasked text frame. The codec refuses it with ValueError(1002, ...), the exception ends the server’s thread, and the client sees its connection close without a word. Lesson 3 makes the server answer that kind of client properly.
Your echo server allows up to 10 messages. A client sends it three texts, café ✓ among them, and gets all three echoes back. Then the server prints server: stopped by ConnectionError: the peer closed the connection. What happened?
When the first conversation prints three echoes, press Submit. It checks your four functions with stand-in sockets, never a real one, and then lets your client and your server talk to each other through them.
The conversation works, but it has no ending: one side hangs up and the other is left to guess why. Lesson 2 gives WebSocket conversations their proper ending, the closing handshake.
Task
Write four loopback_demo.py, in this order. Submit names the first one that still raises NotImplementedError, so you meet one at a time.
server_reply(event): for a text or binary event, returnws_frames.encode_framewith the sameand data and no ; for a , a pong with the same payload and no mask; for a pong, b"".client_frame(opcode, payload, *, random_bytes=os.urandom): returnws_frames.encode_frame(opcode, payload, mask_key=random_bytes(4)), one new key per call.serve_conversation(sock, read_exact, *, max_messages=MAX_MESSAGES): read events withws_frames.read_message(read_exact, role="server"), sendserver_reply(event)withsock.sendall, count text and binary messages only, and oncemax_messageshave been echoed, return{"messages": n, "code": None}without reading again.client_conversation(sock, read_exact, texts, *, random_bytes=os.urandom): send a ping with a payload of your choice, read the reply withrole="client", and raiseValueError(1002, "the server did not answer the ping")unless it is a pong with the same payload; then send each text and read its echo before the next; return{"echoes": [...], "code": None}. Build everywith client_frame(..., random_bytes=random_bytes).
Press Run: the first conversation should print three echoes, and the server’s “stopped by ConnectionError” line is expected after this lesson.
Then press Submit. The check never opens a server_reply and client_frame directly. It gives serve_conversation a stand-in socket whose client sends a masked ping, a text and a binary message, or a text, a pong, an empty ping and a binary message, delivered whole, one byte at a time and two bytes at a time, and then goes quiet without closing, so a server that reads past its limit would wait forever and fails. It gives client_conversation a stand-in server that answers whatever your client sends, checks that every frame is masked with its own key from random_bytes, and once answers your ping with the wrong payload. Finally your client talks to a correct stand-in server, and the bytes it sent are fed to your serve_connection, whose replies must match that server’s byte for byte. Importing loopback_demo.py must print nothing.