test(net/udp): 阶段 16 台架加固 + 真机首跑证据回填(#73)
CI / 静态门禁 (pre-commit) (push) Failing after 5m39s
CI / 主机侧单测 (MSVC) (push) Successful in 30s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 4m15s
CI / 主机侧单测 (MSVC) (pull_request) Successful in 47s

第一次上真机把台架的两处软肋照出来了,都不是被测代码的问题:

- peer 死在 Windows 的 ICMP 10054:板子还没 bind 时那一下端口不可达会被记在套接句上,
  下一次 recvfrom 才抛出来。本机 CPython 3.14.7 既没有 socket.SIO_UDP_CONNRESET 也不接受
  它的数值形式,所以主力防线改成把 10054 当「这一轮没收到」继续重试,并在回路自检里加
  冷端口探针——探针走脚本真正在用的 _recv_maybe,另拿裸套接句证明这台机器确实会回 ICMP,
  否则那条判定就没有牙。
- 单步预算错位:板端每步等 25 s,peer 每步只等 8 s,一步超时后整条梯子全灭。现在 peer
  每步都重发、单步预算 --step-budget 默认 35 s(小于 26 s 打警告);板端 udp_bench_expect
  按载荷第 0 字节的步号自愈,比本步小的旧包丢掉继续等(上限 UDP_BENCH_STALE_MAX = 8),
  比本步大的直接报出来。DBG_WARN 在本工程不存在、字符串字面量必须纯 ASCII,两处已改。

真机已拿到的证据:TC-NET-1601a/b/c(建 socket、bind :6001、net_udp_get_mode 回 SERVER)与
TC-NET-1602(服务端形态收到 20 B、addrlen/sin_family/源端口全对、逐字节回显、形态翻成
CLIENT)全绿——剥 8 字节信息头与源地址回填在真芯片上成立;②③④⑤ 因板子当晚两次失电
(19:19、21:0x,SWD No target connected + ping 不通)未测,指南 §12.6 与覆盖率口径第 4 轮
的状态条据此改写。产品态(宏回默认)重新构建 0 Error 0 Warning,Code 249,124 / RO 29,836 /
RW 2,052 / ZI 149,996 与本阶段之前逐字节一致,阶段 16 在产品里被预处理干净。
This commit is contained in:
2026-11-03 21:28:36 +08:00
parent b0a6394aa1
commit fc0d3e4b5c
5 changed files with 189 additions and 42 deletions
+55 -17
View File
@@ -66,10 +66,38 @@ def payload(tag: int, n: int) -> bytes:
return bytes(out)
def new_peer_socket() -> socket.socket:
"""建一条 UDP 源套接句;能关就关掉 Windows 的「ICMP 端口不可达回抛」。
真机第一次上台架(2026-11-03 20:54)就是栽在这里:板子还没把 UDP socket 打开时,
我们发过去的第一颗包换来一个 ICMP 端口不可达,Windows 把它记在套接句上,随后的
recvfrom 直接抛 `ConnectionResetError: [WinError 10054] 远程主机强迫关闭了一个
现有的连接`——对端脚本当场死在第一条用例,板子白等到 25 秒超时,两边看到的都是
「对端缺席」,分不清是谁的问题。
能关掉这个行为当然最好(SIO_UDP_CONNRESET=0),但实测本机 CPython 3.14.7 既没有
`socket.SIO_UDP_CONNRESET` 这个常量,也不接受把它的数值 0x9800000C 直接喂给 ioctl
(`ValueError: invalid ioctl command`)。所以**主力防线是 recv 侧把 WinError 10054
当成「这一轮没收到」继续重试**(见 _recv_after / _recv_maybe 的 except),这里的
ioctl 只是有常量时的锦上添花。判据由 udp_queue_bench_loopback.py 的冷端口探针守着。
"""
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
flag = getattr(socket, "SIO_UDP_CONNRESET", None)
if flag is not None:
try:
s.ioctl(flag, False)
except (OSError, ValueError):
pass
return s
class Peer:
def __init__(self, board: str, timeout: float) -> None:
def __init__(self, board: str, timeout: float, step_budget: float = 35.0) -> None:
self.board = board
self.timeout = timeout
# 单步总预算:必须盖过板端单步等待(UDP_BENCH_WAIT_MS = 25 s)再加余量,理由见
# exchange() 里的说明。--first-wait 只覆盖第一步(板子要启动+起监听)。
self.step_budget = step_budget
self.total = 0
self.failed = 0
self.ports = {}
@@ -112,26 +140,29 @@ class Peer:
# 先清掉上一步可能迟到的回显,避免把它当成本步的应答
self._drain(sock)
deadline = time.time() + (first_wait if first_wait > 0 else self.timeout)
# 每一步都重发,且单步预算必须盖过板端单步等待(UDP_BENCH_WAIT_MS = 25 s)再加
# 余量:板端只要超时一次就会晚一步,本端若只等 8 秒,后面每一步都会踩进板子的
# 上一步预算里,整条梯子一起错位(2026-11-03 第一次上台架就是这样全灭)。
# 重发是安全的:板端按载荷第 0 字节的步号丢掉旧包(步序自愈),本端每步开头也
# 会先 drain 掉迟到的回显。
step_budget = first_wait if first_wait > 0 else self.step_budget
deadline = time.time() + step_budget
got = None
addr = None
while got is None:
sock.sendto(data_out, dst)
# 首步:板子可能还在启动/还没 bind,每 4 秒重发一次直到首步总预算用尽。
# 重发不会污染下一步——下一步开头会先 drain 掉多出来的那一份回显。
attempt = 4.0 if first_wait > 0 else self.timeout
left = deadline - time.time()
if left <= 0:
return self.check(False, "TC 0x{0:02X} 端口 {1}: 板端无回显(预算内)"
.format(tag, port))
return self.check(False, "TC 0x{0:02X} 端口 {1}: 板端 {2:.0f}s 内无回显"
.format(tag, port, step_budget))
attempt = 4.0 if first_wait > 0 else 6.0
try:
got, addr = self._recv_after(sock, min(attempt, left))
self.note_port(sock, tag)
except socket.timeout:
if first_wait <= 0:
return self.check(False, "TC 0x{0:02X} 端口 {1}: 板端 {2:.0f}s 内无回显"
.format(tag, port, self.timeout))
self.log("TC 0x{0:02X}: 无回显,重发(首步剩余 {1:.0f}s)".format(tag, left))
except (socket.timeout, ConnectionResetError):
# ConnectionResetError = 对端端口当时没开换来的 ICMP 不可达(见
# new_peer_socket 的说明);这里和「这一轮没收到」同等对待,继续重试。
self.log("TC 0x{0:02X}: 无回显,重发(本步剩余 {1:.0f}s)".format(tag, left))
# 一个数据报进、一个数据报出——唯一例外是本步载荷超过芯片发送缓冲(1KB):
# 那是已登记的芯片限制(NET_SEND_CHUNK_MAX 本是 TCP 流的分块口径,UDP 上每块
# 各发一包,改造前后同一条路径)。>1KB 的步骤按「预期拆包」判,并把实测拆分打
@@ -188,7 +219,7 @@ class Peer:
try:
sock.settimeout(budget)
return sock.recvfrom(4096)
except socket.timeout:
except (socket.timeout, ConnectionResetError):
return None
finally:
sock.settimeout(self.timeout)
@@ -212,6 +243,8 @@ def main() -> int:
help="对应 TC-NET-1606 的静默时长(板端探针预算 1 秒)")
ap.add_argument("--late", type=float, default=12.0,
help="对应 TC-NET-1607 的迟发时长:板端在这一步无限等包")
ap.add_argument("--step-budget", type=float, default=35.0,
help="单步总预算(含重发),必须盖过板端 25 秒单步等待,默认 35 秒")
ap.add_argument("--port", type=int, default=0,
help="覆盖阶段 16 的首个监听端口(默认用板端 UDP_BENCH_PORT 6001)")
ap.add_argument("--port2", type=int, default=0,
@@ -225,15 +258,20 @@ def main() -> int:
if not (7.0 < args.late <= 22.0):
print("--late 必须落在 (7, 22] 秒内,否则板端 7000~25000ms 判定窗会判失败", file=sys.stderr)
return 2
if args.step_budget <= 26.0:
# 只提醒不拦死:自检脚本用假板子跑,预算给得很小;真机台架则会看到这条。
print("[warn] --step-budget={0:.0f}s 小于板端单步等待 25s + 余量:"
"板端一旦超时一次,本端后续每一步都会踩进板子的上一步预算里(错位全灭)"
.format(args.step_budget), file=sys.stderr)
peer = Peer(args.board, args.timeout)
peer = Peer(args.board, args.timeout, args.step_budget)
# 三条独立源套接句 = 三个不同临时源端口,1604「换源端口」就是这么来的。
# 端口号不能在未 connect 时用 getsockname() 取(Windows 会 WinError 10022,
# 端口要到第一次 sendto 才分配),也不能 connect()——connect 会让内核丢掉
# 非对端的包,那正是这一步要验的东西。改成首次收包后再查,照样打得出来。
socks = {"A": socket.socket(socket.AF_INET, socket.SOCK_DGRAM),
"B": socket.socket(socket.AF_INET, socket.SOCK_DGRAM),
"C": socket.socket(socket.AF_INET, socket.SOCK_DGRAM)}
socks = {"A": new_peer_socket(),
"B": new_peer_socket(),
"C": new_peer_socket()}
peer.log("对端 = {0},三条源套接句 A/B/C 的临时源端口在首次收包后打印".format(args.board))
# 板端每一步用新源端口的条件:1604(换源端口验形态恢复)及其后