protocol: 修复重复分片能凑出一张坏帧

`seen >= expected` 判断分片是否收齐是错的:UDP 会重复投递,一个丢失的
分片可以被另一个包的副本凑数,于是计数达标而缓冲区里留着空洞,交出去
一张尾部是 0 的 JPEG。

这个 bug 很隐蔽——帧率完全正常,没有任何报错线索,只是偶尔几张图解不
出来。实测 10 秒内出现 34 张这样的坏帧。

改为按分片下标记录是否收到,重复的直接跳过且不计数;再显式确认最后一个
分片确实到过。实测坏帧降到 1(板子启动瞬间的残帧),完整帧率不变。

补两个测试覆盖它——原有测试没有一个会重复发分片,所以这个 bug 一直没被
发现:
  - a_duplicate_cannot_complete_a_frame_with_a_hole
  - a_gap_that_is_never_filled_never_yields_a_frame

同时更新 scripts/udp_probe.py:它有同一个 bug,所以测不出修复效果。它还
把 Subscribe 的 role 字节多填了 12 个零字节,固件按偏移严格读取因而拒收,
表现为"板子在线但 0 clients subscribed"。脚本的意义正是独立核对线路格式,
所以这些偏移是手写的,改协议时必须同步。

另删 scripts/pick-flash-pad.sh:链接期 padding 已被证明移不动
.flash.text 内部,flash.sh 现在会自动挑选避开坏字节的 app 偏移。
This commit is contained in:
Horldsence
2026-10-05 18:54:35 +08:00
parent 4f893ce140
commit 1c65eaabc6
3 changed files with 247 additions and 148 deletions
+75 -3
View File
@@ -540,8 +540,13 @@ pub struct FrameAssembler {
height: u16,
since: Option<std::time::Instant>,
timeout: std::time::Duration,
/// 已收到的分片数,含重复。用来统计网络丢了多少。
/// 已收到的分片数(不含重复)。用来统计网络丢了多少。
seen: usize,
/// 每个分片是否已收到。
///
/// 存在的原因是 `seen` 会被重复包灌水:UDP 会重复投递,一个丢失的分片可以
/// 被另一个包的副本凑数,于是"收够了"却留着空洞,交出去一张尾部是 0 的坏图。
have: Vec<bool>,
}
impl FrameAssembler {
@@ -557,6 +562,7 @@ impl FrameAssembler {
since: None,
timeout,
seen: 0,
have: Vec::new(),
}
}
@@ -602,12 +608,27 @@ impl FrameAssembler {
self.height = frag.header.height;
self.since = Some(std::time::Instant::now());
self.seen = 0;
self.have.clear();
self.have.resize(self.expected, false);
}
Some(id) if id == frame_id => {}
_ => unreachable!("current_id was just reconciled with frame_id"),
}
let start = frag.header.frag_index as usize * MAX_FRAGMENT_PAYLOAD;
let index = frag.header.frag_index as usize;
// 重复分片直接跳过,且**不**增加 seen。
//
// 这里曾经用 `seen >= expected` 判断收齐,那是错的:UDP 会重复 delivered
// 重复包,一个丢失的分片可以被另一个包的副本凑数,于是 seen 达标而缓冲区
// 里留着空洞,交出去一张 JPEG 尾部是 0 的坏图。这个 bug 很隐蔽——它不会让
// 帧率下降,只会让偶尔几张图解不出来,而接收端没有任何线索知道原因。
if self.have[index] {
return Err(DropReason::Superseded);
}
self.have[index] = true;
let start = index * MAX_FRAGMENT_PAYLOAD;
let end = start + frag.payload.len();
if end > self.buffer.len() {
@@ -616,7 +637,13 @@ impl FrameAssembler {
self.buffer[start..end].copy_from_slice(&frag.payload);
self.seen += 1;
if self.seen >= self.expected {
// 计数达标还不够:最后一个分片必须真的到过,否则中间可能缺一块。
// 显式检查尾块,比依赖 seen 的算术更直接,也更容易看出意图。
let last = self.expected - 1;
let tail_expected = frag.header.total_len as usize;
let tail_index = tail_expected.saturating_sub(1) / MAX_FRAGMENT_PAYLOAD;
if self.seen >= self.expected && self.have[tail_index] {
let total = frag.header.total_len as usize;
self.buffer.truncate(total.min(self.buffer.len()));
@@ -660,6 +687,7 @@ impl FrameAssembler {
self.buffer.shrink_to_fit();
self.since = None;
self.seen = 0;
self.have = Vec::new();
}
}
@@ -852,6 +880,50 @@ mod tests {
assert_ne!(a, id_from_mac(&[1, 2, 3, 4, 5, 6]));
}
#[test]
fn a_duplicate_cannot_complete_a_frame_with_a_hole() {
// UDP 会重复投递。一个丢失的分片若能被另一片的副本凑数,接收端就会以为
// 收齐了,交出一张中间是 0 的坏 JPEG——而且帧率正常、没有任何报错线索。
let jpeg: Vec<u8> = (0..5000u32).map(|i| (i % 251) as u8).collect();
let frags = fragment_frame(1, 1, &jpeg, 640, 480);
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(2));
// 丢掉第 2 片,然后把第 0 片连送三次来灌 seen。
for f in frags.iter().filter(|f| f.header.frag_index != 2) {
let _ = asm.push(f.clone());
}
for _ in 0..3 {
assert!(
asm.push(frags[0].clone()).is_err(),
"a duplicate must not be allowed to complete the frame"
);
}
assert!(asm.is_in_progress(), "the hole must keep it incomplete");
// 补上真正的第 2 片,才在这一片上收齐——所以结果要从这一次的返回值拿。
let completed = asm.push(frags[2].clone());
assert_eq!(
completed.expect("completes once the hole is filled").data,
jpeg,
);
}
#[test]
fn a_gap_that_is_never_filled_never_yields_a_frame() {
let jpeg = vec![0xAAu8; 5000];
let frags = fragment_frame(1, 1, &jpeg, 640, 480);
let mut asm = FrameAssembler::new(std::time::Duration::from_secs(2));
// 反复送同一片,永远凑不齐——接收端必须一直等着,而不是交出半张图。
for _ in 0..50 {
assert!(asm.push(frags[0].clone()).is_err());
}
assert!(asm.is_in_progress(), "50 duplicates must not complete a frame");
assert_eq!(asm.sender_id(), 1, "and it must still be the same camera");
}
#[test]
fn empty_jpeg_still_produces_one_fragment() {
let frags = fragment_frame(1, 1, &[], 64, 48);