fix(download): text_bounded 不再信 Content-Length 预分配,补两道内存闸;api/version 第四处裸 send 也上界(M290 r1)
复审 r1(p2-1 / p3-1 / p3-2 / p3-5)返工,三处既有结论(send_bounded 逐字节未动、三个既有调用方
零改动、阶段只用两档)原样保留。
**p2-1 `http.rs::text_bounded` 的内存**(复审判据成立):`Content-Length` 是「对面说了算」的数字,
拿它喂 `Vec::with_capacity` 等于让远端决定我们的内存,而分配失败是 abort、进不了 `Result`。
改成三道闸:
1. 头里声明 > `MAX_IN_MEMORY_BYTES`(64 MiB)⇒ 读完头立刻有界失败,**不预分配、不读正文**;
2. 容量只按真实到达增长(`Vec::new()`);
3. 读循环逐块卡同一道上限(在累加前判,不会先撑过上限一块)。
两道闸的 `message` 分别说「声明的长度」与「内存上限」,`kind` 都是 `InvalidRequest` ⇒ 与超时
(`Timeout` + 阶段)可区分。
**p3-1 第四处裸 `send()`**:`game/java/api/version.rs::fetch_release_page` 原本
`client.get(url).send()` + `response.text()`、整文件 0 处 timeout(生产路径:`source::pinned` →
`api::fetch_version`)。改走 `http::send_bounded` + `http::text_bounded`,界是参数(生产
`http::default_deadlines()`,用例压到 200ms)。
**p3-2 `download/stall.rs` 模块头**:把「`game/java/api.rs`、`game/java/net.rs`、`update/github.rs`
是裸 `send()`——未审」这句假事实换成实测清单(谁已收、谁可以留、为什么留:`auth/http.rs` 自带
`.timeout`、`preflight.rs` 由 `check_with_deadline` 套住、`region.rs` 自带 3s),并给
`http::text_bounded` 补一行「入口/上界/阶段」。
**p3-5 doc**:删掉「与 `Response::text()` 的唯一差别是 charset 解码」这条过度披露——本仓 reqwest
没开 `charset` feature,`text()` 本来就是 `from_utf8_lossy`,解码逐字节等价;改成真实差别
(读空闲 + 内存上限)。
离线用例(全在 `#[cfg(test)]`,夹具 `game::java::net::probe` 新增 `Mode::Declared{declared,sent}`
与 `Mode::Flood(n)`,外加共用的 `bounded()` 护栏助手):
* `api/version.rs::bounded_probes`:静默 ⇒ 阶段等响应头;正文停顿 ⇒ 阶段等字节;
声明 `Content-Length: 999999999999` 且不发正文 ⇒ <2s 返回 `InvalidRequest` 且文案含
「声明的长度」;不声明长度灌 65 MiB ⇒ 读循环在 64 MiB 处拒。
* `net.rs::tests` 的六条(4 条原有 + 2 条正常路径)改用同一个 `bounded()` 助手,断言一条未放宽。
变异实跑(贴红原文见 mission 报告):
1. 退回 `Vec::with_capacity(declared)` 且删提前拒绝 ⇒ `memory allocation of 999999999999 bytes
failed` + 进程 abort(0xc0000409),测试二进制消失——不是一条红;
2. 只删提前拒绝(预分配仍是 `Vec::new()`)⇒ 干净红:`left: Timeout / right: InvalidRequest`(0.21s);
3. 删循环里的上限卡口 ⇒ 干净红:`expect_err` 拿到 `Ok("xxxx…")`(0.54s);
4. `fetch_release_page` 退回裸 `send()` + `text()` ⇒ 两条 version 用例各在护栏 5s 处 panic(5.01s)。
门禁(真实退出码):check=0 / lib=896 passed / game=120 passed / launch=91 passed /
engine_mock=17 passed / clippy -D warnings=0 / fmt=0。行数:http 389、stall 378、api 389、
net 390、version 397、github 363(全 ≤400)。
This commit is contained in:
1 parent
ad99303a95
commit
bde6649aab
4 files changed
+264
-91
No files matched your search
@@ -14,6 +14,7 @@ use reqwest::header::{
|
|||||||
};
|
};
|
||||||
use reqwest::redirect::Policy;
|
use reqwest::redirect::Policy;
|
||||||
|
|
||||||
|
use super::bytes::MAX_IN_MEMORY_BYTES;
|
||||||
use super::config::EngineConfig;
|
use super::config::EngineConfig;
|
||||||
use super::error::{DownloadError, DownloadErrorKind};
|
use super::error::{DownloadError, DownloadErrorKind};
|
||||||
use super::stall::{Deadlines, StallReport, StallStage};
|
use super::stall::{Deadlines, StallReport, StallStage};
|
||||||
@@ -128,31 +129,46 @@ where
|
|||||||
///
|
///
|
||||||
/// 为什么不是 `response.text()`:它是一次无界的整段读,上游「回了头但正文不动」时永远不返回
|
/// 为什么不是 `response.text()`:它是一次无界的整段读,上游「回了头但正文不动」时永远不返回
|
||||||
/// —— 与 M289 在 `download_bytes` 里修掉的是同一种形态。这里换成流式累加 + 空闲判定,
|
/// —— 与 M289 在 `download_bytes` 里修掉的是同一种形态。这里换成流式累加 + 空闲判定,
|
||||||
/// 口径与 [`super::bytes`] 的读循环逐字一致(只是落成 `String`)。
|
/// 口径与 [`super::bytes`] 的读循环一致(同一道 `MAX_IN_MEMORY_BYTES` 卡口、同一个 `read_step`
|
||||||
|
/// 空闲判定;区别只是它落成 `Vec<u8>`、这里落成 `String`,且这里的循环在累加**前**判上限,
|
||||||
|
/// 不会先撑过上限一块)。
|
||||||
///
|
///
|
||||||
/// 只给「一次 GET 取一小段文本」用:分块/落盘作业走 [`read_step`] 自己的循环(它们要进度、
|
/// 只给「一次 GET 取一小段文本」用:分块/落盘作业走 [`read_step`] 自己的循环(它们要进度、
|
||||||
/// 要边读边写盘、要卡内存上限),别把那条路改成经过这里。
|
/// 要边读边写盘),别把那条路改成经过这里。
|
||||||
///
|
///
|
||||||
/// 与 `reqwest::Response::text()` 的**唯一**差别:不按 `Content-Type` 的 charset 解码,一律
|
/// **内存有三道闸**(复审 r1 p2-1:`Content-Length` 是「对面说了算」的数字,拿它去预分配等于让
|
||||||
/// UTF-8 lossy(声明了非 UTF-8 字符集时中文字节会变成替换符)。三条消费者(Adoptium JSON /
|
/// 远端决定我们的内存,而 `Vec::with_capacity` 分配失败是 abort、进不了 `Result`):
|
||||||
/// GitHub 清单 JSON / 各镜像的目录与校验文本)上游都是 UTF-8 或纯 ASCII。
|
/// 1. 头里声明的长度超 [`MAX_IN_MEMORY_BYTES`] ⇒ 立刻有界失败,**不预分配、不读正文**;
|
||||||
|
/// 2. 容量只按真实到达的字节增长(`Vec::new()`,不拿 `declared` 预分配);
|
||||||
|
/// 3. 读循环逐块卡上限(声明可以撒谎、也可以缺失 —— 关连接分帧)⇒ 边读边拒。
|
||||||
|
///
|
||||||
|
/// 解码:本仓 reqwest **没开 `charset`** feature(`Cargo.toml`),`Response::text()` 内部本来就是
|
||||||
|
/// `String::from_utf8_lossy` ⇒ 这里与它在解码上逐字节等价(复审 r1 p3-5 核过)。将来一旦打开
|
||||||
|
/// `charset`,`text()` 才会按 `Content-Type` 转码,那时两边才有区别。
|
||||||
///
|
///
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// 正文读取超时 ⇒ `kind = Timeout` 的 [`DownloadError`],阶段「等字节」+ 已收/预期/无进展
|
/// 正文读取超时 ⇒ `kind = Timeout` 的 [`DownloadError`],阶段「等字节」+ 已收/预期/无进展
|
||||||
/// 三个数(`message` 里带一份,见 [`DownloadError::stalled`])。
|
/// 三个数(`message` 里带一份,见 [`DownloadError::stalled`]);声明长度超上限、或正文读着读着
|
||||||
|
/// 超上限 ⇒ `kind = InvalidRequest`,`message` 分别说「声明的长度」与「内存上限」(两种可区分)。
|
||||||
pub async fn text_bounded(
|
pub async fn text_bounded(
|
||||||
response: reqwest::Response,
|
response: reqwest::Response,
|
||||||
url: &str,
|
url: &str,
|
||||||
deadlines: Deadlines,
|
deadlines: Deadlines,
|
||||||
) -> Result<String, DownloadError> {
|
) -> Result<String, DownloadError> {
|
||||||
let declared = content_length(response.headers());
|
let declared = content_length(response.headers());
|
||||||
|
if let Some(len) = declared {
|
||||||
|
if len > MAX_IN_MEMORY_BYTES {
|
||||||
|
return Err(too_large(
|
||||||
|
format!(
|
||||||
|
"文本资源声明的长度超上限(声明 {len} 字节 > 上限 {MAX_IN_MEMORY_BYTES} 字节)"
|
||||||
|
),
|
||||||
|
url,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
let mut stream = response.bytes_stream();
|
let mut stream = response.bytes_stream();
|
||||||
let mut body: Vec<u8> = Vec::with_capacity(
|
let mut body: Vec<u8> = Vec::new();
|
||||||
declared
|
|
||||||
.and_then(|len| usize::try_from(len).ok())
|
|
||||||
.unwrap_or(0),
|
|
||||||
);
|
|
||||||
loop {
|
loop {
|
||||||
let next = read_step(
|
let next = read_step(
|
||||||
futures_util::StreamExt::next(&mut stream),
|
futures_util::StreamExt::next(&mut stream),
|
||||||
@@ -163,11 +179,33 @@ pub async fn text_bounded(
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
let Some(chunk) = next else { break };
|
let Some(chunk) = next else { break };
|
||||||
body.extend_from_slice(&chunk.map_err(|err| classify_reqwest(&err, url))?);
|
let chunk = chunk.map_err(|err| classify_reqwest(&err, url))?;
|
||||||
|
if body.len() as u64 + chunk.len() as u64 > MAX_IN_MEMORY_BYTES {
|
||||||
|
return Err(too_large(
|
||||||
|
format!(
|
||||||
|
"文本资源在读入时超过内存上限(已收 {} 字节 + 本块 {} 字节 > 上限 {MAX_IN_MEMORY_BYTES} 字节)",
|
||||||
|
body.len(),
|
||||||
|
chunk.len()
|
||||||
|
),
|
||||||
|
url,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
body.extend_from_slice(&chunk);
|
||||||
}
|
}
|
||||||
Ok(String::from_utf8_lossy(&body).into_owned())
|
Ok(String::from_utf8_lossy(&body).into_owned())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 「文本资源太大」这一类的错误(两道闸共用形状,`message` 各自说清是哪一道)。
|
||||||
|
///
|
||||||
|
/// 不挂 `.with_source(url)`:地址已经在 `message` 里了,再挂一次只是让 `Display` 重复一遍
|
||||||
|
/// (与 `send_bounded` 的失速错误同一取舍)。
|
||||||
|
fn too_large(reason: String, url: &str) -> DownloadError {
|
||||||
|
DownloadError::new(
|
||||||
|
DownloadErrorKind::InvalidRequest,
|
||||||
|
format!("{reason},拒绝载入:{url}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
/// 手里没有 `EngineConfig` 的消费者用的两段界:**取 [`EngineConfig::default`],不另抄秒数**。
|
/// 手里没有 `EngineConfig` 的消费者用的两段界:**取 [`EngineConfig::default`],不另抄秒数**。
|
||||||
///
|
///
|
||||||
/// `game/java/api.rs`、`game/java/net.rs`、`update/github.rs` 的签名只收 `reqwest::Client`
|
/// `game/java/api.rs`、`game/java/net.rs`、`update/github.rs` 的签名只收 `reqwest::Client`
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
//! | `DownloadEngine::download` 单连接 | `http::send_bounded` + `http::read_step` | 等响应头 / 等字节 |
|
//! | `DownloadEngine::download` 单连接 | `http::send_bounded` + `http::read_step` | 等响应头 / 等字节 |
|
||||||
//! | `DownloadEngine::download` 分块 | 同上 + [`StallStage::AwaitChunkJoin`](无进度窗口与总期限) | 等分块回收 |
|
//! | `DownloadEngine::download` 分块 | 同上 + [`StallStage::AwaitChunkJoin`](无进度窗口与总期限) | 等分块回收 |
|
||||||
//! | `DownloadEngine::download_bytes` | `http::send_bounded` + `read_step` 流式累加(顺带用 `MAX_IN_MEMORY_BYTES` 卡住无 `Content-Length` 的洪流) | 等响应头 / 等字节 |
|
//! | `DownloadEngine::download_bytes` | `http::send_bounded` + `read_step` 流式累加(顺带用 `MAX_IN_MEMORY_BYTES` 卡住无 `Content-Length` 的洪流) | 等响应头 / 等字节 |
|
||||||
|
//! | `http::text_bounded`(模块外几条「一锤子 GET 取文本」的正文) | `read_step` 空闲判定 + 头/循环两道 `MAX_IN_MEMORY_BYTES` | 等字节 |
|
||||||
//! | `DownloadEngine::acquire`(许可) | `EngineConfig::permit_timeout` | [`StallStage::AcquirePermit`] |
|
//! | `DownloadEngine::acquire`(许可) | `EngineConfig::permit_timeout` | [`StallStage::AcquirePermit`] |
|
||||||
//! | 校验(`verify_part`) | `EngineConfig::verify_timeout` | [`StallStage::Verify`] |
|
//! | 校验(`verify_part`) | `EngineConfig::verify_timeout` | [`StallStage::Verify`] |
|
||||||
//! | `font::fetch_blocking` | 自带的整作业预算(外加上面全部) | —— |
|
//! | `font::fetch_blocking` | 自带的整作业预算(外加上面全部) | —— |
|
||||||
@@ -34,10 +35,23 @@
|
|||||||
//! 与 `region.rs` 的 3s 探测(自带界)。新加下载入口时**先看这张表**,别再出现第二个
|
//! 与 `region.rs` 的 3s 探测(自带界)。新加下载入口时**先看这张表**,别再出现第二个
|
||||||
//! `download_bytes`。
|
//! `download_bytes`。
|
||||||
//!
|
//!
|
||||||
//! 模块外还有几处用**同一个** `reqwest::Client`(只设了 `connect_timeout`)的地方,M289 没碰
|
//! # 模块外那几处 `send()` 的实测清单(M290 / M290 r1 收口)
|
||||||
//! (scope 是 `download/**`):`auth/http.rs` 自带 `.timeout(REQUEST_TIMEOUT)`;而
|
//!
|
||||||
//! `game/java/api.rs`、`game/java/net.rs`、`update/github.rs` 是裸 `send()`——**未审**,
|
//! 口径是 `git grep -n "\.send()" crates/core/src`(M290 r1 之后的 tip)。**core 里已经没有
|
||||||
//! 与这里修掉的是同族形态,已用 TowerFinding 报给塔台另立 mission。
|
//! 「既无自身上界、又没有外部 deadline」的 `send()` 了**:
|
||||||
|
//!
|
||||||
|
//! | 位置 | 现状 |
|
||||||
|
//! |------|------|
|
||||||
|
//! | `game/java/api.rs`(Adoptium latest 查询) | 已收:`http::send_bounded` + `http::text_bounded`(M290) |
|
||||||
|
//! | `game/java/net.rs`(四家发行版的一锤子 GET) | 同上(M290) |
|
||||||
|
//! | `game/java/api/version.rs`(GA 清单翻页) | 同上(M290 r1,这一处是复审打穿的第四处) |
|
||||||
|
//! | `update/github.rs`(更新清单) | 同上;界取 `engine.config().deadlines()`(M290) |
|
||||||
|
//! | `auth/http.rs` | **可以留**:RequestBuilder 自己挂了 `.timeout(REQUEST_TIMEOUT)`,reqwest 的该超时覆盖到响应体读完 |
|
||||||
|
//! | `auth/microsoft/preflight.rs` | **可以留**:`probe()` 由 `check_with_deadline` 的整体 deadline 套住 |
|
||||||
|
//! | `download/region.rs` | **可以留**:自带 3s 探测界(上面那张表里也有它) |
|
||||||
|
//!
|
||||||
|
//! 这几条文本路径只用 `await_headers` / `await_bytes` 两档:它们没有分块回收、也不经引擎许可,
|
||||||
|
//! 所以 `await_chunk_join` / `acquire_permit` 在这里不适用(不是「没做到」,是「不适用」)。
|
||||||
//!
|
//!
|
||||||
//! # 取舍:三段可以共存的界,**不是**「整作业必须在 N 秒内下完」
|
//! # 取舍:三段可以共存的界,**不是**「整作业必须在 N 秒内下完」
|
||||||
//!
|
//!
|
||||||
|
|||||||
@@ -24,6 +24,9 @@
|
|||||||
|
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
use crate::download::http;
|
||||||
|
use crate::download::stall::Deadlines;
|
||||||
|
|
||||||
use super::{ApiBinary, ApiVersion, ADOPTIUM_API_BASE};
|
use super::{ApiBinary, ApiVersion, ADOPTIUM_API_BASE};
|
||||||
use crate::game::java::catalog::{
|
use crate::game::java::catalog::{
|
||||||
canonical_version, matches_version, version_pin_error, VersionListScope,
|
canonical_version, matches_version, version_pin_error, VersionListScope,
|
||||||
@@ -207,22 +210,32 @@ pub fn page_outcome(status: u16, body: &str) -> Result<Option<Vec<String>>, Java
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 取一页 GA 清单(HTTP 那一跳)。判定全交给 [`page_outcome`],这里只负责把响应取回来。
|
/// 取一页 GA 清单(HTTP 那一跳)。判定全交给 [`page_outcome`],这里只负责把响应取回来。
|
||||||
|
///
|
||||||
|
/// 两处 `await` 都有界(M290 r1 p3-1:这一处原先既无上界也无内存上限,是「上游接了连不回话就
|
||||||
|
/// 永久挂」那一族的最后一份):等响应头走 [`http::send_bounded`]、正文走 [`http::text_bounded`],
|
||||||
|
/// 与 `net::get_text` 同一套构造点,失败时 `message` 里带**阶段**。界是参数只为让用例压到
|
||||||
|
/// 毫秒级,生产走 [`http::default_deadlines`]。
|
||||||
async fn fetch_release_page(
|
async fn fetch_release_page(
|
||||||
client: &reqwest::Client,
|
client: &reqwest::Client,
|
||||||
url: &str,
|
url: &str,
|
||||||
|
deadlines: Deadlines,
|
||||||
) -> Result<Option<Vec<String>>, JavaError> {
|
) -> Result<Option<Vec<String>>, JavaError> {
|
||||||
let response = client.get(url).send().await.map_err(|error| {
|
let response = http::send_bounded(client.get(url), url, deadlines, None)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
JavaError::new(
|
JavaError::new(
|
||||||
JavaErrorKind::Network,
|
JavaErrorKind::Network,
|
||||||
format!("取 Adoptium GA 清单失败:{error}({url})"),
|
format!("取 Adoptium GA 清单失败:{error}"),
|
||||||
)
|
)
|
||||||
.with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
|
.with_hint("检查网络/代理;已装的 JDK 仍可用 `ull java list` 看到")
|
||||||
})?;
|
})?;
|
||||||
let status = response.status().as_u16();
|
let status = response.status().as_u16();
|
||||||
let body = response.text().await.map_err(|error| {
|
let body = http::text_bounded(response, url, deadlines)
|
||||||
|
.await
|
||||||
|
.map_err(|error| {
|
||||||
JavaError::new(
|
JavaError::new(
|
||||||
JavaErrorKind::Network,
|
JavaErrorKind::Network,
|
||||||
format!("读 Adoptium GA 清单响应失败:{error}({url})"),
|
format!("读 Adoptium GA 清单响应失败:{error}"),
|
||||||
)
|
)
|
||||||
})?;
|
})?;
|
||||||
page_outcome(status, &body)
|
page_outcome(status, &body)
|
||||||
@@ -247,7 +260,7 @@ pub async fn fetch_version(
|
|||||||
) -> Result<JdkRelease, JavaError> {
|
) -> Result<JdkRelease, JavaError> {
|
||||||
let (releases, truncated) = collect_releases(|page| {
|
let (releases, truncated) = collect_releases(|page| {
|
||||||
let url = releases_url(major, target, page);
|
let url = releases_url(major, target, page);
|
||||||
async move { fetch_release_page(client, &url).await }
|
async move { fetch_release_page(client, &url, http::default_deadlines()).await }
|
||||||
})
|
})
|
||||||
.await?;
|
.await?;
|
||||||
let Some(name) = pick_release(&releases, wanted).cloned() else {
|
let Some(name) = pick_release(&releases, wanted).cloned() else {
|
||||||
@@ -284,3 +297,101 @@ pub async fn fetch_version(
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
|
/// `fetch_release_page` 的两处上界 + `http::text_bounded` 的两道内存闸;夹具与 `net::probe` 同源。
|
||||||
|
#[cfg(test)]
|
||||||
|
mod bounded_probes {
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
use super::fetch_release_page;
|
||||||
|
use crate::download::bytes::MAX_IN_MEMORY_BYTES;
|
||||||
|
use crate::download::error::{DownloadError, DownloadErrorKind};
|
||||||
|
use crate::download::http::text_bounded;
|
||||||
|
use crate::game::java::error::JavaErrorKind;
|
||||||
|
use crate::game::java::net::probe::{bounded, tight, Hanging, Mode};
|
||||||
|
|
||||||
|
async fn fetch_body(url: &str) -> Result<String, DownloadError> {
|
||||||
|
let client = reqwest::Client::new();
|
||||||
|
let response = client.get(url).send().await.expect("响应头应到手");
|
||||||
|
text_bounded(response, url, tight()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 服务端接了连接、一个字不回:取 GA 清单必须自己返回,并说清卡在**等响应头**。
|
||||||
|
/// 变异:把 `fetch_release_page` 的 `send_bounded` 退回裸 `send()` ⇒ 护栏 5s panic,红。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_silent_release_list_is_bounded_and_the_stage_is_named() {
|
||||||
|
let url = Hanging::start(Mode::Silent).url("/ga");
|
||||||
|
let (result, elapsed) = bounded(
|
||||||
|
"取 GA 清单的 send() 又无界了",
|
||||||
|
fetch_release_page(&reqwest::Client::new(), &url, tight()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_secs(2),
|
||||||
|
"必须自己返回:{elapsed:?}"
|
||||||
|
);
|
||||||
|
let error = result.expect_err("silent 服务端必须失败");
|
||||||
|
assert_eq!(error.kind(), JavaErrorKind::Network);
|
||||||
|
assert!(
|
||||||
|
error.message().contains("等响应头") && error.message().contains("await_headers"),
|
||||||
|
"{}",
|
||||||
|
error.message()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 头到手、正文挤出几个字节就停住:阶段是**等字节**(变异:换回 `text()` ⇒ 5s panic,红)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_stalled_release_body_says_await_bytes() {
|
||||||
|
let mode = Mode::Declared {
|
||||||
|
declared: 4096,
|
||||||
|
sent: 8,
|
||||||
|
};
|
||||||
|
let url = Hanging::start(mode).url("/ga");
|
||||||
|
let (result, _) = bounded(
|
||||||
|
"正文读取又无界了",
|
||||||
|
fetch_release_page(&reqwest::Client::new(), &url, tight()),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let error = result.expect_err("说到一半停住必须失败");
|
||||||
|
assert!(
|
||||||
|
error.message().contains("等字节") && error.message().contains("await_bytes"),
|
||||||
|
"{}",
|
||||||
|
error.message()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 头里离谱的 `Content-Length`:**有界失败**,绝不拿它预分配(复审 r1 p2-1)。
|
||||||
|
/// 变异:删掉提前拒绝并保留 `Vec::with_capacity(declared)` ⇒ 测试进程 abort(报告里贴了);
|
||||||
|
/// 只删提前拒绝(预分配是 `Vec::new()`)⇒ 本用例红(错误从「声明的长度」变成「等字节」)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_lying_content_length_is_a_bounded_error_not_an_abort() {
|
||||||
|
let mode = Mode::Declared {
|
||||||
|
declared: 999_999_999_999,
|
||||||
|
sent: 0,
|
||||||
|
};
|
||||||
|
let url = Hanging::start(mode).url("/lying.json");
|
||||||
|
let (result, elapsed) = bounded("离谱的声明长度没人管", fetch_body(&url)).await;
|
||||||
|
assert!(
|
||||||
|
elapsed < Duration::from_secs(2),
|
||||||
|
"读完头就该返回:{elapsed:?}"
|
||||||
|
);
|
||||||
|
let error = result.expect_err("离谱的声明长度必须失败");
|
||||||
|
assert_eq!(error.kind, DownloadErrorKind::InvalidRequest);
|
||||||
|
assert!(
|
||||||
|
error.message.contains("声明的长度") && error.message.contains("999999999999"),
|
||||||
|
"要看出是「声明的长度超上限」而不是超时:{}",
|
||||||
|
error.message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 声明缺失但正文是洪流:读循环逐块卡 [`MAX_IN_MEMORY_BYTES`](变异:删掉那道卡口 ⇒ 本
|
||||||
|
/// 用例拿到 `Ok`,红)。
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_flooding_body_is_cut_at_the_memory_cap() {
|
||||||
|
let url = Hanging::start(Mode::Flood(MAX_IN_MEMORY_BYTES + 1024 * 1024)).url("/flood.txt");
|
||||||
|
let (result, _) = bounded("正文没有内存上限", fetch_body(&url)).await;
|
||||||
|
let error = result.expect_err("洪流必须在接近上限处被拒");
|
||||||
|
assert_eq!(error.kind, DownloadErrorKind::InvalidRequest);
|
||||||
|
assert!(error.message.contains("内存上限"), "{}", error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -79,11 +79,9 @@ async fn get_text_with(
|
|||||||
Ok(body)
|
Ok(body)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 「接了连接不回话 / 说到一半停住」的回环服务端:M290 给**模块外**那三条一锤子 GET
|
/// 「接了连接不回话 / 停住 / 谎报长度 / 灌洪流」的回环服务端:M290 给**模块外**那几条一锤子
|
||||||
/// 配的定点探针(`game/java/api.rs`、本模块、`update/github.rs` 各用一次)。
|
/// GET 配的定点探针(`game/java/api.rs`、`api/version.rs`、本模块、`update/github.rs`、以及
|
||||||
///
|
/// `download/http.rs` 的正文上界各用一次)。住在这里只因为 scope 里的文件只有本模块有余量。
|
||||||
/// 为什么住在这里:夹具必须和它守的四个文件同属一个 scope,而 `download/http.rs` 加完
|
|
||||||
/// 正文上界已贴着 400 行红线;本模块行数最宽裕,且它就是「取一段文本」这条路径本身。
|
|
||||||
///
|
///
|
||||||
/// 与 `tests/download/support` 的 `MockServer::silent` 是**两种夹具**:那一份服务集成测试里的
|
/// 与 `tests/download/support` 的 `MockServer::silent` 是**两种夹具**:那一份服务集成测试里的
|
||||||
/// 引擎公开 API,这一份只能服务 `cfg(test)` 下的库里私有路径(`get_text_with` 等)。
|
/// 引擎公开 API,这一份只能服务 `cfg(test)` 下的库里私有路径(`get_text_with` 等)。
|
||||||
@@ -102,8 +100,10 @@ pub(crate) mod probe {
|
|||||||
pub(crate) enum Mode {
|
pub(crate) enum Mode {
|
||||||
/// 读完请求头、**一个字节都不写**(对面接了连接就装死)。
|
/// 读完请求头、**一个字节都不写**(对面接了连接就装死)。
|
||||||
Silent,
|
Silent,
|
||||||
/// 回完响应头 + 这么多正文字节,然后停住(连接不断)。
|
/// 头里声明 `declared` 字节、只发 `sent` 字节,然后停住(连接不断)。
|
||||||
StalledAfter(usize),
|
Declared { declared: u64, sent: usize },
|
||||||
|
/// 不发 `Content-Length`,灌 `Flood` 那么多字节后关连接(超长流:只能靠读方自己卡)。
|
||||||
|
Flood(u64),
|
||||||
/// 正常应答:200 + 这段正文(`Content-Length` 如实声明)。
|
/// 正常应答:200 + 这段正文(`Content-Length` 如实声明)。
|
||||||
Answer(&'static str),
|
Answer(&'static str),
|
||||||
}
|
}
|
||||||
@@ -124,6 +124,19 @@ pub(crate) mod probe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 跑一次「被测调用」,外面套 [`GATE`] 护栏并带回耗时:**护栏只会在读方自己无界时触发**,
|
||||||
|
/// 所以耗时远小于 `GATE` 就等于「它自己返回的」。`what` 是失败时那句 bug 说明。
|
||||||
|
pub(crate) async fn bounded<T>(
|
||||||
|
what: &str,
|
||||||
|
step: impl std::future::Future<Output = T>,
|
||||||
|
) -> (T, Duration) {
|
||||||
|
let started = std::time::Instant::now();
|
||||||
|
let value = tokio::time::timeout(GATE, step)
|
||||||
|
.await
|
||||||
|
.unwrap_or_else(|_| panic!("{what}:外层 {GATE:?} 挂断(M290 修的就是这条)"));
|
||||||
|
(value, started.elapsed())
|
||||||
|
}
|
||||||
|
|
||||||
/// 只在 `127.0.0.1` 上说坏话的服务端(线程泄漏到进程结束,用例之间互不影响)。
|
/// 只在 `127.0.0.1` 上说坏话的服务端(线程泄漏到进程结束,用例之间互不影响)。
|
||||||
pub(crate) struct Hanging {
|
pub(crate) struct Hanging {
|
||||||
addr: SocketAddr,
|
addr: SocketAddr,
|
||||||
@@ -155,15 +168,31 @@ pub(crate) mod probe {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
match mode {
|
match mode {
|
||||||
Mode::StalledAfter(bytes) => {
|
Mode::Declared { declared, sent } => {
|
||||||
// 头部声称 4 KiB、正文只给 `bytes` 字节:读方按空闲判定就该收手。
|
// 头里说 `declared`、正文只给 `sent` 字节:读方按空闲判定就该收手;
|
||||||
let head = b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
// `declared` 也可以离谱到远超上限(那种情况读方必须连正文都不读)。
|
||||||
Content-Length: 4096\r\n\r\n";
|
let head = format!(
|
||||||
if socket.write_all(head).is_err() {
|
"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\n\
|
||||||
|
Content-Length: {declared}\r\n\r\n"
|
||||||
|
);
|
||||||
|
socket.write_all(head.as_bytes()).expect("写响应头失败");
|
||||||
|
let _ = socket.write_all(&vec![b'{'; sent]);
|
||||||
|
let _ = socket.flush();
|
||||||
|
}
|
||||||
|
Mode::Flood(total) => {
|
||||||
|
// 不声明长度(靠关连接分帧):读方只能自己卡上限。
|
||||||
|
let chunk = vec![b'x'; 64 * 1024];
|
||||||
|
socket
|
||||||
|
.write_all(
|
||||||
|
b"HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\n\r\n",
|
||||||
|
)
|
||||||
|
.expect("写响应头失败");
|
||||||
|
for _ in 0..total.div_ceil(chunk.len() as u64) {
|
||||||
|
if socket.write_all(&chunk).is_err() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let _ = socket.write_all(&vec![b'{'; bytes]);
|
}
|
||||||
let _ = socket.flush();
|
return;
|
||||||
}
|
}
|
||||||
Mode::Answer(body) => {
|
Mode::Answer(body) => {
|
||||||
let head = format!(
|
let head = format!(
|
||||||
@@ -171,9 +200,7 @@ pub(crate) mod probe {
|
|||||||
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
Content-Length: {}\r\nConnection: close\r\n\r\n",
|
||||||
body.len()
|
body.len()
|
||||||
);
|
);
|
||||||
if socket.write_all(head.as_bytes()).is_err() {
|
socket.write_all(head.as_bytes()).expect("写响应头失败");
|
||||||
return;
|
|
||||||
}
|
|
||||||
let _ = socket.write_all(body.as_bytes());
|
let _ = socket.write_all(body.as_bytes());
|
||||||
let _ = socket.flush();
|
let _ = socket.flush();
|
||||||
return;
|
return;
|
||||||
@@ -202,22 +229,21 @@ pub(crate) mod probe {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use std::time::{Duration, Instant};
|
use std::time::Duration;
|
||||||
|
|
||||||
use super::probe::{tight, Hanging, Mode, GATE};
|
use super::probe::{bounded, tight, Hanging, Mode};
|
||||||
use super::{get_text, get_text_with};
|
use super::{get_text, get_text_with};
|
||||||
use crate::game::java::error::JavaErrorKind;
|
use crate::game::java::error::JavaErrorKind;
|
||||||
|
|
||||||
/// 服务端接了连接、一个字不回:`get_text` 必须自己返回,且说清卡在**等响应头**。
|
/// 服务端接了连接、一个字不回:`get_text` 必须自己返回,且说清卡在**等响应头**。
|
||||||
///
|
///
|
||||||
/// 变异证明:把 `get_text_with` 的 `send_bounded` 退回裸 `client.get(url).send()`
|
/// 变异证明:把 `get_text_with` 的 `send_bounded` 退回裸 `client.get(url).send()`
|
||||||
/// ⇒ 本用例在外层 5s 处挂断(`.expect(...)` 的文案就是那句 bug 说明),红。
|
/// ⇒ 护栏在外层 5s 处 panic(文案就是那句 bug 说明),红。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_silent_server_is_bounded_and_the_stage_is_named() {
|
async fn a_silent_server_is_bounded_and_the_stage_is_named() {
|
||||||
let server = Hanging::start(Mode::Silent);
|
let server = Hanging::start(Mode::Silent);
|
||||||
let started = Instant::now();
|
let (result, elapsed) = bounded(
|
||||||
let error = tokio::time::timeout(
|
"get_text 的 send() 又无界了",
|
||||||
GATE,
|
|
||||||
get_text_with(
|
get_text_with(
|
||||||
&reqwest::Client::new(),
|
&reqwest::Client::new(),
|
||||||
&server.url("/list.json"),
|
&server.url("/list.json"),
|
||||||
@@ -225,15 +251,12 @@ mod tests {
|
|||||||
tight(),
|
tight(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.expect("get_text 的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)")
|
|
||||||
.expect_err("silent 服务端必须失败");
|
|
||||||
let elapsed = started.elapsed();
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
elapsed < Duration::from_secs(2),
|
elapsed < Duration::from_secs(2),
|
||||||
"必须在上界附近自己返回,实际 {elapsed:?}"
|
"必须自己返回,实际 {elapsed:?}"
|
||||||
);
|
);
|
||||||
|
let error = result.expect_err("silent 服务端必须失败");
|
||||||
assert_eq!(error.kind(), JavaErrorKind::Network);
|
assert_eq!(error.kind(), JavaErrorKind::Network);
|
||||||
assert!(
|
assert!(
|
||||||
error.message().contains("等响应头") && error.message().contains("await_headers"),
|
error.message().contains("等响应头") && error.message().contains("await_headers"),
|
||||||
@@ -245,13 +268,15 @@ mod tests {
|
|||||||
|
|
||||||
/// 头到手、正文挤出几个字节就停住:正文那一段同样有界,阶段是**等字节**。
|
/// 头到手、正文挤出几个字节就停住:正文那一段同样有界,阶段是**等字节**。
|
||||||
///
|
///
|
||||||
/// 变异证明:把 `text_bounded` 换回 `response.text()` ⇒ 本用例在外层 5s 处挂断,红。
|
/// 变异证明:把 `text_bounded` 换回 `response.text()` ⇒ 护栏在外层 5s 处 panic,红。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_stalled_body_is_bounded_and_says_await_bytes() {
|
async fn a_stalled_body_is_bounded_and_says_await_bytes() {
|
||||||
let server = Hanging::start(Mode::StalledAfter(16));
|
let server = Hanging::start(Mode::Declared {
|
||||||
let started = Instant::now();
|
declared: 4096,
|
||||||
let error = tokio::time::timeout(
|
sent: 16,
|
||||||
GATE,
|
});
|
||||||
|
let (result, elapsed) = bounded(
|
||||||
|
"正文读取又无界了",
|
||||||
get_text_with(
|
get_text_with(
|
||||||
&reqwest::Client::new(),
|
&reqwest::Client::new(),
|
||||||
&server.url("/list.json"),
|
&server.url("/list.json"),
|
||||||
@@ -259,15 +284,12 @@ mod tests {
|
|||||||
tight(),
|
tight(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.expect("正文读取又无界了:外层 5s 挂断(`text()` 是整段读,没有空闲判定)")
|
|
||||||
.expect_err("说到一半停住必须失败");
|
|
||||||
let elapsed = started.elapsed();
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
elapsed < Duration::from_secs(2),
|
elapsed < Duration::from_secs(2),
|
||||||
"应在读空闲附近返回,实际 {elapsed:?}"
|
"应在读空闲附近返回,实际 {elapsed:?}"
|
||||||
);
|
);
|
||||||
|
let error = result.expect_err("说到一半停住必须失败");
|
||||||
assert!(
|
assert!(
|
||||||
error.message().contains("等字节") && error.message().contains("await_bytes"),
|
error.message().contains("等字节") && error.message().contains("await_bytes"),
|
||||||
"要说清卡在哪一段:{}",
|
"要说清卡在哪一段:{}",
|
||||||
@@ -277,8 +299,7 @@ mod tests {
|
|||||||
|
|
||||||
/// 正常路径不动:真回了一段 JSON 就原样交出来(含中文,证明解码没被打坏)。
|
/// 正常路径不动:真回了一段 JSON 就原样交出来(含中文,证明解码没被打坏)。
|
||||||
///
|
///
|
||||||
/// 变异证明:把 `text_bounded` 的解码换成按字节硬塞(`body.iter().map(|b| *b as char)`)⇒
|
/// 变异证明:把 `text_bounded` 的解码换成按字节硬塞 ⇒ 中文那条断言红。
|
||||||
/// 中文那条断言红;把状态分类挪到正文读之后 ⇒ 这里仍绿(本用例只管 200 的正常语义)。
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn a_normal_response_is_handed_over_unchanged() {
|
async fn a_normal_response_is_handed_over_unchanged() {
|
||||||
let body = r#"{"major": 21, "note": "中文也要活"}"#;
|
let body = r#"{"major": 21, "note": "中文也要活"}"#;
|
||||||
@@ -291,16 +312,15 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `game/java/api.rs` 那条 latest 查询的 HTTP 跳(`fetch_latest` → `fetch_body`):两处
|
/// `game/java/api.rs` 那条 latest 查询的 HTTP 跳(`fetch_latest` → `fetch_body`):两处
|
||||||
/// `await` 同样有界、阶段同样说得出。**用例住在这里**而不是 `api.rs`:那份文件已贴着
|
/// `await` 同样有界、阶段同样说得出。**用例住在这里**而不是 `api.rs`:那份文件贴着 400 行
|
||||||
/// 400 行红线(389 行),再塞一条就撞上,而它测的正是本模块这几行同款 GET(同一套构造点)。
|
/// 红线,而它测的正是本模块这几行同款 GET(同一套构造点)。
|
||||||
///
|
///
|
||||||
/// 变异证明:把 `fetch_body` 的 `send_bounded` 退回裸 `send()` ⇒ 本用例在外层 5s 挂断,红。
|
/// 变异证明:把 `fetch_body` 的 `send_bounded` 退回裸 `send()` ⇒ 护栏在外层 5s 处 panic,红。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn the_adoptium_latest_hop_is_bounded_and_the_stage_is_named() {
|
async fn the_adoptium_latest_hop_is_bounded_and_the_stage_is_named() {
|
||||||
let server = Hanging::start(Mode::Silent);
|
let server = Hanging::start(Mode::Silent);
|
||||||
let started = Instant::now();
|
let (result, elapsed) = bounded(
|
||||||
let error = tokio::time::timeout(
|
"fetch_latest 的 send() 又无界了",
|
||||||
GATE,
|
|
||||||
crate::game::java::api::fetch_body(
|
crate::game::java::api::fetch_body(
|
||||||
&reqwest::Client::new(),
|
&reqwest::Client::new(),
|
||||||
21,
|
21,
|
||||||
@@ -308,15 +328,12 @@ mod tests {
|
|||||||
tight(),
|
tight(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.expect("fetch_latest 的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)")
|
|
||||||
.expect_err("silent 服务端必须失败");
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
started.elapsed() < Duration::from_secs(2),
|
elapsed < Duration::from_secs(2),
|
||||||
"必须在上界附近自己返回,实际 {:?}",
|
"必须自己返回,实际 {elapsed:?}"
|
||||||
started.elapsed()
|
|
||||||
);
|
);
|
||||||
|
let error = result.expect_err("silent 服务端必须失败");
|
||||||
assert_eq!(error.kind(), JavaErrorKind::Network);
|
assert_eq!(error.kind(), JavaErrorKind::Network);
|
||||||
assert!(
|
assert!(
|
||||||
error.message().contains("等响应头") && error.message().contains("await_headers"),
|
error.message().contains("等响应头") && error.message().contains("await_headers"),
|
||||||
@@ -326,8 +343,6 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 正常路径不变:200 + 真 JSON 时 `fetch_body` 逐字节交出正文(`fetch_latest` 就靠它解析)。
|
/// 正常路径不变:200 + 真 JSON 时 `fetch_body` 逐字节交出正文(`fetch_latest` 就靠它解析)。
|
||||||
///
|
|
||||||
/// 变异证明:把 `text_bounded` 的返回值改成空串或按字符重编码 ⇒ 本用例红。
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn the_adoptium_latest_hop_still_returns_the_body_on_200() {
|
async fn the_adoptium_latest_hop_still_returns_the_body_on_200() {
|
||||||
let body = r#"[{"release_name":"jdk-21.0.13+9"}]"#;
|
let body = r#"[{"release_name":"jdk-21.0.13+9"}]"#;
|
||||||
@@ -344,32 +359,27 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// `update/github.rs` 取更新清单那一跳(`fetch_manifest_documents` → `candidate_text`):
|
/// `update/github.rs` 取更新清单那一跳(`fetch_manifest_documents` → `candidate_text`):
|
||||||
/// 同样是「一锤子 GET 拿一段 JSON」,两处 `await` 都必须有界。**用例住在这里**而不是
|
/// 同样是「一锤子 GET 拿一段 JSON」。**用例住在这里**而不是 `github.rs`:它内部是 `block_on`
|
||||||
/// `github.rs`:它内部是 `block_on` 的同步签名——在 tokio 用例里直接调它就会嵌套
|
/// 的同步签名——在 tokio 用例里直接调它会嵌套 `block_on` panic,只有这一层能直接测到。
|
||||||
/// `block_on` panic,只有这一层能被直接测到。
|
|
||||||
///
|
///
|
||||||
/// 变异证明:把 `candidate_text` 的 `send_bounded` 退回裸 `send()` ⇒ 本用例在外层 5s 挂断,红。
|
/// 变异证明:把 `candidate_text` 的 `send_bounded` 退回裸 `send()` ⇒ 护栏在外层 5s 处 panic,红。
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn the_manifest_fetch_is_bounded_and_the_stage_is_named() {
|
async fn the_manifest_fetch_is_bounded_and_the_stage_is_named() {
|
||||||
let server = Hanging::start(Mode::Silent);
|
let server = Hanging::start(Mode::Silent);
|
||||||
let started = Instant::now();
|
let (result, elapsed) = bounded(
|
||||||
let error = tokio::time::timeout(
|
"取更新清单的 send() 又无界了",
|
||||||
GATE,
|
|
||||||
crate::update::github::candidate_text(
|
crate::update::github::candidate_text(
|
||||||
&reqwest::Client::new(),
|
&reqwest::Client::new(),
|
||||||
&server.url("/manifest.json"),
|
&server.url("/manifest.json"),
|
||||||
tight(),
|
tight(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.await
|
.await;
|
||||||
.expect("取更新清单的 send() 又无界了:外层 5s 挂断(M290 修的就是这条)")
|
|
||||||
.expect_err("silent 服务端必须失败");
|
|
||||||
|
|
||||||
assert!(
|
assert!(
|
||||||
started.elapsed() < Duration::from_secs(2),
|
elapsed < Duration::from_secs(2),
|
||||||
"必须在上界附近自己返回,实际 {:?}",
|
"必须自己返回,实际 {elapsed:?}"
|
||||||
started.elapsed()
|
|
||||||
);
|
);
|
||||||
|
let error = result.expect_err("silent 服务端必须失败");
|
||||||
assert_eq!(error.kind, crate::update::UpdateErrorKind::Network);
|
assert_eq!(error.kind, crate::update::UpdateErrorKind::Network);
|
||||||
assert!(
|
assert!(
|
||||||
error.message.contains("等响应头") && error.message.contains("await_headers"),
|
error.message.contains("等响应头") && error.message.contains("await_headers"),
|
||||||
|
|||||||
Reference in new issue
Block a user