From c39ff11bb9da6f78949db06818dcd84da087bf8a Mon Sep 17 00:00:00 2001 From: Wanderingss <1624155937@qq.com> Date: Fri, 25 Sep 2026 05:38:03 +0800 Subject: [PATCH] =?UTF-8?q?Trap=2023:=204=E4=B8=AA=E5=8F=AF=E5=A4=8D?= =?UTF-8?q?=E7=94=A8=E5=8F=B0=E6=9E=B6=E6=8E=A2=E9=92=88=E8=BF=81=E5=85=A5?= =?UTF-8?q?MDK-ARM/bench=5Fdiag/=E5=B9=B6=E6=8F=90=E4=BA=A4=EF=BC=8C10?= =?UTF-8?q?=E4=B8=AA=E4=B8=80=E6=AC=A1=E6=80=A7=E7=94=84=E5=88=AB=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=B7=B2=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- MDK-ARM/bench_diag/bench_diag_abort_gap.py | 323 ++++++++++++++++++ MDK-ARM/bench_diag/bench_diag_pcref.py | 56 +++ MDK-ARM/bench_diag/bench_diag_recover.py | 259 ++++++++++++++ MDK-ARM/bench_diag/bench_diag_sink_recycle.py | 187 ++++++++++ docs/问题记录/CH395F_Trap_Records.md | 2 +- 5 files changed, 826 insertions(+), 1 deletion(-) create mode 100644 MDK-ARM/bench_diag/bench_diag_abort_gap.py create mode 100644 MDK-ARM/bench_diag/bench_diag_pcref.py create mode 100644 MDK-ARM/bench_diag/bench_diag_recover.py create mode 100644 MDK-ARM/bench_diag/bench_diag_sink_recycle.py diff --git a/MDK-ARM/bench_diag/bench_diag_abort_gap.py b/MDK-ARM/bench_diag/bench_diag_abort_gap.py new file mode 100644 index 00000000..774f1889 --- /dev/null +++ b/MDK-ARM/bench_diag/bench_diag_abort_gap.py @@ -0,0 +1,323 @@ +#!/usr/bin/env python3 +# Reusable bench probe (Trap 23 boundary scan; lives in MDK-ARM/bench_diag/). +# Boundary probe for Trap 23 residual: abort an in-flight STOR, wait G ms, then run a +# fresh STOR(4096) and measure BOTH directions INDEPENDENTLY: +# ab_stored / fu_stored = device-side byte count, authoritative, parsed from the +# device log line "UPLOAD '' bytes OK" +# fu_retr = client-side received length + sha256 (fresh session) +# The abort->reopen gap G is swept so the temporal boundary can be read off directly. +# +# Round: +# 1. abort phase : login -> PASV -> STOR /aNNNN.bin (8192B burst) -> abort (RST or FIN) +# 2. gap : sleep G ms (also measured: abort -> next control/data connect) +# 3. follow-up : login -> PASV -> STOR /fNNNN.bin (4096B, graceful data close) -> 226 +# 4. verify : fresh session -> RETR /fNNNN.bin -> length + sha256 -> DELE +# 5. classify : UP_TRUNC (device stored != 4096) / DN_TRUNC (stored ok, client short) +# +# --control drops phase 1 entirely (no preceding abort) = baseline defect rate. +import argparse, hashlib, os, random, re, socket, struct, sys, time + +DEVLOG = os.path.join("tools", "serial_tool", "state", "serial.log") + + +def log(m): + sys.stdout.write(m + "\n") + sys.stdout.flush() + + +class Ctl: + """Minimal raw FTP control channel (no ftplib) so close semantics/timing are exact.""" + + def __init__(self, ip, port, timeout=10.0): + self.s = socket.create_connection((ip, port), timeout=timeout) + self.f = self.s.makefile("rb") + self.banner = self.resp() + + def resp(self): + line = self.f.readline() + if not line: + raise EOFError("control closed by peer") + txt = line.decode("latin1").rstrip("\r\n") + code = txt[:3] + if len(txt) > 3 and txt[3] == "-": + while True: + l2 = self.f.readline() + if not l2: + break + t2 = l2.decode("latin1").rstrip("\r\n") + txt += "\n" + t2 + if t2.startswith(code + " "): + break + return txt + + def cmd(self, line): + self.s.sendall((line + "\r\n").encode()) + return self.resp() + + def login(self): + self.cmd("USER anonymous") + self.cmd("PASS bench@local") + self.cmd("TYPE I") + + def pasv(self, timeout=10.0): + r = self.cmd("PASV") + m = re.search(r"\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)", r) + if not m: + raise RuntimeError("PASV failed: %r" % r[:80]) + 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=timeout) + + def close(self): + """Close BOTH the makefile handle and the socket: leaving the makefile open keeps + the fd alive, no FIN is sent, and the device holds the stale connection until its + chip TCP timeout (~2.5s of :21 refusals) -- an artefact, not a device defect.""" + try: + self.f.close() + except Exception: + pass + try: + self.s.close() + except OSError: + pass + + def abort(self, fin): + """Tear the control channel down with the same semantic as the trigger.""" + if fin: + try: + self.s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + else: + self.s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + self.close() + + def quit(self): + try: + self.cmd("QUIT") + except Exception: + pass + self.close() + + +def abort_sock(s, fin, plain=False): + if plain: + # pure FIN: plain close, no shutdown(SHUT_RD) -- so the peer's FIN arriving later is + # NOT answered with an RST (which is what shutdown(SHUT_RDWR) causes on Windows) + try: + s.close() + except OSError: + pass + return + if fin: + try: + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + else: + s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + try: + s.close() + except OSError: + pass + + +def connect_ctl(ip, port, trace=False, tries=4, spacing=0.15): + """Connect the control channel, tolerating a short post-abort refusal window. + + Returns (Ctl, seconds_since_first_attempt_or_None).""" + t0 = time.monotonic() + last = None + for k in range(tries): + try: + return Ctl(ip, port), (time.monotonic() - t0) + except Exception as e: + last = e + if trace: + log(" [trace] connect attempt %d failed: %s" % (k + 1, type(e).__name__)) + time.sleep(spacing) + raise last + + +def read_until_eof(s, deadline_s): + got = bytearray() + s.settimeout(deadline_s) + while True: + try: + b = s.recv(65536) + except socket.timeout: + break + except OSError: + break + if not b: + break + got.extend(b) + return bytes(got) + + +def devlog_offset(path): + try: + return os.path.getsize(path) + except OSError: + return 0 + + +def devlog_new(path, off): + try: + with open(path, "rb") as f: + f.seek(off) + return f.read().decode("utf-8", "replace") + except OSError: + return "" + + +def parse_uploads(text): + """name -> stored size, from device log 'UPLOAD '' bytes OK'.""" + out = {} + for m in re.finditer(r"UPLOAD '([^']*)'\s+(\d+) bytes OK", text): + out[os.path.basename(m.group(1))] = int(m.group(2)) + return out + + +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("--n", type=int, default=8) + ap.add_argument("--gap", type=int, default=0, help="abort -> reopen gap in ms") + ap.add_argument("--size", type=int, default=4096, help="follow-up STOR payload bytes") + ap.add_argument("--abort-send", type=int, default=8192, help="aborted STOR payload bytes") + ap.add_argument("--fin", action="store_true", help="graceful FIN abort instead of RST") + ap.add_argument("--ctl-abort", action="store_true", + help="also RST the control channel (aggressive, kills :21 for ~19s)") + ap.add_argument("--fast-teardown", action="store_true", + help="close the control channel ~30ms after the abort instead of 500ms") + ap.add_argument("--followups", type=int, default=1, + help="consecutive STORs after one abort (is the damage one-shot?)") + ap.add_argument("--plain-close", action="store_true", + help="close the data socket with a plain close (pure FIN, no SHUT_RD)") + ap.add_argument("--control", action="store_true", help="no abort phase (baseline)") + ap.add_argument("--trace", action="store_true", help="per-phase progress + traceback") + ap.add_argument("--devlog", default=DEVLOG) + a = ap.parse_args() + + off = devlog_offset(a.devlog) + payload = bytes(random.getrandbits(8) for _ in range(a.size)) + want = hashlib.sha256(payload).hexdigest() + + res = {"OK": 0, "UP_TRUNC": 0, "DN_TRUNC": 0, "ERR": 0} + log("== gap sweep: gap=%dms abort=%s control=%s n=%d size=%d" + % (a.gap, "FIN" if a.fin else "RST", a.control, a.n, a.size)) + for i in range(1, a.n + 1): + ab_name = "/a%04d.bin" % i + fu_name = "/f%04d.bin" % i + t_abort = None + dt_ctl = dt_data = -1.0 + try: + # ---- 1. abort phase ---- + if not a.control: + c = Ctl(a.ip, a.port) + if a.trace: + log(" [trace] abort-phase control connected: %r" % c.banner[:40]) + c.login() + d = c.pasv() + c.cmd("STOR " + ab_name) + d.sendall(bytes(random.getrandbits(8) for _ in range(a.abort_send))) + abort_sock(d, a.fin, a.plain_close) + t_abort = time.monotonic() + if a.ctl_abort: + # aggressive variant: RST the control channel too (kills the :21 + # listener for ~19s until the chip's own TCP timeout fires -- see + # device log 'sock1 TIMEOUT' / 'sock1 relisten quarantine') + c.abort(True) + else: + # faithful to ftp_abort_probe/real client: data channel aborted, + # control channel closed gracefully. --fast-teardown shrinks the + # settle sleep 500ms -> 30ms so the follow-up can land inside the + # post-abort window (this is the boundary variable under test). + time.sleep(0.03 if a.fast_teardown else 0.5) + try: + c.resp() + except Exception: + pass + c.quit() + # ---- 2. gap ---- + if a.gap > 0: + time.sleep(a.gap / 1000.0) + # ---- 3./4. follow-up STOR(s) + verify ---- + fu_names = [("/f%04d_%d.bin" % (i, k)) if a.followups > 1 else ("/f%04d.bin" % i) + for k in range(a.followups)] + for k, fu_name in enumerate(fu_names): + c2, t_accept = connect_ctl(a.ip, a.port, a.trace) + if a.trace: + log(" [trace] follow-up control connected: %r" % c2.banner[:40]) + dt_ctl = (time.monotonic() - t_abort) if t_abort else -1.0 + c2.login() + d2 = c2.pasv() + dt_data = (time.monotonic() - t_abort) if t_abort else -1.0 + c2.cmd("STOR " + fu_name) + d2.sendall(payload) + try: + d2.shutdown(socket.SHUT_WR) + except OSError: + pass + r = c2.resp() + d2.close() + c2.quit() + # verify: fresh session, RETR + c3, _ = connect_ctl(a.ip, a.port, a.trace) + if a.trace: + log(" [trace] verify control connected: %r" % c3.banner[:40]) + c3.login() + d3 = c3.pasv() + c3.cmd("RETR " + fu_name) + got = read_until_eof(d3, 15.0) + d3.close() + try: + c3.cmd("DELE " + fu_name) + if not a.control: + c3.cmd("DELE " + ab_name) + except Exception: + pass + c3.quit() + retr = len(got) + sha_ok = hashlib.sha256(got).hexdigest() == want + # stored sizes are resolved after the run from the device log; make a + # provisional classification from the client side only + cls = "OK" if (retr == a.size and sha_ok) else "DN_TRUNC" + log(" %2d.%d resp=%-4s retr=%5d/%d sha=%-4s t_accept=%6.0fms dt_data=%6.0fms provisional=%s" + % (i, k + 1, r[:3], retr, a.size, "OK" if sha_ok else "BAD", + (t_accept or 0.0) * 1e3, dt_data * 1e3, cls)) + res[cls] += 1 + except Exception as e: + res["ERR"] += 1 + log(" %2d EXC %s: %s" % (i, type(e).__name__, str(e)[:70])) + if a.trace: + import traceback + traceback.print_exc() + time.sleep(0.3) + + # ---- device-log truth: authoritative stored sizes ---- + up = parse_uploads(devlog_new(a.devlog, off)) + log("-- device log: %d UPLOAD records" % len(up)) + up_trunc = 0 + for i in range(1, a.n + 1): + for k in range(a.followups): + nm = ("f%04d_%d.bin" % (i, k)) if a.followups > 1 else ("f%04d.bin" % i) + sz = up.get(nm) + if sz is None: + log(" %s : (no UPLOAD record)" % nm) + elif sz != a.size: + up_trunc += 1 + log(" %s : stored=%d <== UPLOAD-SIDE TRUNCATION" % (nm, sz)) + ab = [(k, v) for k, v in up.items() if k.startswith("a")] + if ab: + log("-- aborted-phase stored sizes (expected partial): %s" + % ", ".join("%s=%d" % kv for kv in sorted(ab))) + log("SUMMARY gap=%dms abort=%s control=%s n=%d client_ok=%d client_dn_trunc=%d " + "device_up_trunc=%d err=%d" + % (a.gap, "FIN" if a.fin else "RST", a.control, a.n, res["OK"], + res["DN_TRUNC"], up_trunc, res["ERR"])) + + +main() diff --git a/MDK-ARM/bench_diag/bench_diag_pcref.py b/MDK-ARM/bench_diag/bench_diag_pcref.py new file mode 100644 index 00000000..82433bbb --- /dev/null +++ b/MDK-ARM/bench_diag/bench_diag_pcref.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +# PC-side control experiment (no device involved; lives in MDK-ARM/bench_diag/). +# Question: is "sendall(4096) then close()" against a SLOW receiver (small SO_RCVBUF, +# no prompt read) lossless on a normal TCP stack? If yes here but lossy against the +# device, the loss is device-side; if lossy here too, it is generic TCP/client behaviour. +import socket, sys, threading, time + +def log(m): + sys.stdout.write(m + "\n"); sys.stdout.flush() + +def run(rcvbuf, read_delay, send_size=4096): + srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + srv.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, rcvbuf) + srv.bind(("127.0.0.1", 0)) + srv.listen(1) + port = srv.getsockname()[1] + got = bytearray() + res = {} + + def server(): + c, _ = srv.accept() + if read_delay: + time.sleep(read_delay) # receiver does not read promptly + c.settimeout(3.0) + try: + while True: + b = c.recv(65536) + if not b: + break + got.extend(b) + except Exception as e: + res["err"] = repr(e) + res["n"] = len(got) + c.close() + + t = threading.Thread(target=server, daemon=True) + t.start() + time.sleep(0.15) + cli = socket.create_connection(("127.0.0.1", port), timeout=5.0) + cli.sendall(bytes([(i * 13 + 5) & 0xFF for i in range(send_size)])) + cli.close() # close immediately after sendall + t.join(timeout=8) + srv.close() + ok = res.get("n") == send_size + log(" PC-control rcvbuf=%-6d read_delay=%-4s sent=%d received=%s %s %s" + % (rcvbuf, read_delay, send_size, res.get("n"), + "OK" if ok else "LOSS", res.get("err", ""))) + return res.get("n") + +log("=== PC-side reference (loopback): does an immediate close() lose data when the") +log("=== receiver reads late / has a tiny buffer? Expected on a correct stack: no loss.") +run(2048, 0.0) +run(2048, 1.5) +run(65536, 0.0) +run(65536, 1.5) diff --git a/MDK-ARM/bench_diag/bench_diag_recover.py b/MDK-ARM/bench_diag/bench_diag_recover.py new file mode 100644 index 00000000..808aeefe --- /dev/null +++ b/MDK-ARM/bench_diag/bench_diag_recover.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# Reusable bench probe (post-abort recovery timeline; lives in MDK-ARM/bench_diag/). +# RECOVERY TIMELINE after an aborted transfer: how long is the device impaired, and is +# the impairment specific to the FTP application (lftpd) or common to the net/chip layer? +# +# Trigger (t=0): +# --target ftp : raw control login -> PASV -> data conn -> STOR -> 8192B burst -> RST the +# DATA channel; control channel teardown is either +# clean = 0.5s settle, read the reply, QUIT (what real clients do) +# fast = QUIT ~20ms after the abort (events coincide) +# --target sink: :7003 connect -> INIT -> LCDMEM -> N keepalive frames (RX in flight, +# unread) -> RST (NO lftpd code involved at all) +# +# Then every --step ms a full short session is attempted until it succeeds --first-ok +# marks the moment the device is usable again-- and every attempt's payload integrity is +# recorded (FTP: NOOP reply; sink: 3209B LCDMEM frame length + CRC, and the echo count of a +# keepalive burst = RX direction integrity). +import argparse, re, socket, struct, sys, time + +TAG_CLIENT = 0xAA +CMD_KEEP = 0 +CMD_INIT = 1 +CMD_LCDMEM = 3 +MEMSIZE = 3200 +LCDMEM_TOTAL = 3209 +ACK_LEN = 5 + + +def log(m): + sys.stdout.write(m + "\n") + sys.stdout.flush() + + +def xor_bytes(b): + c = 0 + for x in b: + c ^= x + return c + + +def req(cmd, data=b""): + body = bytes(data) + return (bytes([TAG_CLIENT, cmd, (len(body) >> 8) & 0xFF, len(body) & 0xFF]) + body + + bytes([xor_bytes(body)])) + + +def read_exact(s, want, deadline): + buf = bytearray() + while len(buf) < want: + left = deadline - time.monotonic() + if left <= 0: + break + s.settimeout(left) + try: + d = s.recv(want - len(buf)) + except (socket.timeout, OSError): + break + if not d: + break + buf.extend(d) + return bytes(buf) + + +def read_frame(s, deadline): + """Read exactly one RemoDispBus frame -> (got, total, crc_ok).""" + buf = bytearray() + total = None + while True: + want = (4 - len(buf)) if total is None else (total - len(buf)) + if want <= 0: + break + left = deadline - time.monotonic() + if left <= 0: + break + s.settimeout(left) + try: + d = s.recv(min(want, 4096)) + except (socket.timeout, OSError): + break + if not d: + break + buf.extend(d) + if total is None and len(buf) >= 4: + total = 4 + (((buf[2] << 8) | buf[3])) + 1 + if total is None or len(buf) < total: + return len(buf), (total or 0), False + body = bytes(buf[4:total - 1]) + return len(buf), total, (buf[total - 1] == xor_bytes(body)) + + +def rst(s): + try: + s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + except OSError: + pass + try: + s.close() + except OSError: + pass + + +# ------------------------------- FTP target ------------------------------- + +def ftp_recv(s, timeout=2.0): + s.settimeout(timeout) + try: + return s.recv(4096).decode("latin1", "replace") + except (socket.timeout, OSError): + return "" + + +def ftp_trigger(ip, port, teardown, name, send=8192): + ctl = socket.create_connection((ip, port), timeout=5.0) + ftp_recv(ctl) + ctl.sendall(b"USER anonymous\r\n"); ftp_recv(ctl) + ctl.sendall(b"TYPE I\r\n"); ftp_recv(ctl) + ctl.sendall(b"PASV\r\n") + r = ftp_recv(ctl) + m = re.search(r"\((\d+),(\d+),(\d+),(\d+),(\d+),(\d+)\)", r) + if not m: + raise RuntimeError("PASV failed: %r" % r[:60]) + dhost = ".".join(m.group(i) for i in (1, 2, 3, 4)) + dport = (int(m.group(5)) << 8) | int(m.group(6)) + d = socket.create_connection((dhost, dport), timeout=5.0) + ctl.sendall(("STOR " + name + "\r\n").encode()) + ftp_recv(ctl, 1.0) + d.sendall(bytes((i * 7 + 3) & 0xFF for i in range(send))) + t = time.monotonic() + rst(d) # abort mid-upload + if teardown == "fast": + time.sleep(0.02) + ftp_recv(ctl, 0.2) + else: + time.sleep(0.5) + ftp_recv(ctl, 1.0) + try: + ctl.sendall(b"QUIT\r\n") + ftp_recv(ctl, 0.5) + except OSError: + pass + try: + ctl.close() + except OSError: + pass + return t + + +def ftp_probe(ip, port, budget=0.4): + """One short real session: connect -> NOOP reply integrity -> QUIT.""" + t = time.monotonic() + try: + s = socket.create_connection((ip, port), timeout=budget) + except Exception as e: + return False, "REFUSED" if isinstance(e, ConnectionRefusedError) else type(e).__name__ + banner = ftp_recv(s, budget) + if not banner.startswith("220"): + rst(s) + return False, "nobanner" + s.sendall(b"NOOP\r\n") + r = ftp_recv(s, budget) + ok = r.startswith("2") + try: + s.sendall(b"QUIT\r\n"); ftp_recv(s, 0.3) + except OSError: + pass + try: + s.close() + except OSError: + pass + return True, ("ok" if ok else "noop=%r" % r[:16]) + + +# ------------------------------- sink target ------------------------------- + +def sink_trigger(ip, port, burst): + s = socket.create_connection((ip, port), timeout=5.0) + s.sendall(req(CMD_INIT)) + read_frame(s, time.monotonic() + 5.0) + if burst: + s.sendall(req(CMD_KEEP) * burst) + s.sendall(req(CMD_LCDMEM, bytes(4))) + t = time.monotonic() + rst(s) + return t + + +def sink_probe(ip, port, burst=0, budget=2.0): + """One real session: INIT + keepalive burst echo count + LCDMEM frame integrity.""" + try: + s = socket.create_connection((ip, port), timeout=budget) + except Exception as e: + return False, "REFUSED" if isinstance(e, ConnectionRefusedError) else type(e).__name__ + dl = time.monotonic() + budget + s.sendall(req(CMD_INIT)) + ini_got, _, ini_crc = read_frame(s, dl) + ack = -1 + if burst: + s.sendall(req(CMD_KEEP) * burst) + ack = len(read_exact(s, burst * ACK_LEN, time.monotonic() + budget)) + s.sendall(req(CMD_LCDMEM, bytes(4))) + got, total, crc = read_frame(s, time.monotonic() + budget) + try: + s.sendall(b"") # no-op: close gracefully (FIN) so we never re-trigger + s.shutdown(socket.SHUT_RDWR) + except OSError: + pass + try: + s.close() + except OSError: + pass + ok = (ini_got == 13 and got == LCDMEM_TOTAL and crc and (not burst or ack == burst * ACK_LEN)) + return True, ("ok" if ok else "init=%d lcd=%d/%d crc=%s ack=%s" + % (ini_got, got, LCDMEM_TOTAL, "OK" if crc else "BAD", + ack if burst else "-")) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--target", choices=("ftp", "sink"), default="ftp") + ap.add_argument("--ip", default="192.168.1.100") + ap.add_argument("--port", type=int, default=0, help="0 = 21 for ftp, 7003 for sink") + ap.add_argument("--rounds", type=int, default=3) + ap.add_argument("--span", type=int, default=4000, help="timeline length in ms") + ap.add_argument("--step", type=int, default=100) + ap.add_argument("--teardown", choices=("clean", "fast"), default="clean") + ap.add_argument("--burst", type=int, default=400, help="sink: keepalive frames in flight") + a = ap.parse_args() + port = a.port or (21 if a.target == "ftp" else 7003) + + log("== recovery timeline target=%s port=%d teardown=%s burst=%d step=%dms span=%dms" + % (a.target, port, a.teardown, a.burst, a.step, a.span)) + for r in range(1, a.rounds + 1): + if a.target == "ftp": + t0 = ftp_trigger(a.ip, port, a.teardown, "/r%04d.bin" % r) + else: + t0 = sink_trigger(a.ip, port, a.burst) + seq = [] + first_ok = None + t = 0 + while t < a.span: + time.sleep(a.step / 1000.0) + t = int((time.monotonic() - t0) * 1e3) + if a.target == "ftp": + up, det = ftp_probe(a.ip, port) + else: + up, det = sink_probe(a.ip, port, a.burst) + seq.append("%d:%s" % (t, det)) + if up: + if first_ok is None: + first_ok = t + if det == "ok" and first_ok is not None and t > first_ok + 400: + break + log(" round %d first_usable=%s ms" % (r, first_ok if first_ok is not None else ">%d" % a.span)) + log(" " + " | ".join(seq)) + time.sleep(6.0) # let the device fully settle before the next trigger + log("SUMMARY target=%s teardown=%s rounds=%d done" % (a.target, a.teardown, a.rounds)) + + +main() diff --git a/MDK-ARM/bench_diag/bench_diag_sink_recycle.py b/MDK-ARM/bench_diag/bench_diag_sink_recycle.py new file mode 100644 index 00000000..27fd22d1 --- /dev/null +++ b/MDK-ARM/bench_diag/bench_diag_sink_recycle.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +# Reusable bench probe (non-FTP :7003 close->reopen boundary; lives in MDK-ARM/bench_diag/). +# Non-FTP boundary probe: the same close->reopen recycle, but on the raw RemoDispBus +# service (:7003, Drivers/BSP/UI/ui_net_sink.c), which shares the net/chip layer with FTP +# and none of lftpd's code. If a rapid recycle damages data here too, the defect lives in +# the net/chip layer and FTP only makes it visible. +# +# Session A (the trigger): connect -> CMD_INIT -> CMD_LCDMEM (3209B, CRC) -> [--burst N +# keepalive frames queued unread = data still in flight at abort] -> abort (RST / --fin). +# Gap G ms. +# Session B (the victim): connect -> [--burst N keepalive frames -> count the echoed acks, +# this is the RX direction analogue of "upload truncated to 2048B"] -> CMD_INIT -> +# CMD_LCDMEM -> frame length + CRC (device->client analogue). +# +# Classification per round: +# RX_SHORT : session B echoed fewer acks than frames sent (client->device data cut short) +# TX_SHORT : session B LCDMEM frame short of 3209 (device->client data cut short) +# CORRUPT : full length but CRC mismatch / unexpected frame +# OK : everything intact +import argparse, socket, struct, sys, time + +TAG_CLIENT = 0xAA +TAG_DEVICE = 0xBB +CMD_KEEP = 0 +CMD_INIT = 1 +CMD_LCDMEM = 3 +MEMSIZE = 3200 +LCDMEM_TOTAL = 4 + 4 + MEMSIZE + 1 # 3209 +ACK_LEN = 5 # empty frame reply to a keepalive + + +def log(m): + sys.stdout.write(m + "\n") + sys.stdout.flush() + + +def xor_bytes(b): + c = 0 + for x in b: + c ^= x + return c + + +def req(cmd, data=b""): + body = bytes(data) + return (bytes([TAG_CLIENT, cmd, (len(body) >> 8) & 0xFF, len(body) & 0xFF]) + body + + bytes([xor_bytes(body)])) + + +def read_frame(s, deadline): + """Read exactly one device frame; returns total/got/crc_ok/short.""" + buf = bytearray() + total = None + while True: + want = (4 - len(buf)) if total is None else (total - len(buf)) + if want <= 0: + break + left = deadline - time.monotonic() + if left <= 0: + return {"total": (total or 0), "got": len(buf), "crc_ok": False, "short": True} + s.settimeout(left) + try: + d = s.recv(min(want, 4096)) + except socket.timeout: + return {"total": (total or 0), "got": len(buf), "crc_ok": False, "short": True} + except OSError: + d = b"" + if not d: + return {"total": (total or 0), "got": len(buf), "crc_ok": False, "short": True} + buf.extend(d) + if total is None and len(buf) >= 4: + total = 4 + (((buf[2] << 8) | buf[3])) + 1 + body = bytes(buf[4:total - 1]) + return {"total": total, "got": len(buf), "crc_ok": (buf[total - 1] == xor_bytes(body)), + "short": (len(buf) != total)} + + +def read_n(s, want, deadline): + """Read up to `want` bytes; stops at deadline / EOF. Returns bytes.""" + buf = bytearray() + while len(buf) < want: + left = deadline - time.monotonic() + if left <= 0: + break + s.settimeout(left) + try: + d = s.recv(min(want - len(buf), 4096)) + except socket.timeout: + break + except OSError: + break + if not d: + break + buf.extend(d) + return bytes(buf) + + +def abort_sock(s, fin): + try: + if fin: + s.shutdown(socket.SHUT_RDWR) + else: + s.setsockopt(socket.SOL_SOCKET, socket.SO_LINGER, struct.pack("ii", 1, 0)) + except OSError: + pass + try: + s.close() + except OSError: + pass + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--ip", default="192.168.1.100") + ap.add_argument("--port", type=int, default=7003) + ap.add_argument("--n", type=int, default=8) + ap.add_argument("--gap", type=int, default=0, help="abort -> reconnect gap in ms") + ap.add_argument("--fin", action="store_true") + ap.add_argument("--burst", type=int, default=400, + help="keepalive frames used to load the RX direction (0 = TCP-only test)") + ap.add_argument("--no-abort", action="store_true", help="no session A (baseline)") + a = ap.parse_args() + + res = {"OK": 0, "RX_SHORT": 0, "TX_SHORT": 0, "CORRUPT": 0, "ERR": 0} + log("== sink recycle: port=%d gap=%dms abort=%s burst=%d n=%d" + % (a.port, a.gap, "NONE" if a.no_abort else ("FIN" if a.fin else "RST"), + a.burst, a.n)) + for i in range(1, a.n + 1): + try: + # ---- session A: trigger ---- + if not a.no_abort: + s1 = socket.create_connection((a.ip, a.port), timeout=10.0) + s1.sendall(req(CMD_INIT)) + read_frame(s1, time.monotonic() + 10.0) + if a.burst: + s1.sendall(req(CMD_KEEP) * a.burst) + s1.sendall(req(CMD_LCDMEM, bytes(4))) + f1 = read_frame(s1, time.monotonic() + 10.0) + abort_sock(s1, a.fin) + t_ab = time.monotonic() + else: + f1 = {"got": -1, "crc_ok": True} + t_ab = time.monotonic() + # ---- gap ---- + if a.gap > 0: + time.sleep(a.gap / 1000.0) + # ---- session B: victim ---- + s2 = socket.create_connection((a.ip, a.port), timeout=10.0) + rx_bytes = 0 + if a.burst: + s2.sendall(req(CMD_KEEP) * a.burst) + got = read_n(s2, a.burst * ACK_LEN, time.monotonic() + 10.0) + rx_bytes = len(got) + s2.sendall(req(CMD_INIT)) + ini = read_frame(s2, time.monotonic() + 10.0) + s2.sendall(req(CMD_LCDMEM, bytes(4))) + f2 = read_frame(s2, time.monotonic() + 10.0) + dt = (time.monotonic() - t_ab) * 1e3 + abort_sock(s2, a.fin) + rx_want = a.burst * ACK_LEN + rx_short = a.burst and rx_bytes < rx_want + tx_short = (f2["got"] != LCDMEM_TOTAL) or (ini["got"] != 13) + if rx_short: + cls = "RX_SHORT" + elif tx_short: + cls = "TX_SHORT" + elif not (f2["crc_ok"] and ini["crc_ok"]): + cls = "CORRUPT" + else: + cls = "OK" + res[cls] += 1 + log(" %2d A lcd=%4d crc=%-4s | B ack=%5d/%5d init=%2dB lcd=%4d/%d crc=%-4s " + "dt=%6.0fms -> %s" + % (i, f1["got"], "OK" if f1["crc_ok"] else "BAD", rx_bytes, rx_want, + ini["got"], f2["got"], LCDMEM_TOTAL, + "OK" if f2["crc_ok"] else "BAD", dt, cls)) + except Exception as e: + res["ERR"] += 1 + log(" %2d EXC %s: %s" % (i, type(e).__name__, str(e)[:70])) + time.sleep(0.3) + log("SUMMARY port=%d gap=%dms abort=%s burst=%d n=%d ok=%d rx_short=%d tx_short=%d " + "corrupt=%d err=%d" + % (a.port, a.gap, "NONE" if a.no_abort else ("FIN" if a.fin else "RST"), a.burst, + a.n, res["OK"], res["RX_SHORT"], res["TX_SHORT"], res["CORRUPT"], res["ERR"])) + + +main() diff --git a/docs/问题记录/CH395F_Trap_Records.md b/docs/问题记录/CH395F_Trap_Records.md index e5fd9ed8..80260348 100644 --- a/docs/问题记录/CH395F_Trap_Records.md +++ b/docs/问题记录/CH395F_Trap_Records.md @@ -700,7 +700,7 @@ NET 层 TCP Client(阶段 7)`net_connect()` 返回 0,但 `net_poll()` 报 **结论**:缺陷位于 **fd 复用生命周期**(芯片上一条连接的残留状态投递给下一条连接),不在 FTP 命令逻辑——lftpd 只是把它放大成误报 226。RST/TIMEOUT 半边由一阶段隔离处理,DISCONNECT 半边由本阶段沉降处理,合计覆盖全部"收过数据后关闭"的释放路径。已知残余边界:中止那一笔 STOR 自身仍回报 226(STOR 无长度声明、在途 FIN 与正常完成不可区分)——后续硬化见待办 #3。 -**对应文件**:`Drivers/BSP/NET/net_socket.c`(`handle_disconnect_event` 沉降登记 + `net_relisten_locked` 两级沉降);`MDK-ARM/ftp_abort_probe.py` / `MDK-ARM/bench_diag_abort_gap.py`(边界甄别与回归判据)。 +**对应文件**:`Drivers/BSP/NET/net_socket.c`(`handle_disconnect_event` 沉降登记 + `net_relisten_locked` 两级沉降);`MDK-ARM/ftp_abort_probe.py` / `MDK-ARM/bench_diag/bench_diag_abort_gap.py`(边界甄别与回归判据)。 ### 后续硬化(2026-09-25):异常结束回 5xx(待办 #3 闭环)