From dfe10b1f30eb7e822917ef35e47a5fe1ab89512e Mon Sep 17 00:00:00 2001 From: "Kwabena W. Agyeman" Date: Thu, 1 Oct 2026 22:26:18 -0700 Subject: [PATCH 1/5] iperf3: Don't wait for a full block when receiving TCP data. The TCP receive path read one whole block (the client's `-l` length) per poll event with a blocking `readinto()`, which on MicroPython returns only once the buffer is full. The sender is not required to end the stream on a block boundary, and the reference iperf3 client usually doesn't when its writes are larger than the socket can take at once: at the end of the test it abandons the block it is in the middle of writing and sends TEST_END. The server then waits forever for the rest of that block, never reads TEST_END from the control socket, and both ends hang. That makes `iperf3 -c ` with the default 128k block size stall on most runs, while `-l 500` works because such a small write is never split. Fix this by making the data socket non-blocking when it's used to receive TCP data, and counting however many bytes each read returns. The same applies to the client in reverse mode. Tested on an OPENMV_N6 over Ethernet with iperf 3.17.1 as the client and its default arguments: 2 of 12 runs completed before this change, 12 of 12 after. Also tested with iperf 3.9, 3.15, 3.16 and 3.22 as the client, with `-l 500` and `-R`, and with the board as the client (forward and reverse) against `iperf3 -s`. Signed-off-by: Kwabena W. Agyeman --- python-ecosys/iperf3/iperf3.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/python-ecosys/iperf3/iperf3.py b/python-ecosys/iperf3/iperf3.py index 05d69f774..ccb7e545f 100644 --- a/python-ecosys/iperf3/iperf3.py +++ b/python-ecosys/iperf3/iperf3.py @@ -217,8 +217,10 @@ def _transfer(udp, reverse, addr, s_data, buf, udp_last_send, udp_packet_id, udp stats.add_bytes(n) else: if reverse: - recvninto(s_data, buf) - n = len(buf) + # The socket is non-blocking, so this reads only what is available. The + # sender is not required to end the stream on a block boundary, so waiting + # here for a full block could block forever and miss TEST_END. + n = recvinto(s_data, buf) or 0 else: n = s_data.send(buf) stats.add_bytes(n) @@ -260,6 +262,9 @@ def server_once(): s_data, addr = s_listen.accept() print("Accepted connection:", addr) recvn(s_data, COOKIE_SIZE) + if not reverse: + # Receive without blocking, see _transfer(). + s_data.setblocking(False) udp = False udp_packet_id = 0 udp_interval = None @@ -488,6 +493,9 @@ def client(host, udp=False, reverse=False, bandwidth=10 * 1024 * 1024): s_data = socket.socket(ai[0], socket.SOCK_STREAM) s_data.connect(ai[-1]) s_data.sendall(cookie) + if reverse: + # Receive without blocking, see _transfer(). + s_data.setblocking(False) buf = bytearray(urandom(param["len"])) elif cmd == EXCHANGE_RESULTS: # Close data socket now that server knows we are finished, to prevent it flooding us From 0dae3de1de71bfb3dc03104fe0149b92ae67ab9d Mon Sep 17 00:00:00 2001 From: "Kwabena W. Agyeman" Date: Fri, 2 Oct 2026 08:48:49 -0700 Subject: [PATCH 2/5] iperf3: Accept clients that do not send a pacing timer. The reference iperf3 client only sends "pacing_timer" in its parameters from version 3.2 onwards. With an older client the server raised `KeyError: pacing_timer` after the streams were created and the client hung. Use the same 1000ms default that this module's own client sends. Tested on an OPENMV_N6 over Ethernet with iperf 3.1.3 as the client. Signed-off-by: Kwabena W. Agyeman --- python-ecosys/iperf3/iperf3.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/python-ecosys/iperf3/iperf3.py b/python-ecosys/iperf3/iperf3.py index ccb7e545f..2be7b58f2 100644 --- a/python-ecosys/iperf3/iperf3.py +++ b/python-ecosys/iperf3/iperf3.py @@ -71,7 +71,8 @@ def fmt_size(val, div): class Stats: def __init__(self, param): - self.pacing_timer_us = param["pacing_timer"] * 1000 + # iperf3 clients before 3.2 do not send the pacing timer. + self.pacing_timer_us = param.get("pacing_timer", 1000) * 1000 self.udp = param.get("udp", False) self.reverse = param.get("reverse", False) self.running = False From 52f4ccbb115534a2c279b55e22387338801f1940 Mon Sep 17 00:00:00 2001 From: "Kwabena W. Agyeman" Date: Fri, 2 Oct 2026 09:08:16 -0700 Subject: [PATCH 3/5] iperf3: Support parallel TCP streams in the server. The server accepted a single data connection whatever the client asked for, so `iperf3 -c -P 2` never started: the client waited for its second stream to be accepted and the server waited for data on the first. Accept as many TCP data connections as the client's "parallel" parameter says, poll them all, and report the bytes transferred per stream in the results (the reference implementation numbers its streams 1, 3, 4, ...). The interval lines printed by the server remain the total of all streams. The client opens its streams back to back, so the listening socket now has a backlog of 4 rather than 1. That is also as many streams as there are TCP pcbs for with lwIP's default of 5, after the control connection. UDP and the client are unchanged, they still use one stream. Signed-off-by: Kwabena W. Agyeman --- python-ecosys/iperf3/iperf3.py | 91 ++++++++++++++++++++-------------- 1 file changed, 54 insertions(+), 37 deletions(-) diff --git a/python-ecosys/iperf3/iperf3.py b/python-ecosys/iperf3/iperf3.py index 2be7b58f2..636321814 100644 --- a/python-ecosys/iperf3/iperf3.py +++ b/python-ecosys/iperf3/iperf3.py @@ -236,7 +236,9 @@ def server_once(): s_listen = socket.socket(ai[0], socket.SOCK_STREAM) s_listen.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s_listen.bind(ai[-1]) - s_listen.listen(1) + # The client opens its parallel streams all at once, so leave room to queue them. + # lwIP has 5 TCP pcbs by default, enough for the control connection and 4 streams. + s_listen.listen(4) s_ctrl, addr = s_listen.accept() # Read client's cookie @@ -254,18 +256,22 @@ def server_once(): if DEBUG: print(param) reverse = param.get("reverse", False) + parallel = param.get("parallel", 1) # Ask to create streams s_ctrl.sendall(bytes([CREATE_STREAMS])) if param.get("tcp", False): - # Accept stream - s_data, addr = s_listen.accept() - print("Accepted connection:", addr) - recvn(s_data, COOKIE_SIZE) - if not reverse: - # Receive without blocking, see _transfer(). - s_data.setblocking(False) + # Accept streams, the client opens one connection per parallel stream + s_data = [] + for _ in range(parallel): + s, addr = s_listen.accept() + print("Accepted connection:", addr) + recvn(s, COOKIE_SIZE) + if not reverse: + # Receive without blocking, see _transfer(). + s.setblocking(False) + s_data.append(s) udp = False udp_packet_id = 0 udp_interval = None @@ -273,10 +279,11 @@ def server_once(): elif param.get("udp", False): # Close TCP connection and open UDP "connection" s_listen.close() - s_data = socket.socket(ai[0], socket.SOCK_DGRAM) - s_data.bind(ai[-1]) - data, addr = s_data.recvfrom(4) - s_data.sendto(struct.pack(" Date: Fri, 2 Oct 2026 09:35:16 -0700 Subject: [PATCH 4/5] iperf3: Close the data streams when the test ends. The reference iperf3 server closes its data streams as soon as the client sends TEST_END, before it asks for the client's results. This server kept them open until the end of the test. When the server is receiving, the data the client still had in flight stays queued in the network stack, because the server no longer reads it. With two streams that can be all of the stack's receive buffers (on OPENMV_N6 two streams can hold 48 segments, against a pool of 32), and then the client's results, or its acknowledgement of the server's results, can't be received. `iperf3 -c -l 5000 -P 2` then timed out at the end: on OPENMV_N6 in 10 of 10 runs over WiFi and 1 of 10 over Ethernet. When the server is sending, a client that reads whole blocks with a blocking read (iperf 3.1.3) is left waiting for the rest of a block and doesn't see the request for results. The server used to keep sending until its socket buffer was full, which is not enough once the client has stopped reading. With the streams closed, the client sees the end of the stream instead. Close the streams on TEST_END, as the reference implementation does. Signed-off-by: Kwabena W. Agyeman --- python-ecosys/iperf3/iperf3.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/python-ecosys/iperf3/iperf3.py b/python-ecosys/iperf3/iperf3.py index 636321814..14be439b5 100644 --- a/python-ecosys/iperf3/iperf3.py +++ b/python-ecosys/iperf3/iperf3.py @@ -335,20 +335,14 @@ def server_once(): stream_bytes[i] += stats.nb0 - nb stats.update() - # Need to continue writing so other side doesn't get blocked waiting for data - if reverse and not udp: - poll.unregister(s_ctrl) - while True: - pollables = poll.poll(0) - if not pollables: - break - for pollable in pollables: - for s in s_data: - if pollable_is_sock(pollable, s): - s.send(data_buf) - stats.stop() + # Close the data streams now, as the reference implementation does. A client that + # reads whole blocks would otherwise wait for the rest of one, and the unread data + # held for the streams can leave no room to receive the client's results. + for s in s_data: + s.close() + # Ask to exchange results s_ctrl.sendall(bytes([EXCHANGE_RESULTS])) @@ -393,8 +387,6 @@ def server_once(): assert cmd == IPERF_DONE # Close all sockets - for s in s_data: - s.close() s_ctrl.close() s_listen.close() From 00f72c4966ba43e494b82aafa6c34ed26186be09 Mon Sep 17 00:00:00 2001 From: "Kwabena W. Agyeman" Date: Fri, 2 Oct 2026 09:35:16 -0700 Subject: [PATCH 5/5] iperf3: Bump version to 0.2.0. Previous commits fixed bugs and implemented parallel stream support. Signed-off-by: Kwabena W. Agyeman --- python-ecosys/iperf3/manifest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/python-ecosys/iperf3/manifest.py b/python-ecosys/iperf3/manifest.py index d63c2d6d1..da1bf927b 100644 --- a/python-ecosys/iperf3/manifest.py +++ b/python-ecosys/iperf3/manifest.py @@ -1,3 +1,3 @@ -metadata(version="0.1.5", pypi="iperf3", pypi_publish="uiperf3") +metadata(version="0.2.0", pypi="iperf3", pypi_publish="uiperf3") module("iperf3.py")