diff --git a/MDK-ARM/ftp_retr_abort_probe.py b/MDK-ARM/ftp_retr_abort_probe.py new file mode 100644 index 00000000..42884c42 --- /dev/null +++ b/MDK-ARM/ftp_retr_abort_probe.py @@ -0,0 +1,144 @@ +# -*- coding: utf-8 -*- +# RETR-side (device-as-sender) abort probe — validates the 2026-09-24 send +# fast-fail fix (commit 13fe184): client starts a RETR, reads PART of the data, +# then aborts the data connection (RST or FIN) while the device is still +# sending. The device's in-flight net_send must now fail immediately with +# CONNRESET (device log: "short send ... (errno=-8)") instead of stalling up +# to NET_SEND_TIMEOUT_MS = 1s and reporting TIMEDOUT (errno=-9, old behavior). +# +# The STOR-side abort probe (ftp_abort_probe.py) cannot validate this path: +# during STOR the device is the receiver (no in-flight device send exists). +# +# Usage: ftp_retr_abort_probe.py [--ip ..] [--port 21] [--rounds 10] +# [--file-size 262144] [--read 16384] [--gap 2] [--graceful] +# Output: TS / ABORT read= ctrl= alive= followup= +# SUMMARY rounds= pass= fail= +# Exit 0 all follow-ups pass, 1 otherwise. +import argparse, hashlib, io, random, re, socket, struct, sys, time +from ftplib import FTP, all_errors + +def log(m): + sys.stdout.write(m + "\n"); sys.stdout.flush() + +def rd(sock): + try: + return sock.recv(4096).decode("latin1").strip() + except Exception: + return "" + +def raw_login(ip, port, timeout=10.0): + s = socket.create_connection((ip, port), timeout=timeout) + rd(s) # 220 banner + s.sendall(b"USER anonymous\r\n"); rd(s) + s.sendall(b"TYPE I\r\n"); rd(s) + return s + +def pasv_data_socket(ctl): + ctl.sendall(b"PASV\r\n") + r = rd(ctl) + m = re.search(r"\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)", r) + if not m: + raise RuntimeError("PASV failed: " + r) + host = ".".join(m.group(i) for i in (1, 2, 3, 4)) + port = (int(m.group(5)) << 8) | int(m.group(6)) + return socket.create_connection((host, port), timeout=10.0) + +def followup_ok(ip, port, size=4096, tries=3): + """normal upload + download + sha256 -> True/False. + Retries the initial connect a few times: the post-abort relisten window + (close->reopen chip settle, see bench 20260924 finding F1) may refuse a + SYN for up to ~5s; a refused follow-up connect is an F1-class failure, + not a send fast-fail failure — retrying decouples the two.""" + data = bytes(random.getrandbits(8) for _ in range(size)) + want = hashlib.sha256(data).hexdigest() + for t in range(tries): + try: + f = FTP(); f.connect(ip, port, timeout=15.0); f.login("anonymous", "x") + f.storbinary("STOR /rb_ok.bin", io.BytesIO(data)) + buf = bytearray(); f.retrbinary("RETR /rb_ok.bin", buf.extend) + try: + f.delete("/rb_ok.bin") + except all_errors: + pass + f.quit() + return hashlib.sha256(bytes(buf)).hexdigest() == want + except all_errors: + time.sleep(1.0) + return False + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--ip", default="192.168.1.100") + ap.add_argument("--port", type=int, default=21) + ap.add_argument("--rounds", type=int, default=10) + ap.add_argument("--file-size", type=int, default=262144, + help="size of the file uploaded before the RETR aborts") + ap.add_argument("--read", type=int, default=16384, + help="bytes the client reads before aborting") + ap.add_argument("--gap", type=float, default=2.0) + ap.add_argument("--graceful", action="store_true", + help="close the data connection with FIN instead of RST") + a = ap.parse_args() + + # Upload the RETR source file once (8.3 name, FatFs constraint). + file_data = bytes(random.getrandbits(8) for _ in range(a.file_size)) + f = FTP(); f.connect(a.ip, a.port, timeout=15.0); f.login("anonymous", "setup") + f.storbinary("STOR /raba.bin", io.BytesIO(file_data)) + f.quit() + + npass = nfail = 0 + try: + for n in range(1, a.rounds + 1): + log("TS %d" % int(time.time())) + ctl_reply = "NA" + alive = "no" + got = 0 + try: + ctl = raw_login(a.ip, a.port) + ds = pasv_data_socket(ctl) + ctl.sendall(b"RETR /raba.bin\r\n") + ctl_reply = rd(ctl) # 150 + # read PART of the transfer, then abort while device is sending + try: + got = len(ds.recv(a.read)) + except Exception: + pass + if a.graceful: + ds.shutdown(socket.SHUT_RDWR) # graceful FIN + else: + ds.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, + struct.pack("ii", 1, 0)) # RST + ds.close() + time.sleep(0.5) + rd(ctl) # 450/226/221 reply + alive = "yes" + try: + ctl.sendall(b"NOOP\r\n"); rd(ctl) + except Exception: + alive = "no" + except Exception as e: + ctl_reply = "EXC:%s" % type(e).__name__ + finally: + try: + ctl.close() + except Exception: + pass + ok = followup_ok(a.ip, a.port) + npass += 1 if ok else 0 + nfail += 0 if ok else 1 + log("ABORT %d read=%d ctrl=%s alive=%s followup=%s" + % (n, got, ctl_reply, alive, "OK" if ok else "FAIL")) + time.sleep(a.gap) + f = FTP(); f.connect(a.ip, a.port, timeout=15.0); f.login("anonymous", "cleanup") + try: + f.delete("/raba.bin") + except all_errors: + pass + f.quit() + except Exception as e: + log("FATAL: %s" % e) + + log("SUMMARY rounds=%d pass=%d fail=%d" % (a.rounds, npass, nfail)) + sys.exit(0 if nfail == 0 else 1) + +main()