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 loopback_demo.py.
serve_clients(listener, results, *, max_clients=2, max_messages=MAX_MESSAGES, deadline=5.0, now=time.monotonic) is the server’s
Give the listener a timeout with
listener.settimeout(WAIT).Compute
stop_at = now() + deadline, once, before the loop.Accept while fewer than
max_clientsresults exist andnow() < stop_at. IfacceptraisesTimeoutError, no client came in time: return the results so far.Give each accepted
its own settimeout(WAIT), close it withwith, and appendserve_connection(conn, max_messages=max_messages)toresults.Return
results.
The thread cannot hand a main, so it appends to the main passed in, the same idea as the outcome 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 parser.error(...), which prints the usage line and the message to standard error and exits with status 2.
main(argv=None) puts it together:
Parse the options.
Open the listener with
socket.create_server(("127.0.0.1", 0))in awithblock, read thewith getsockname()[1], and printlistening on 127.0.0.1:PORTwith the real port.Start
serve_clientson athreading.Thread, passing the listener and a results list asargs, andmax_clients,max_messagesanddeadlineaskwargs. The server’s limit isMAX_MESSAGES, not--messages: the bound belongs to the server, not to what thesays clients will send. --messagesdecides how many texts each client sends.For each client, in the main thread, connect with
socket.create_connection(("127.0.0.1", port), timeout=WAIT)in awithblock, runclient_connectionwith texts such as"client 1, message 2", and printclient N: E echoes, closed with C. If the client raisesOSErroror, print that it failed and go on to the next one. Join the server thread with
timeout=WAIT, inside the listener’swithblock, so the listener is not closed under a thread that may still be accepting.Print
server: client N sent M messages, closed with Cfor each result.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
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
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 loopback_demo.py:
serve_clients(listener, results, *, max_clients=2, max_messages=MAX_MESSAGES, deadline=5.0, now=time.monotonic): calllistener.settimeout(WAIT), computestop_at = now() + deadlineonce, and accept whilelen(results) < max_clientsandnow() < stop_at. Give each acceptedsettimeout(WAIT), close it withwith, and appendserve_connection(conn, max_messages=max_messages)toresults. WhenacceptraisesTimeoutError, return the results so far. Call the clock only throughnow.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) withargparse; refuse aoutside its bounds with parser.error(...).main(argv=None): parse the options; bindsocket.create_server(("127.0.0.1", 0))in awithblock and printlistening on 127.0.0.1:PORT; runserve_clientson athreading.Threadwithmax_clients=args.clients,max_messages=MAX_MESSAGESanddeadline=args.deadline; from the main thread, connect each client withsocket.create_connection(..., timeout=WAIT)and runclient_connectionwith--messagestexts, printing one line per client; join the thread withtimeout=WAITbefore 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.