TT Lab
Get started
Learn Learning paths Courses

One Slow Connection Froze Every Other One

Recover the missing reply at the uppercase print shop

Continue in TT Lab

Goal

Build a multiplexing server component that answers to the end a client that has closed only its send direction, and that limits memory and total lifetime.

Why it matters

If you close both directions at EOF, the response that has not yet been sent disappears. Conversely, if you wait forever, a slow client holds on to connection resources. You solve both problems together by separating input termination, remaining output, and the final reason. The lab is one request per connection that ends with EOF and an ASCII byte uppercase conversion, and it is not an HTTP or TLS implementation. You do not modify the existing line-based server and write in a separate file.

Steps

  1. Separate the termination reason from socket ownership — Create Peer(sock, max_bytes=4096, expires_at=None). max_bytes is an int from 1 to 65536, excluding bool. If expires_at is None, set it to time.monotonic()+30.0, and if you specify it directly, it must be a non-negative finite int/float, excluding bool. A violation is a ValueError. Set sock to non-blocking and keep sock, max_bytes, and float(expires_at). inbox and outbox are empty bytes, read_eof, prepared, and closed are False, and reason is an empty string. finish(reason) does the following only on the first call: closed=True, keeping the reason, and emptying both buffers, and it does not close the socket.
  2. Tell EOF from exceeding the limit — Implement Peer.read_once(). If closed or read_eof, it returns 0 without calling recv. Otherwise it calls recv(min(4096, max_bytes+1-len(inbox))) once. BlockingIOError keeps the state and returns 0, and any other OSError is finish('io_error') and 0. If it is b'', it is just read_eof=True and 0, and it does not close. Append the received bytes to inbox, and if the limit is exceeded, finish('request_limit'). If there was data, return the length received. It does not change expires_at.
  3. Build the reply for a client that has fallen silent — Create Peer.prepare_reply(). If closed, before EOF, or prepared=True, it does nothing. Otherwise, it marks prepared=True, moves inbox.upper() to outbox, and then empties inbox. Only when outbox is empty does it finish('complete'). Even if called several times, it does not regenerate the remaining reply. Use bytes.upper() without decoding to a string.
  4. Delete only as much as actually went out — Peer.write_once() returns 0 without send if closed or outbox is empty. Otherwise, it sends outbox[:4096] once, deletes from the front only the returned length, and returns that length. BlockingIOError keeps the buffer and returns 0, and any other OSError or a send return of 0 is finish('io_error') and 0. If outbox becomes empty after sending and read_eof is set, it does finish('complete').
  5. Turn off read interest after EOF — Peer.events() is 0 if closed. Otherwise it returns an int that ORs EVENT_READ when read_eof=False and EVENT_WRITE when there is an outbox. It does not change the socket or the buffers. Preparing the reply after EOF is done immediately by the loop, so the actual registration mask of 0 applies only to a connection that has terminated.
  6. Keep one byte from extending the lifetime forever — Peer.expire(now) does finish('deadline') only when it is not closed and now >= expires_at. now is a finite monotonic time provided by the caller. It applies in the same way even if a reply remains after EOF, and it does not change a reason that was already set by termination.
  7. Unregister, then reclaim the FD — The module function retire(selector, peer) does selector.unregister(peer.sock) and then peer.sock.close(). Even if a KeyError occurs because it is not registered yet, it closes, and for any other error it closes and then propagates it again. It does not change peer.reason. The input is a valid open socket, and the actual close is called only once by the loop, which is the owner.
  8. Complete a real TCP reply next to a silent client — Create serve(peers). The input is a list of new Peers that hold distinct open connection sockets, and the function takes ownership of the sockets. It handles all the connections together with DefaultSelector, and the select wait is the smaller of 0.05 seconds and the time to the nearest deadline (minimum 0). On every iteration and right before I/O, it checks expire(time.monotonic()). On READ it calls read_once and then prepare_reply, on WRITE it calls write_once, and it updates the interest mask. It removes a terminated connection with retire, and even if an exception occurs, it reclaims the remaining sockets and the selector. When everything has terminated, it returns a list of reasons in input order, and an empty input gives []. It does not do listening, accept, or thread creation in this function. Grading reruns the contracts of the earlier steps along with 4 real TCP connections.

Notes

Separate the termination reason from socket ownership

Create Peer(sock, max_bytes=4096, expires_at=None). max_bytes is an int from 1 to 65536, excluding bool. If expires_at is None, set it to time.monotonic()+30.0, and if you specify it directly, it must be a non-negative finite int/float, excluding bool. A violation is a ValueError. Set sock to non-blocking and keep sock, max_bytes, and float(expires_at). inbox and outbox are empty bytes, read_eof, prepared, and closed are False, and reason is an empty string. finish(reason) does the following only on the first call: closed=True, keeping the reason, and emptying both buffers, and it does not close the socket.

Ending the state and reclaiming the FD are different. Make sure the cleanup that comes later does not overwrite the failure reason that was left first.

Tell EOF from exceeding the limit

Implement Peer.read_once(). If closed or read_eof, it returns 0 without calling recv. Otherwise it calls recv(min(4096, max_bytes+1-len(inbox))) once. BlockingIOError keeps the state and returns 0, and any other OSError is finish('io_error') and 0. If it is b'', it is just read_eof=True and 0, and it does not close. Append the received bytes to inbox, and if the limit is exceeded, finish('request_limit'). If there was data, return the length received. It does not change expires_at.

Even when you have reached the allowed amount exactly, you have to check for the next one byte or EOF. recv(0) is not evidence of termination.

Build the reply for a client that has fallen silent

Create Peer.prepare_reply(). If closed, before EOF, or prepared=True, it does nothing. Otherwise, it marks prepared=True, moves inbox.upper() to outbox, and then empties inbox. Only when outbox is empty does it finish('complete'). Even if called several times, it does not regenerate the remaining reply. Use bytes.upper() without decoding to a string.

The output direction is still alive after EOF. prepared prevents rebuilding the reply from the beginning after part of it has been sent.

Delete only as much as actually went out

Peer.write_once() returns 0 without send if closed or outbox is empty. Otherwise, it sends outbox[:4096] once, deletes from the front only the returned length, and returns that length. BlockingIOError keeps the buffer and returns 0, and any other OSError or a send return of 0 is finish('io_error') and 0. If outbox becomes empty after sending and read_eof is set, it does finish('complete').

Do not prove partial sending with only the short responses of the normal path. On the call after EAGAIN, the same remaining bytes must go out.

Turn off read interest after EOF

Peer.events() is 0 if closed. Otherwise it returns an int that ORs EVENT_READ when read_eof=False and EVENT_WRITE when there is an outbox. It does not change the socket or the buffers. Preparing the reply after EOF is done immediately by the loop, so the actual registration mask of 0 applies only to a connection that has terminated.

Having something to send does not mean you can receive again. Compute the conditions for input and output separately.

Keep one byte from extending the lifetime forever

Peer.expire(now) does finish('deadline') only when it is not closed and now >= expires_at. now is a finite monotonic time provided by the caller. It applies in the same way even if a reply remains after EOF, and it does not change a reason that was already set by termination.

Do not mix idle with total lifetime. The deadline in this contract is fixed at creation, not by activity.

Unregister, then reclaim the FD

The module function retire(selector, peer) does selector.unregister(peer.sock) and then peer.sock.close(). Even if a KeyError occurs because it is not registered yet, it closes, and for any other error it closes and then propagates it again. It does not change peer.reason. The input is a valid open socket, and the actual close is called only once by the loop, which is the owner.

finally is not a feature for hiding exceptions but the place to release the resources you own. Tell KeyError apart from other exceptions.

Complete a real TCP reply next to a silent client

Create serve(peers). The input is a list of new Peers that hold distinct open connection sockets, and the function takes ownership of the sockets. It handles all the connections together with DefaultSelector, and the select wait is the smaller of 0.05 seconds and the time to the nearest deadline (minimum 0). On every iteration and right before I/O, it checks expire(time.monotonic()). On READ it calls read_once and then prepare_reply, on WRITE it calls write_once, and it updates the interest mask. It removes a terminated connection with retire, and even if an exception occurs, it reclaims the remaining sockets and the selector. When everything has terminated, it returns a list of reasons in input order, and an empty input gives []. It does not do listening, accept, or thread creation in this function. Grading reruns the contracts of the earlier steps along with 4 real TCP connections.

If you build an inner loop that waits for one client's response, you are back to a sequential server. At each readiness event, perform only finite I/O.