One Slow Connection Froze Every Other One
Recover the missing reply at the uppercase print shop
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
- 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.
- 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.
- 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.
- 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').
- 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.
- 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.
- 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.
- 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
- Run mkdir -p /root/halfclose and then save to /root/halfclose/server.py. There is no installation or external communication.
- Run the combined check with python3 /opt/fixtures/halfclose/check.py 8 /root/halfclose/server.py. If you put the current step number in place of 8, it checks cumulatively up to the earlier steps.
- BlockingIOError means it cannot make progress yet, and b'' means receive termination. Do not use recv(0) as a termination check.
- Completion is the local output queue being drained and is not a guarantee of the other side's work processing. Grading also checks the actual received bytes separately.
- Files disappear when the session ends. Keep the code you need separately before it ends.
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.