test(net/udp): 主机单测补 UDP-25 两条 socket 并存的隔离(#73)
CI / 静态门禁 (pre-commit) (push) Failing after 45s
CI / 主机侧单测 (MSVC) (push) Failing after 50s
CI / 静态门禁 (pre-commit) (pull_request) Failing after 45s
CI / 主机侧单测 (MSVC) (pull_request) Failing after 45s

前 24 组全都只用一条 UDP socket,漏掉的正是队列化真正要保的那半句承诺:
**等待是一条 socket 自己的事**——串行的是命令,不是等待者。

UDP-25 用 13 条断言钉住两条并存 socket 的四件事:fdB 来包不得结算也不得通知
fdA 的 parked 收包(连它的调用缓冲都不许被写一个字节);fdA 自己的包到达时按
自己那一包的表头结算并恰好通知一次;fdB parked 时 fdA 自己 recvfrom 照常拿到
数据;关停在等的 fdB 必须当场结算、槽位与出参槽都不残留。

写这组时踩到假件的一个真坑,值得记下来:假件的 get_recv_len 是"每次读消耗一格、
最后一格永远重复"的脚本队列,本组前面给同一 fd 留过旧长度,不清就会拿**旧长度配
新内容**(现象是 UDP-25j 读出 12 字节而不是 7 字节),所以每条用例前用
ch395f_fake_reset_recv_len(fd) 归零再宣布——这是测法问题,不是被测代码的问题。

全套 667/667 绿(改造前 470,本次 +13);net_udp.c 仍 237/239 行 14/14 函数,
net_socket.c 966/969 行 59/59 函数,模块级覆盖率无回退。
This commit is contained in:
2026-11-04 01:28:01 +08:00
parent f4ed8a4db2
commit 1b5e5d424c
2 changed files with 101 additions and 1 deletions
@@ -719,7 +719,7 @@ uint16_t ch395f_link_change_count(void);
- **不写第二份分块逻辑**:UDP 发送复用 `net_send_locked`(1KB 分块 + 挂起 send 都在里面)。为此把"会话是否已死"收成一条判据 `net_send_state_down()`(`net_pending.c`):TCP 看 `ESTABLISHED`、UDP 看 `UDP_OPEN`;原先 `net_send_locked` 两处 + `net_send_pending_tick` + `net_pending_tick` 各写一遍,共 4 处。
- **顺带修掉的形态缺陷**(真隐患):`net_sendto` 为发一个包把 DesIP 从 `0xFFFFFFFF` 写成具体 IP,芯片静默切到 UDP Client 形态(收包**不带** 8 字节信息头),而旧 `recvfrom` 无条件剥 8 字节 → 请求-响应式协议(SNTP 48 B 应答)会丢载荷前 8 字节并交出垃圾源地址。**修法**:`net_sock_t` 加 `udp_mode`(塞进原有 2 字节 padding,`sizeof(net_sock_t)` 不变)、`bind` 恒 SERVER、显式对端 `sendto` 切 CLIENT、`net_udp_set_peer(fd, NULL)` 轻量回 SERVER(不重开 Socket 因此不清芯片缓冲)、只在 SERVER 且 `avail >= 8` 时剥头(残包不猜头,TC-NETSOCK-440o 契约不变)。
- **截断语义定案**:一次调用交付一个数据报,`buf` 装不下的尾巴**必须排空**——`ch395f_read_recv_buf` 是按长度推进读指针的消费式读,不排空则下一次 `recvfrom` 交出上一个包的残料。排空用 64 B 栈上 scratch 循环读,**不用** `ch395f_clear_recv_buf`:它清整个接收缓冲,会把后面已经到达的包一起丢。
- **用例面**:新分片 `test/host/net_socket/ns_s7_udp.c` 共 24 组 UDP-1~24(bind 的 5 条芯片命令**顺序**、未 bind 一律 -5、服务器形态剥头逐字节、客户端形态不剥头、`sendto` 切形态、挂起收包在后续 poll 轮到达、预算超时按调用方 deadline 计、close 与重建结算不许静默丢通知、出参槽互斥与泄漏、截断排空、netTask 自等守卫、`net_init` 重建取消、流式 API 拒 `DGRAM`、非 netTask 调用零芯片事务、执行体与公开入口的参数守卫(越界 fd / `STREAM` / 未 bind / 零长 / 出参容量不足——拒时必须零芯片事务)、"队列未绑定"与"队列满"两条失败出口不许漏槽、`net_udp_set_peer` 轻/重两种回服务器模式(重开丢缓冲 vs 不重开)、挂起 send 由 `net_send_pending` 结算且登记时不许抢发第二份回执、`net_udp_tick` 的会话作废与 stale-readable 兜底、重 bind 必须把还在等包的调用方按 -8 结算、`bind` 遇满队列必须 BUSY 且不给 fd 投毒、**芯片 `open` 回 `ERR_BUSY` 时精确码必须沿通知值回到等待方**(真芯片会这么拒)、**队列里"别人的失败"排在我这条之前时不许污染我的回执**(同 545 在 TCP 侧的钉子,UDP bind 侧补一条)、`net_recvfrom_to` 的"真失败"与"超时"两条出口分开、**netTask 直调路径(板级用例走的那条)的 `set_peer` / `recvfrom` 正例与三种拒绝**、**UDP-24 = 240 轮定种子(`20261104`)随机长跑**:每轮随机载荷长度 1~112(112 = 假件收包流 128 B 上限减掉 8 字节信息头,不是魔数)、随机源端口与源 IP、随机 SERVER/CLIENT 形态、随机"显式对端 `sendto`"与"按记账对端 `sendto`",每 17 轮 `close` + 重 bind 一次;断言收在六族不变量上(读到的长度必须等于芯片宣布值、载荷既不吞信息头也不丢真字节、源地址按形态分别取自表头/对端记账、`udp_mode` 与命令严格一致、`close`/重 bind 后出参槽与挂起槽都不残留、每次 `sendto` 交给芯片的字节数恰好 = `len`),并要求 ≥90% 轮次端到端干净(实测 240/240)——**形态切换 × 挂起结算 × 关闭重建这三件事的交叉面,逐条用例排不出组合,只能靠随机扫**;改写 TC-NETSOCK-440(补 bind 前置 + 新增 440p~440u)、555(表头逐字节按形态分路)、560 组("拒在 netTask 外"→"经队列后非 netTask 仍零 SPI")、500h-i / 506 / 545。全套 **654/654 绿**(改造前 470)。
- **用例面**:新分片 `test/host/net_socket/ns_s7_udp.c` 共 25 组 UDP-1~25(bind 的 5 条芯片命令**顺序**、未 bind 一律 -5、服务器形态剥头逐字节、客户端形态不剥头、`sendto` 切形态、挂起收包在后续 poll 轮到达、预算超时按调用方 deadline 计、close 与重建结算不许静默丢通知、出参槽互斥与泄漏、截断排空、netTask 自等守卫、`net_init` 重建取消、流式 API 拒 `DGRAM`、非 netTask 调用零芯片事务、执行体与公开入口的参数守卫(越界 fd / `STREAM` / 未 bind / 零长 / 出参容量不足——拒时必须零芯片事务)、"队列未绑定"与"队列满"两条失败出口不许漏槽、`net_udp_set_peer` 轻/重两种回服务器模式(重开丢缓冲 vs 不重开)、挂起 send 由 `net_send_pending` 结算且登记时不许抢发第二份回执、`net_udp_tick` 的会话作废与 stale-readable 兜底、重 bind 必须把还在等包的调用方按 -8 结算、`bind` 遇满队列必须 BUSY 且不给 fd 投毒、**芯片 `open` 回 `ERR_BUSY` 时精确码必须沿通知值回到等待方**(真芯片会这么拒)、**队列里"别人的失败"排在我这条之前时不许污染我的回执**(同 545 在 TCP 侧的钉子,UDP bind 侧补一条)、`net_recvfrom_to` 的"真失败"与"超时"两条出口分开、**netTask 直调路径(板级用例走的那条)的 `set_peer` / `recvfrom` 正例与三种拒绝**、**UDP-24 = 240 轮定种子(`20261104`)随机长跑**:每轮随机载荷长度 1~112(112 = 假件收包流 128 B 上限减掉 8 字节信息头,不是魔数)、随机源端口与源 IP、随机 SERVER/CLIENT 形态、随机"显式对端 `sendto`"与"按记账对端 `sendto`",每 17 轮 `close` + 重 bind 一次;断言收在六族不变量上(读到的长度必须等于芯片宣布值、载荷既不吞信息头也不丢真字节、源地址按形态分别取自表头/对端记账、`udp_mode` 与命令严格一致、`close`/重 bind 后出参槽与挂起槽都不残留、每次 `sendto` 交给芯片的字节数恰好 = `len`),并要求 ≥90% 轮次端到端干净(实测 240/240)——**形态切换 × 挂起结算 × 关闭重建这三件事的交叉面,逐条用例排不出组合,只能靠随机扫**、**UDP-25 = 两条 UDP socket 并存的隔离**(前 24 组全都只用一条 socket,漏掉的正是队列化真正要保的那半句:等待是**一条 socket 自己的事**——fdB 来包不得结算、不得通知 fdA 的 parked 收包,fdA 的调用缓冲一个字节都不能动;反过来 fdB parked 时 fdA 自己 `recvfrom` 必须照常拿到自己那一包,串行的是命令不是等待者;关停在等的那条必须当场结算,不许留槽);改写 TC-NETSOCK-440(补 bind 前置 + 新增 440p~440u)、555(表头逐字节按形态分路)、560 组("拒在 netTask 外"→"经队列后非 netTask 仍零 SPI")、500h-i / 506 / 545。全套 **667/667 绿**(改造前 470)。
- **假件新增(一律可加不可改)**:`g_fx.wlen[]` + `g_fx.last_write[]` 捕获 `ch395f_write_send_buf` 的**真实字节**(此前假件忽略 `p_data`,UDP 截断与排空无从断言);`ch395f_fake_set_write_delay(fd, n)` 让 SENBUF_FREE 第 n 次才到(钉"排空必须在下一次写之前完成");`g_chip.read_calls` 供"读了几个 scratch 块"断言。`g_fake` / `g_chip` 既有字段一字未动。
- **两条教训(都是被测码真 bug,不是测法自误)**:① **执行侧自等守卫不能复用调用侧谓词**——`net_udp_recvfrom_exec` 用 `net_ctx_is_owner_task()` 判自等,而它在 netTask 内恒真 → 每一条经队列投进来的正常 recvfrom 都被拒;正确判据是 `caller == xTaskGetCurrentTaskHandle()`(对齐 `net_pending.c` 的 `caller == s_owner_task`)。抓它的是 UDP-8a。② **芯片长度只能读一次并传参**——`net_udp_datagram_read` 内部又调一次 `ch395f_get_recv_len`,于是"剥几字节"与"读几字节"不同源;假件的 recv_len 队列**每次调用弹一个值**,这条设计当场把双读暴露出来。现由调用方读一次 `avail` 传入三个调用点。
- **补测阶段又抓到两条(同一批缺口分析顺手挖的)**:③ **槽指针必须在 fd 合法性检查之后取**——`net_udp_recvfrom_exec` 原先先算 `&s_net_udp[sockfd]` 再判 `p_sock == NULL`,越界 fd 立刻得到一个越界槽指针(哪怕当下没解引用,后面登记就是野写)。④ **作废不等于可以不吭声**——`net_udp_bind_exec` 重开芯片 Socket 会丢掉缓冲里的包,却只 `net_udp_clear()`:A 任务正阻塞在 `net_recvfrom`、B 任务重 bind 同一个 fd,A 就永远睡死。现在重开之前先按 `NET_ERR_CONNRESET` 结算并通知(UDP-20 钉住)。教训同 `net_pending`:**任何"取消挂起"的动作都必须走 complete 那条路,clear 只是归零**。