fix(download): 换源后不再沿用旧源校验器 + 三条 p3 收口(M293 r1)
复审 r1(p2-1)指出的链条:源 A 落 3/4 段失败 ⇒ 元数据 url=A + A 的 ETag;换源 B 时 `decide` 判 UrlChanged、删盘重来,但 `ChunkJournal::begin` 把 A 的校验器原样写进 url=B 的 新元数据,而 `record` 只在 `etag.is_none()` 时写入 ⇒ B 的真实校验器永远盖不上。于是拿 **A 的 ETag** 对 B 发 `If-Range`:按 RFC 7233 §3.2 服务器回 200 + 完整正文 ⇒ `fetch_chunk` 判 RangeUnsupported ⇒ 丢掉整份 ⇒ 单连接从头下 —— 债务以另一条路径复现。 修法(复审给的选项 A):校验器与**记下它的 URL** 绑定,`same_source = previous.filter(|m| m.url == candidate.url)`,`etag` / `last_modified` / `If-Range` 三处全部走它,让「谁能当校验器」 只有一个判定点。不选 B(换源时清空)是因为 B 只是 A 在「URL 变了」这一特例上的写法,条件放在 读取处比散到写入处更难漏;不选 C(多源各记一份)是因为 `ResumeMeta` 本来就有 url,再加一个来源 字段会多一处可与 url 漂移的状态。 夹具此前不解析 `If-Range`,这一跳只能读代码推。现在 `support/` 记录每个请求的 `If-Range` 取值, 并按 RFC 7233 §3.2 处置(对不上就忽略 Range、回 200 + 完整正文),于是「真实服务器上的后果」也 进了用例。 判据(新用例 `chunked/switch.rs`,两个夹具服务器当一个官方源 + 一个镜像源): * 源 B 收到的 `If-Range` 只许是它自己的 `"src-b"`(源 A 同源续传照旧带 `"src-a"` 作对照); * 源 B 的字节数仍是 3 段 + 卡住的 1KiB + 补 1 段 = 41984(1.025×),换源没把同源续传打回整份重下; * 最终文件逐字节正确; * 第二条用例走「新源自己不发 ETag」那一支:`NoValidator` ⇒ 新源重试整份重下(`resumed_from=0`、 8 个请求、72704 字节),一个条件请求都不许发 —— 这是「无法证明就不复用」的明确取舍。 变异(去掉 URL 过滤)⇒ 两条都红,实测原文: assertion `left == right` failed: 源 B 收到了不属于它的校验器 left: ["\"src-a\""] right: ["\"src-b\""] 新源没有任何校验器,就不许发条件请求(带 A 的值更是串源):["\"src-a\""] 同一变异下还实测到了后果:`b_bytes=113664 b_hits=6 resumed_from=0`(200 白下一份 + 单连接整份 重下 = 2.77×),即复审预测的那一跳确实存在。 三条 p3 一并收口: * p3-1:`abort_all()` 之前用 `try_join_next` 把「已落完但还没被收走」的段记进账 (`absorb_completed`,总长判据与正常回收同一道)。这是尽力减少浪费、不是正确性前提,且 它是「谁先完成」的竞态 ⇒ 没有确定性用例能钉住,已在文档里写明边界。 * p3-2:`verify_chunks` 改为返回 `Option<u64>`(可复用字节数),`AttemptOk.resumed_from` 直接取它,删掉原来「记录另算一遍」的双份口径。 * p3-3:`chunk_resume` 模块头新增「已知代价」:重读量正比于**复用的字节**(不是整份文件)、 为什么不能用「相信摘要」来省掉、以及超时后 `spawn_blocking` 线程不可取消在 Windows 上的 句柄风险。 拆文件守 400 行硬线:`await_chunks` + `absorb_completed` 移到 `transfer_chunk/join.rs` (逐字节搬移),`MockResponse` 移到 `support/response.rs`(逐字节搬移,只加 `with_etag` / `without_etag` 两个构造器)。 门禁:lib 903 / engine_mock 17 / chunked 20 / resume 12 / partfile 7 / cache 6 全绿, clippy -D warnings 0、fmt --check 0;字节判据复测未变差(resume 41984 = 1.025×, noresume 72704 = 1.775×)。
This commit is contained in:
1 parent
3ade2d14a8
commit
ffca0f4454
8 files changed
+628
-238
No files matched your search
@@ -8,10 +8,13 @@
|
||||
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
mod response;
|
||||
mod server;
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use self::server::{MockResponse, MockServer};
|
||||
pub use self::response::MockResponse;
|
||||
#[allow(unused_imports)]
|
||||
pub use self::server::MockServer;
|
||||
|
||||
/// 断言用的小工具:唯一临时目录(进程内计数 + 时间戳,避免并行测试互相踩)。
|
||||
pub fn temp_dir(tag: &str) -> std::path::PathBuf {
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
//! 夹具的一次应答描述(状态码 / 正文 / Range 支持 / 校验器 / 各种坏形态)。
|
||||
//!
|
||||
//! 从 `support/server.rs` 拆出只为守 400 行硬线(M293 r1:给夹具加上 `If-Range` 观测之后)。
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
/// mock 服务器给一次请求的应答。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockResponse {
|
||||
/// HTTP 状态码。
|
||||
pub status: u16,
|
||||
/// 响应体。
|
||||
pub body: Vec<u8>,
|
||||
/// 是否声明 `Accept-Ranges: bytes` 并在收到 `Range` 时真的切段。
|
||||
pub range_support: bool,
|
||||
/// `ETag`(续传测试用)。
|
||||
pub etag: Option<String>,
|
||||
/// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。
|
||||
pub lie_content_range: bool,
|
||||
/// 读完请求头后**一个字节都不写**(服务端接了连接但永不回话)。
|
||||
///
|
||||
/// 这是「无界 `await`」唯一能表达成用例的形态:读方不设上界就永远不返回。
|
||||
pub silent: bool,
|
||||
/// 写出这么多字节正文后就**停住不再写**(中途卡住),连接保持打开。
|
||||
pub stall_at: Option<usize>,
|
||||
/// 正文实际只发这么多字节(头部仍声称全量 = 短包)。
|
||||
pub send_len: Option<usize>,
|
||||
/// 正文之后**多**发这么多字节,且故意不发 `Content-Length`(超长包:HTTP/1.1 靠关连接分帧)。
|
||||
pub trailing_extra: Option<usize>,
|
||||
/// 正文按 `piece` 字节一片、片间睡 `gap` 滴流(慢但持续,用来证明超时不会误杀它)。
|
||||
pub drip: Option<(usize, Duration)>,
|
||||
}
|
||||
|
||||
impl MockResponse {
|
||||
/// 200 + 完整内容(支持 Range)。
|
||||
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
body: body.into(),
|
||||
range_support: true,
|
||||
etag: Some("\"v1\"".to_string()),
|
||||
lie_content_range: false,
|
||||
silent: false,
|
||||
stall_at: None,
|
||||
send_len: None,
|
||||
trailing_extra: None,
|
||||
drip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定状态码 + 空体。
|
||||
pub fn status(status: u16) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: Vec::new(),
|
||||
range_support: false,
|
||||
etag: None,
|
||||
lie_content_range: false,
|
||||
silent: false,
|
||||
stall_at: None,
|
||||
send_len: None,
|
||||
trailing_extra: None,
|
||||
drip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 200 但**永不回话**:读完请求头就挂住,一个字都不写。
|
||||
pub fn silent() -> Self {
|
||||
Self {
|
||||
silent: true,
|
||||
..Self::ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 写完 `bytes` 字节正文后停住(中途卡住,连接不断)。
|
||||
pub fn stalling_after(mut self, bytes: usize) -> Self {
|
||||
self.stall_at = Some(bytes);
|
||||
self
|
||||
}
|
||||
|
||||
/// 头部声称全量、正文只发 `sent` 字节(短包,客户端读不满)。
|
||||
pub fn short_body(mut self, sent: usize) -> Self {
|
||||
self.send_len = Some(sent);
|
||||
self
|
||||
}
|
||||
|
||||
/// 正文之后多发 `extra` 字节且不发 `Content-Length`(超长包)。
|
||||
pub fn oversize_body(mut self, extra: usize) -> Self {
|
||||
self.trailing_extra = Some(extra);
|
||||
self
|
||||
}
|
||||
|
||||
/// 正文按 `piece` 字节一片、片间 `gap` 滴流(慢但持续)。
|
||||
pub fn dripping(mut self, piece: usize, gap: Duration) -> Self {
|
||||
self.drip = Some((piece, gap));
|
||||
self
|
||||
}
|
||||
|
||||
/// 声明不支持 Range(模拟部分镜像)。
|
||||
pub fn without_range(mut self) -> Self {
|
||||
self.range_support = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// 指定 `ETag`(换源用例要能造出「两台服务器各自不同的校验器」)。
|
||||
pub fn with_etag(mut self, etag: impl Into<String>) -> Self {
|
||||
self.etag = Some(etag.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// 完全不发 `ETag`(模拟「自己没有任何校验器」的源)。
|
||||
pub fn without_etag(mut self) -> Self {
|
||||
self.etag = None;
|
||||
self
|
||||
}
|
||||
|
||||
/// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。
|
||||
pub fn with_wrong_content_range(mut self) -> Self {
|
||||
self.lie_content_range = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
@@ -16,119 +16,17 @@ use std::time::Duration;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
|
||||
/// mock 服务器给一次请求的应答。
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MockResponse {
|
||||
/// HTTP 状态码。
|
||||
pub status: u16,
|
||||
/// 响应体。
|
||||
pub body: Vec<u8>,
|
||||
/// 是否声明 `Accept-Ranges: bytes` 并在收到 `Range` 时真的切段。
|
||||
pub range_support: bool,
|
||||
/// `ETag`(续传测试用)。
|
||||
pub etag: Option<String>,
|
||||
/// 返回 206 时故意把 `Content-Range` 写成从 0 开始(模拟段错位的坏服务器)。
|
||||
pub lie_content_range: bool,
|
||||
/// 读完请求头后**一个字节都不写**(服务端接了连接但永不回话)。
|
||||
///
|
||||
/// 这是「无界 `await`」唯一能表达成用例的形态:读方不设上界就永远不返回。
|
||||
pub silent: bool,
|
||||
/// 写出这么多字节正文后就**停住不再写**(中途卡住),连接保持打开。
|
||||
pub stall_at: Option<usize>,
|
||||
/// 正文实际只发这么多字节(头部仍声称全量 = 短包)。
|
||||
pub send_len: Option<usize>,
|
||||
/// 正文之后**多**发这么多字节,且故意不发 `Content-Length`(超长包:HTTP/1.1 靠关连接分帧)。
|
||||
pub trailing_extra: Option<usize>,
|
||||
/// 正文按 `piece` 字节一片、片间睡 `gap` 滴流(慢但持续,用来证明超时不会误杀它)。
|
||||
pub drip: Option<(usize, Duration)>,
|
||||
}
|
||||
pub use super::response::MockResponse;
|
||||
|
||||
/// 夹具「挂住」的时长:远大于用例自己的上界,保证读方只能靠超时脱身。
|
||||
const HOLD: Duration = Duration::from_secs(30);
|
||||
|
||||
impl MockResponse {
|
||||
/// 200 + 完整内容(支持 Range)。
|
||||
pub fn ok(body: impl Into<Vec<u8>>) -> Self {
|
||||
Self {
|
||||
status: 200,
|
||||
body: body.into(),
|
||||
range_support: true,
|
||||
etag: Some("\"v1\"".to_string()),
|
||||
lie_content_range: false,
|
||||
silent: false,
|
||||
stall_at: None,
|
||||
send_len: None,
|
||||
trailing_extra: None,
|
||||
drip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 固定状态码 + 空体。
|
||||
pub fn status(status: u16) -> Self {
|
||||
Self {
|
||||
status,
|
||||
body: Vec::new(),
|
||||
range_support: false,
|
||||
etag: None,
|
||||
lie_content_range: false,
|
||||
silent: false,
|
||||
stall_at: None,
|
||||
send_len: None,
|
||||
trailing_extra: None,
|
||||
drip: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// 200 但**永不回话**:读完请求头就挂住,一个字都不写。
|
||||
pub fn silent() -> Self {
|
||||
Self {
|
||||
silent: true,
|
||||
..Self::ok(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
/// 写完 `bytes` 字节正文后停住(中途卡住,连接不断)。
|
||||
pub fn stalling_after(mut self, bytes: usize) -> Self {
|
||||
self.stall_at = Some(bytes);
|
||||
self
|
||||
}
|
||||
|
||||
/// 头部声称全量、正文只发 `sent` 字节(短包,客户端读不满)。
|
||||
pub fn short_body(mut self, sent: usize) -> Self {
|
||||
self.send_len = Some(sent);
|
||||
self
|
||||
}
|
||||
|
||||
/// 正文之后多发 `extra` 字节且不发 `Content-Length`(超长包)。
|
||||
pub fn oversize_body(mut self, extra: usize) -> Self {
|
||||
self.trailing_extra = Some(extra);
|
||||
self
|
||||
}
|
||||
|
||||
/// 正文按 `piece` 字节一片、片间 `gap` 滴流(慢但持续)。
|
||||
pub fn dripping(mut self, piece: usize, gap: Duration) -> Self {
|
||||
self.drip = Some((piece, gap));
|
||||
self
|
||||
}
|
||||
|
||||
/// 声明不支持 Range(模拟部分镜像)。
|
||||
pub fn without_range(mut self) -> Self {
|
||||
self.range_support = false;
|
||||
self
|
||||
}
|
||||
|
||||
/// 206 但 `Content-Range` 谎报(模拟段错位的坏 CDN)。
|
||||
pub fn with_wrong_content_range(mut self) -> Self {
|
||||
self.lie_content_range = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// 进程内极简 HTTP/1.1 服务器:按脚本顺序返回响应,脚本用尽后一律 500。
|
||||
pub struct MockServer {
|
||||
addr: SocketAddr,
|
||||
hits: Arc<AtomicUsize>,
|
||||
bytes: Arc<std::sync::atomic::AtomicU64>,
|
||||
if_ranges: Arc<std::sync::Mutex<Vec<String>>>,
|
||||
handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
@@ -141,9 +39,11 @@ impl MockServer {
|
||||
let addr = listener.local_addr().expect("取本地地址失败");
|
||||
let hits = Arc::new(AtomicUsize::new(0));
|
||||
let bytes = Arc::new(std::sync::atomic::AtomicU64::new(0));
|
||||
let if_ranges = Arc::new(std::sync::Mutex::new(Vec::new()));
|
||||
let script = Arc::new(std::sync::Mutex::new(script));
|
||||
let counter = Arc::clone(&hits);
|
||||
let body_bytes = Arc::clone(&bytes);
|
||||
let seen_if_ranges = Arc::clone(&if_ranges);
|
||||
|
||||
let handle = tokio::spawn(async move {
|
||||
loop {
|
||||
@@ -153,6 +53,7 @@ impl MockServer {
|
||||
let script = Arc::clone(&script);
|
||||
let counter = Arc::clone(&counter);
|
||||
let bytes = Arc::clone(&body_bytes);
|
||||
let seen = Arc::clone(&seen_if_ranges);
|
||||
tokio::spawn(async move {
|
||||
let index = counter.fetch_add(1, Ordering::Relaxed);
|
||||
let response = {
|
||||
@@ -160,7 +61,7 @@ impl MockServer {
|
||||
script.get(index).cloned()
|
||||
};
|
||||
let response = response.unwrap_or_else(|| MockResponse::status(500));
|
||||
let _ = serve(stream, response, &bytes).await;
|
||||
let _ = serve(stream, response, &bytes, &seen).await;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -169,6 +70,7 @@ impl MockServer {
|
||||
addr,
|
||||
hits,
|
||||
bytes,
|
||||
if_ranges,
|
||||
handle,
|
||||
}
|
||||
}
|
||||
@@ -201,6 +103,17 @@ impl MockServer {
|
||||
self.bytes.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// 收到过的 `If-Range` 取值(按到达顺序)。
|
||||
///
|
||||
/// 换源用例的判据:源 A 的校验器**不许**出现在源 B 的请求里。夹具照 RFC 7233 §3.2 处置
|
||||
/// (对不上就忽略 Range、回 200 + 完整正文),所以这条也顺带把「真实服务器上的后果」跑出来。
|
||||
pub fn if_range_values(&self) -> Vec<String> {
|
||||
self.if_ranges
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// 关停。
|
||||
pub fn stop(self) {
|
||||
self.handle.abort();
|
||||
@@ -211,6 +124,7 @@ async fn serve(
|
||||
mut stream: TcpStream,
|
||||
response: MockResponse,
|
||||
sent: &std::sync::atomic::AtomicU64,
|
||||
seen_if_ranges: &std::sync::Mutex<Vec<String>>,
|
||||
) -> std::io::Result<()> {
|
||||
let mut buffer = Vec::new();
|
||||
let mut chunk = [0_u8; 1024];
|
||||
@@ -230,6 +144,30 @@ async fn serve(
|
||||
.find_map(|line| line.strip_prefix("range: bytes="))
|
||||
.map(str::trim)
|
||||
.and_then(parse_range_header);
|
||||
let if_range = request
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("if-range:"))
|
||||
.map(|value| value.trim().to_string());
|
||||
if let Some(value) = &if_range {
|
||||
seen_if_ranges
|
||||
.lock()
|
||||
.unwrap_or_else(|poisoned| poisoned.into_inner())
|
||||
.push(value.clone());
|
||||
}
|
||||
// RFC 7233 §3.2:`If-Range` 与当前表示对不上(含「服务器根本没有校验器」)⇒ **忽略 Range**,
|
||||
// 回 200 + 完整正文。夹具照此处置,于是「拿别的源的校验器去问」会真的走到
|
||||
// `fetch_chunk` 的 `RangeUnsupported` 分支(那就是真实服务器上退化成整份重下的那一跳)。
|
||||
let validator_ok = if_range.as_deref().is_some_and(|sent_value| {
|
||||
response
|
||||
.etag
|
||||
.as_deref()
|
||||
.is_some_and(|etag| sent_value.eq_ignore_ascii_case(etag))
|
||||
});
|
||||
let range = if response.range_support && if_range.is_some() && !validator_ok {
|
||||
None
|
||||
} else {
|
||||
range
|
||||
};
|
||||
|
||||
if response.silent {
|
||||
// 接了连接、读完请求头、一个字都不回:读方不设上界就永远挂在这里。
|
||||
|
||||
Reference in new issue
Block a user