From 8c87b43a2acb36950cbda845b3376cb385cda3bb Mon Sep 17 00:00:00 2001 From: Vladimir Dubrovin <3proxy@3proxy.ru> Date: Tue, 13 Oct 2026 12:21:23 +0300 Subject: [PATCH] Deliver the whole answer to a half-closed client When the client shuts down its sending side, sockmap() shuts down the server's in turn, and once the server has answered and closed, poll() reports a hang-up on its socket. A socket sockmap() had nothing to ask of at that moment, its buffer being full for a slow client, was still polled, and a hang-up there ended both directions: whatever of the answer was still unread in the kernel was dropped and the client got an end of stream in the middle of it, megabytes short with large answers. A hang-up on such a socket now only marks it: what is left is read once the buffer has room, up to the end of the stream as usual. Errors still end the connection. Since poll() reports a hang-up whatever is asked, the socket is left out of poll() while nothing is asked of it. The same holds for the client's side, for an upload cut short after the server half-closes. Co-Authored-By: Claude Opus 5.5 --- src/sockmap.c | 17 ++++++++++-- tests/cases/portmap.py | 59 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/sockmap.c b/src/sockmap.c index 99e017d..4c60428 100644 --- a/src/sockmap.c +++ b/src/sockmap.c @@ -75,6 +75,8 @@ int sockmap(struct clientparam * param, int timeo, int usesplice){ #endif int HASERROR=0; int CLIENTTERMREAD = 0, CLIENTTERMWRITE = 0, SERVERTERMREAD = 0, SERVERTERMWRITE = 0; + /* the side has hung up: closed by the peer after its own was shut down */ + int CLIENTHUP = 0, SERVERHUP = 0; int after = 0; struct pollfd fds[8]; int fdsc = 0; @@ -629,6 +631,9 @@ log("done read from server to buf"); #endif )) cli_events |= POLLOUT; + /* poll() reports a hang-up whatever is asked, and would + return at once every time: wait for the other side then */ + if(!cli_events && CLIENTHUP) fds[fdsc].fd = INVALID_SOCKET; if(cli_events){ fds[fdsc].fd = param->clisock; fds[fdsc].events = cli_events; @@ -664,9 +669,13 @@ log("ready to write to client"); if(fds[fdsc].events & POLLOUT) CLIENTTERMWRITE = 1; } } - } else if(fds[fdsc].revents & (POLLERR|POLLNVAL|POLLHUP)) { + } else if(fds[fdsc].revents & (POLLERR|POLLNVAL)) { CLIENTTERMREAD = 1; CLIENTTERMWRITE = 1; + } else if(fds[fdsc].revents & POLLHUP) { + /* what the client sent before it closed is still to be + read, once the buffer has room for it */ + CLIENTHUP = 1; } fdsc++; @@ -682,6 +691,7 @@ log("ready to write to client"); #endif )) srv_events |= POLLOUT; + if(!srv_events && SERVERHUP) fds[fdsc].fd = INVALID_SOCKET; if(srv_events){ fds[fdsc].fd = param->remsock; fds[fdsc].events = srv_events; @@ -724,9 +734,12 @@ log("server terminated connection"); if(fds[fdsc].events & POLLOUT) SERVERTERMWRITE = 1; } } - } else if(fds[fdsc].revents & (POLLERR|POLLNVAL|POLLHUP)) { + } else if(fds[fdsc].revents & (POLLERR|POLLNVAL)) { SERVERTERMREAD = 1; SERVERTERMWRITE = 1; + } else if(fds[fdsc].revents & POLLHUP) { + /* the rest of the answer may still be waiting to be read */ + SERVERHUP = 1; } fdsc++; // } diff --git a/tests/cases/portmap.py b/tests/cases/portmap.py index 72be8f7..d456192 100644 --- a/tests/cases/portmap.py +++ b/tests/cases/portmap.py @@ -1,5 +1,29 @@ """The port mappers: tcppm forwards a TCP port, udppm a UDP one.""" +import socket +import threading +import time + +HALFCLOSE_SIZE = 20000000 + + +def halfclose_origin(listener): + """Answers each connection which asks for something with HALFCLOSE_SIZE + bytes, then waits for the client's end of the stream and closes. The + connections waiting for the service to start ask nothing.""" + while True: + try: + c, _ = listener.accept() + except OSError: + return + try: + if c.recv(1000): + c.sendall(b"x" * HALFCLOSE_SIZE) + c.recv(10) + except OSError: + pass + c.close() + def run(t): # --- tcppm --------------------------------------------------------- @@ -40,6 +64,41 @@ def run(t): t.stop_all() + # A client which shuts down its sending side still gets the whole answer, + # even when it reads slowly: the server has finished and closed by then, + # and the rest of the answer waits in the proxy's buffer and the kernel's. + listener = socket.socket() + listener.bind(("127.0.0.1", 0)) + listener.listen(1) + threading.Thread(target=halfclose_origin, args=(listener,), daemon=True).start() + halfclosed = t.free_port() + t.start("portmap_halfclose", f""" + log + auth none + tcppm {halfclosed} 127.0.0.1 {listener.getsockname()[1]} + """, ports=[halfclosed]) + s = socket.socket() + s.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, 32768) + s.settimeout(30) + got = 0 + try: + s.connect(("127.0.0.1", halfclosed)) + s.sendall(b"request") + s.shutdown(socket.SHUT_WR) + time.sleep(1) + while True: + d = s.recv(65536) + if not d: + break + got += len(d) + except OSError: + pass + s.close() + listener.close() + t.eq(HALFCLOSE_SIZE, got, "a half-closed client reading slowly gets the whole answer") + + t.stop_all() + # --- udppm --------------------------------------------------------- # something has to be listening for the mapped datagrams to go anywhere echo = t.udp_echo()