0%

The websockets Library, Without asyncio · practice

A Server in a Few Lines

interop.py pointed the library’s client at the Chapter 5 server. This lesson turns that around: the server becomes the library’s. In Chapter 5, serve_conversation read , answered , echoed, counted and ran the closing , after serve_handshake had answered the upgrade. With the library, the part that is about echoing fits in a handler of about six lines.

A handler and its connection

A handler is a you write and the library calls. It takes one , the connection to one client, and uses a few things on it:

  • connection.recv(timeout=...) waits at most that many seconds for the next message and returns it: a str for a text message, bytes for a binary one. When nothing arrives in time, it raises TimeoutError.

  • connection.send(message) sends a message: a str as text, bytes as binary.

  • connection.close(code, reason) sends a close frame with that code and reason, and runs the closing handshake.

  • connection.request.path is the path the client asked for, such as /echo. Later chapters use it; this one does not.

The library does everything else. For each client that connects, it runs the handshake and then calls your handler on a thread of its own: the thread-per-connection pattern from Networks with Python Chapter 9. When the handler returns, the library closes the connection with 1000 if the handler has not already closed it.

Three clients connect at once. How many times does the library call echo_handler?

echo_handler(connection) is Chapter 5’s echo with the plumbing removed. While fewer than MAX_MESSAGES messages have been echoed, read one with connection.recv(timeout=WAIT) and send it straight back with connection.send. Sending the unchanged keeps its type, so text goes back as text and binary as binary, the choice server_reply made with . After the loop, close with connection.close(1000, "message limit reached"). WAIT and MAX_MESSAGES are already at the top of echo_library.py: 2 seconds and 10 messages.

Serving

run_echo(talk, *, serve=serve) serves the handler for one conversation and hands the to talk, a function that plays the client. It starts like this:

with serve(echo_handler, "127.0.0.1", 0) as server:
    port = server.socket.getsockname()[1]

serve, from websockets.sync.server, binds and listens, like socket.create_server, and returns a server . Pass echo_handler itself, without calling it: the library calls it, once per connection. Port 0 lets the system pick a free port, and server.socket is the listening , so getsockname()[1] reads the port the way you did in Networks.

Nothing is served yet. server.serve_forever() accepts connections and starts a handler thread for each, and it returns only when the server is shut down. So it needs a thread of its own: create threading.Thread(target=server.serve_forever), passing the without calling it, and start it before you call talk(port).

When the with block ends, for whatever reason, the server object calls shutdown(). That closes the listening socket, closes any connection still open with 1001, and makes serve_forever return. After the block, join the thread with timeout=WAIT, and return whatever talk returned.

The serve=serve is a seam, like connect= in Networks with Python: run_echo uses the library’s serve unless a caller passes another, and Submit passes a stand-in that records what your code does with it.

What makes serve_forever return?

A library conversation uses more threads than yours did. In a local count, one conversation with the library’s server and client in one process ran seven threads, and five with the library’s pings switched off; Chapter 8 covers those pings. The number depends on when you count, which is why nothing in this course counts threads. All of them end when the conversation closes and the server shuts down.

What is gone

Put echo_handler next to Chapter 5’s server. The handshake is gone: no serve_handshake, no check_upgrade, no accept_value. Unmasking is gone, and so is the pong: the library answers every ping as it arrives, and recv only ever hands you messages. The closing handshake is gone too: when the client sends a close, the library answers it, the work your close_frame and wait_for_close did. What remains is the part about this program: how many messages, what to send back, when to close and with which code.

Which of these does your Chapter 5 server do that echo_handler no longer does?

Before you press Run

Run prints a block of about ten lines to standard error that looks like a crash. In this lesson it is expected, and here is why. The Chapter 5 client sends its three messages and then closes with 1000 and the reason done. Your handler is back in recv, waiting for a fourth message, and recv raises ConnectionClosedOK instead. The leaves echo_handler, and the library logs it. The block starts with the line connection handler failed, continues with a whose file paths differ from machine to machine, and ends with this line:

websockets.exceptions.ConnectionClosedOK: received 1000 (OK) done; then sent 1000 (OK) done

That last line says the connection still closed cleanly: the library received the client’s close and sent the same close back. Submit accepts a handler that lets this exception escape in this lesson, and Lesson 4 catches it, so the block goes away.

Now press Run. The Lesson 2 section runs the course’s Chapter 5 client, built by hand, against your library server. A finished version, run on the site, printed:

Lesson 2: your library server, the Chapter 5 client
{'echoes': ['hello', 'café ✓', 'third message'], 'code': 1000}

The hand-built client pinged and got its pong, sent three messages, got three echoes, and closed with 1000; the library answered every frame. The later sections still say “not written yet”. Then press Submit.

If the threads puzzle you, ask Monty:

Why does serve_forever need a thread of its own when the library already starts a thread for each handler?

The server speaks to a hand-built client. In the next lesson the client side moves to the library too.

Task

Write two in echo_library.py, in this order. Submit names the first one that still raises NotImplementedError.

  1. echo_handler(connection): while fewer than MAX_MESSAGES messages have been echoed, read one with connection.recv(timeout=WAIT) and send it back unchanged with connection.send. After the , call connection.close(1000, "message limit reached"). No try yet: Lesson 4 adds it.

  2. run_echo(talk, *, serve=serve): in with serve(echo_handler, "127.0.0.1", 0) as server:, read the with server.socket.getsockname()[1], start a threading.Thread(target=server.serve_forever), and call talk(port). After the with block, join the thread with timeout=WAIT and return what talk returned.

Press Run: the Lesson 2 section should print {'echoes': ['hello', 'café ✓', 'third message'], 'code': 1000}, and the connection handler failed block described in the lesson appears once on standard error.

Then press Submit. The check never opens a . It gives echo_handler stand-in connections with messages it has not shown you, text and binary: it expects each one sent back unchanged, in order, at most MAX_MESSAGES of them (it also tries a smaller MAX_MESSAGES), no read past the limit, a timeout of at most 2 seconds on every recv, and a close with 1000. A client that closes early may make your handler raise in this lesson. It gives run_echo a stand-in serve and watches the thread you start, without running it: serve called with echo_handler, "127.0.0.1" and 0, a thread whose target is serve_forever started before talk, talk called with the port, the server shut down after talk, also when talk raises, a join with a timeout after the shutdown, and talk’s returned. Keep import threading and write threading.Thread(...). Calling server.shutdown() yourself inside the block is fine, and so is a for loop over range(MAX_MESSAGES). Importing echo_library.py must print nothing.