0%

A Conversation on Loopback · capstone

Capstone project: Project: The Hand-Built Echo Service

Until now main has run one conversation at a time through run_once, the wiring the chapter supplied. In this project you write the wiring yourself and make loopback_demo.py a command: python loopback_demo.py --clients 2 --messages 3 starts your server on 127.0.0.1, serves the clients one after another within a message limit and a deadline, has each one close with 1000, prints a line per client and per server result, and exits 0. It is the last piece of the course built from nothing but the standard library.

What to build

Three in loopback_demo.py.

serve_clients(listener, results, *, max_clients=2, max_messages=MAX_MESSAGES, deadline=5.0, now=time.monotonic) is the server’s , and it runs on a thread:

  1. Give the listener a timeout with listener.settimeout(WAIT).

  2. Compute stop_at = now() + deadline, once, before the loop.

  3. Accept while fewer than max_clients results exist and now() < stop_at. If accept raises TimeoutError, no client came in time: return the results so far.

  4. Give each accepted its own settimeout(WAIT), close it with with, and append serve_connection(conn, max_messages=max_messages) to results.

  5. Return results.

The thread cannot hand a back to main, so it appends to the main passed in, the same idea as the outcome in run_once.

parse_args(argv) reads three options with argparse: --clients, an int from 1 to 5, default 2; --messages, an int from 1 to 10, default 3; --deadline, a float above 0 and at most 8, default 5.0. A outside its bounds goes to parser.error(...), which prints the usage line and the message to standard error and exits with status 2.

main(argv=None) puts it together:

  1. Parse the options.

  2. Open the listener with socket.create_server(("127.0.0.1", 0)) in a with block, read the with getsockname()[1], and print listening on 127.0.0.1:PORT with the real port.

  3. Start serve_clients on a threading.Thread, passing the listener and a results list as args, and max_clients, max_messages and deadline as kwargs. The server’s limit is MAX_MESSAGES, not --messages: the bound belongs to the server, not to what the says clients will send. --messages decides how many texts each client sends.

  4. For each client, in the main thread, connect with socket.create_connection(("127.0.0.1", port), timeout=WAIT) in a with block, run client_connection with texts such as "client 1, message 2", and print client N: E echoes, closed with C. If the client raises OSError or , print that it failed and go on to the next one.

  5. Join the server thread with timeout=WAIT, inside the listener’s with block, so the listener is not closed under a thread that may still be accepting.

  6. Print server: client N sent M messages, closed with C for each result.

  7. Return 0 if every client closed with 1000 and the thread stopped, 1 otherwise. The last line of the file, raise SystemExit(main()), turns that into the command’s exit status.

Time as a parameter

serve_clients never calls time.monotonic() itself. It calls now(), whose default is time.monotonic, the injected clock from Python in Practice. A real run gets the real clock. A check can pass a function that returns 0.0 and then 6.0, and see whether the deadline stops the loop without waiting six seconds.

The deadline is checked between clients, not during a conversation. A conversation in progress already has its own bounds: a 2-second timeout on every read and a limit on messages. The deadline only decides whether another client is accepted.

The deadline passes while the second client is still talking. What does serve_clients do?

Run it

This lesson has a . With your three functions written, run the command with its defaults:

python loopback_demo.py

It prints this, with a port number where PORT is:

listening on 127.0.0.1:PORT
client 1: 3 echoes, closed with 1000
client 2: 3 echoes, closed with 1000
server: client 1 sent 3 messages, closed with 1000
server: client 2 sent 3 messages, closed with 1000

Try the largest run the options allow, five clients with ten messages each; it prints a client line and a server line for each of the five, all closed with 1000:

python loopback_demo.py --clients 5 --messages 10

Then a value out of bounds:

python loopback_demo.py --clients 6
echo $?
usage: loopback_demo.py [-h] [--clients CLIENTS] [--messages MESSAGES]
                        [--deadline DEADLINE]
loopback_demo.py: error: --clients must be 1 to 5

That goes to standard error, and echo $? prints the exit status, 2. Your tests from Lesson 4 still pass:

python -m pytest -q

When the default run ends with both clients closed with 1000, press Check my work. It runs the checks of Lessons 1 to 3 again on your finished file. It gives serve_clients a stand-in listener with clients waiting and a clock it controls, and checks the client limit, the deadline, the accept timeout, both timeouts, that every accepted socket is closed, and that a client who vanishes does not stop the next one. It calls parse_args with values inside and outside the bounds. main opens a real listener, a thread and clients, so the check reads its code instead: the address ("127.0.0.1", 0), a Thread, and a timeout on every join and on create_connection. Your printed lines are not checked, so read them yourself.

What the code is made of

Count what this service took. In the course’s versions of the three files, ws_handshake.py, ws_frames.py and loopback_demo.py come to about 620 lines. The part that makes it an echo service, sending each message back, is a few lines of server_reply. Nearly everything else is plumbing that every program needs and none of them wants to write: the , the length forms, with a fresh key, answering , the closing handshake in both directions, failure codes, and the threads and timeouts that keep it all bounded.

That is the point of having built it once. Chapter 6 hands all of that plumbing to the websockets library and rebuilds this service in a few lines, starting by pointing the library’s own client at the course’s version of the server you just finished.

Task

Write three in loopback_demo.py:

  1. serve_clients(listener, results, *, max_clients=2, max_messages=MAX_MESSAGES, deadline=5.0, now=time.monotonic): call listener.settimeout(WAIT), compute stop_at = now() + deadline once, and accept while len(results) < max_clients and now() < stop_at. Give each accepted settimeout(WAIT), close it with with, and append serve_connection(conn, max_messages=max_messages) to results. When accept raises TimeoutError, return the results so far. Call the clock only through now.

  2. parse_args(argv): --clients (int, 1 to 5, default 2), --messages (int, 1 to 10, default 3) and --deadline (float, above 0 and at most 8, default 5.0) with argparse; refuse a outside its bounds with parser.error(...).

  3. main(argv=None): parse the options; bind socket.create_server(("127.0.0.1", 0)) in a with block and print listening on 127.0.0.1:PORT; run serve_clients on a threading.Thread with max_clients=args.clients, max_messages=MAX_MESSAGES and deadline=args.deadline; from the main thread, connect each client with socket.create_connection(..., timeout=WAIT) and run client_connection with --messages texts, printing one line per client; join the thread with timeout=WAIT before the listener closes; print one line per server result; return 0 only if every client closed with 1000 and the thread stopped.

In the , python loopback_demo.py should print two client lines and two server lines, all closed with 1000, and python loopback_demo.py --clients 6 should exit with status 2.

Then press Check my work. It repeats the checks of Lessons 1 to 3 on your finished file. It gives serve_clients a stand-in listener with scripted clients and a clock it controls: three clients waiting with max_clients=2, a clock that passes the deadline after the first client, a listener whose accept times out, a client that vanishes before the next one, and max_messages=1; it checks the timeouts on the listener and on every accepted socket, and that every accepted socket is closed. It calls parse_args with the defaults, the largest allowed values, and values just outside each bound. It reads the code of main and the functions it uses for the address ("127.0.0.1", 0), a Thread, and a timeout on every join and on create_connection, and fails on "0.0.0.0" anywhere in the file. It does not run main, read your terminal, or check your printed lines.