Merge tag 'v0.10.27' into beta

This commit is contained in:
2026-01-27 23:03:46 +03:00
804 changed files with 69201 additions and 21982 deletions

View File

@@ -0,0 +1,12 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n locale = $4,\n default_page = $5,\n collapsed_navigation = $6,\n advanced_rendering = $7,\n native_decorations = $8,\n\n discord_rpc = $9,\n developer_mode = $10,\n telemetry = $11,\n personalized_ads = $12,\n\n onboarded = $13,\n\n extra_launch_args = jsonb($14),\n custom_env_vars = jsonb($15),\n mc_memory_max = $16,\n mc_force_fullscreen = $17,\n mc_game_resolution_x = $18,\n mc_game_resolution_y = $19,\n hide_on_process_start = $20,\n\n hook_pre_launch = $21,\n hook_wrapper = $22,\n hook_post_exit = $23,\n\n custom_dir = $24,\n prev_custom_dir = $25,\n migrated = $26,\n\n toggle_sidebar = $27,\n feature_flags = $28,\n hide_nametag_skins_page = $29,\n\n skipped_update = $30,\n pending_update_toast_for_version = $31,\n auto_download_updates = $32,\n\n version = $33\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 33
},
"nullable": []
},
"hash": "175067f04e775f5469146f3cb77c422c3ab7203409083fd3c9c968b00b46918f"
}

View File

@@ -1,6 +1,6 @@
{
"db_name": "SQLite",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
"query": "\n SELECT\n max_concurrent_writes, max_concurrent_downloads,\n theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,\n discord_rpc, developer_mode, telemetry, personalized_ads,\n onboarded,\n json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,\n mc_memory_max, mc_force_fullscreen, mc_game_resolution_x, mc_game_resolution_y, hide_on_process_start,\n hook_pre_launch, hook_wrapper, hook_post_exit,\n custom_dir, prev_custom_dir, migrated, json(feature_flags) feature_flags, toggle_sidebar,\n skipped_update, pending_update_toast_for_version, auto_download_updates,\n version\n FROM settings\n ",
"describe": {
"columns": [
{
@@ -19,148 +19,153 @@
"type_info": "Text"
},
{
"name": "default_page",
"name": "locale",
"ordinal": 3,
"type_info": "Text"
},
{
"name": "collapsed_navigation",
"name": "default_page",
"ordinal": 4,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "hide_nametag_skins_page",
"name": "collapsed_navigation",
"ordinal": 5,
"type_info": "Integer"
},
{
"name": "advanced_rendering",
"name": "hide_nametag_skins_page",
"ordinal": 6,
"type_info": "Integer"
},
{
"name": "native_decorations",
"name": "advanced_rendering",
"ordinal": 7,
"type_info": "Integer"
},
{
"name": "discord_rpc",
"name": "native_decorations",
"ordinal": 8,
"type_info": "Integer"
},
{
"name": "developer_mode",
"name": "discord_rpc",
"ordinal": 9,
"type_info": "Integer"
},
{
"name": "telemetry",
"name": "developer_mode",
"ordinal": 10,
"type_info": "Integer"
},
{
"name": "personalized_ads",
"name": "telemetry",
"ordinal": 11,
"type_info": "Integer"
},
{
"name": "onboarded",
"name": "personalized_ads",
"ordinal": 12,
"type_info": "Integer"
},
{
"name": "extra_launch_args",
"name": "onboarded",
"ordinal": 13,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "custom_env_vars",
"name": "extra_launch_args",
"ordinal": 14,
"type_info": "Text"
},
{
"name": "mc_memory_max",
"name": "custom_env_vars",
"ordinal": 15,
"type_info": "Integer"
"type_info": "Text"
},
{
"name": "mc_force_fullscreen",
"name": "mc_memory_max",
"ordinal": 16,
"type_info": "Integer"
},
{
"name": "mc_game_resolution_x",
"name": "mc_force_fullscreen",
"ordinal": 17,
"type_info": "Integer"
},
{
"name": "mc_game_resolution_y",
"name": "mc_game_resolution_x",
"ordinal": 18,
"type_info": "Integer"
},
{
"name": "hide_on_process_start",
"name": "mc_game_resolution_y",
"ordinal": 19,
"type_info": "Integer"
},
{
"name": "hook_pre_launch",
"name": "hide_on_process_start",
"ordinal": 20,
"type_info": "Text"
"type_info": "Integer"
},
{
"name": "hook_wrapper",
"name": "hook_pre_launch",
"ordinal": 21,
"type_info": "Text"
},
{
"name": "hook_post_exit",
"name": "hook_wrapper",
"ordinal": 22,
"type_info": "Text"
},
{
"name": "custom_dir",
"name": "hook_post_exit",
"ordinal": 23,
"type_info": "Text"
},
{
"name": "prev_custom_dir",
"name": "custom_dir",
"ordinal": 24,
"type_info": "Text"
},
{
"name": "migrated",
"name": "prev_custom_dir",
"ordinal": 25,
"type_info": "Text"
},
{
"name": "migrated",
"ordinal": 26,
"type_info": "Integer"
},
{
"name": "feature_flags",
"ordinal": 26,
"ordinal": 27,
"type_info": "Text"
},
{
"name": "toggle_sidebar",
"ordinal": 27,
"ordinal": 28,
"type_info": "Integer"
},
{
"name": "skipped_update",
"ordinal": 28,
"type_info": "Text"
},
{
"name": "pending_update_toast_for_version",
"ordinal": 29,
"type_info": "Text"
},
{
"name": "auto_download_updates",
"name": "pending_update_toast_for_version",
"ordinal": 30,
"type_info": "Text"
},
{
"name": "auto_download_updates",
"ordinal": 31,
"type_info": "Integer"
},
{
"name": "version",
"ordinal": 31,
"ordinal": 32,
"type_info": "Integer"
}
],
@@ -181,6 +186,7 @@
false,
false,
false,
false,
null,
null,
false,
@@ -202,5 +208,5 @@
false
]
},
"hash": "07ea3a644644de61c4ed7c30ee711d29fd49f10534230b1b03097275a30cb50f"
"hash": "8e62fba05f331f91822ec204695ecb40567541c547181a4ef847318845cf3110"
}

View File

@@ -1,12 +0,0 @@
{
"db_name": "SQLite",
"query": "\n UPDATE settings\n SET\n max_concurrent_writes = $1,\n max_concurrent_downloads = $2,\n\n theme = $3,\n default_page = $4,\n collapsed_navigation = $5,\n advanced_rendering = $6,\n native_decorations = $7,\n\n discord_rpc = $8,\n developer_mode = $9,\n telemetry = $10,\n personalized_ads = $11,\n\n onboarded = $12,\n\n extra_launch_args = jsonb($13),\n custom_env_vars = jsonb($14),\n mc_memory_max = $15,\n mc_force_fullscreen = $16,\n mc_game_resolution_x = $17,\n mc_game_resolution_y = $18,\n hide_on_process_start = $19,\n\n hook_pre_launch = $20,\n hook_wrapper = $21,\n hook_post_exit = $22,\n\n custom_dir = $23,\n prev_custom_dir = $24,\n migrated = $25,\n\n toggle_sidebar = $26,\n feature_flags = $27,\n hide_nametag_skins_page = $28,\n\n skipped_update = $29,\n pending_update_toast_for_version = $30,\n auto_download_updates = $31,\n\n version = $32\n ",
"describe": {
"columns": [],
"parameters": {
"Right": 32
},
"nullable": []
},
"hash": "a40e60da6dd1312d4a1ed52fa8fd2394e7ad21de1cb44cf8b93c4b1459cdc716"
}

View File

@@ -0,0 +1,9 @@
# Copying
The source code of Modrinth App's backend is licensed under the GNU General Public License, Version 3 only, which is provided in the file [LICENSE](./LICENSE). However, some files listed below are licensed under a different license.
## Modrinth logo
The use of Modrinth branding elements, including but not limited to the wrench-in-labyrinth logo, the landing image, and any variations thereof, is strictly prohibited without explicit written permission from Rinth, Inc. This includes trademarks, logos, or other branding elements.
> All rights reserved. © 2020-2025 Rinth, Inc.

View File

@@ -10,7 +10,7 @@ async-compression = { workspace = true, features = ["gzip", "tokio"] }
async-recursion = { workspace = true }
async-tungstenite = { workspace = true, features = [
"tokio-runtime",
"tokio-rustls-webpki-roots",
"tokio-rustls-webpki-roots"
] }
async-walkdir = { workspace = true }
async_zip = { workspace = true, features = [
@@ -46,6 +46,7 @@ itertools = { workspace = true }
notify = { workspace = true }
notify-debouncer-mini = { workspace = true }
p256 = { workspace = true, features = ["ecdsa"] }
parking_lot = { workspace = true }
paste = { workspace = true }
path-util = { workspace = true }
phf = { workspace = true }
@@ -95,12 +96,7 @@ tokio = { workspace = true, features = [
"sync",
"time",
] }
tokio-util = { workspace = true, features = [
"compat",
"io",
"io-util",
"time",
] }
tokio-util = { workspace = true, features = ["compat", "io", "io-util", "time"] }
tracing = { workspace = true }
tracing-error = { workspace = true }
tracing-subscriber = { workspace = true, features = ["chrono", "env-filter"] }

View File

@@ -0,0 +1,2 @@
-- Add migration script here
ALTER TABLE settings ADD COLUMN locale TEXT NOT NULL DEFAULT 'en-US';

View File

@@ -65,6 +65,9 @@ pub enum ErrorKind {
#[error("Error fetching URL: {0}")]
FetchError(#[from] reqwest::Error),
#[error("Too many API errors; temporarily blocked")]
ApiIsDownError,
#[error("{0}")]
LabrinthError(LabrinthError),

View File

@@ -73,7 +73,7 @@ impl QuickPlayVersion {
let mut singleplayer = QuickPlaySingleplayerVersion::Builtin;
let mut singleplayer_version = singleplayer.min_version();
for version in versions.iter().take(version_index - 1) {
for version in versions.iter().take(version_index) {
if let Some(check_version) = server_version
&& version.id == check_version
{

View File

@@ -26,8 +26,13 @@ pub use event::{
pub use logger::start_logger;
pub use state::State;
pub const LAUNCHER_USER_AGENT: &str = concat!(
"modrinth/theseus/",
env!("CARGO_PKG_VERSION"),
" (support@modrinth.com)"
);
pub fn launcher_user_agent() -> String {
const LAUNCHER_BASE_USER_AGENT: &str =
concat!("modrinth/theseus/", env!("CARGO_PKG_VERSION"),);
format!(
"{} ({}; support@modrinth.com)",
LAUNCHER_BASE_USER_AGENT,
std::env::consts::OS
)
}

View File

@@ -1307,9 +1307,9 @@ impl CachedEntry {
let variations =
futures::future::try_join_all(filtered_keys.iter().map(
|((loaders_key, game_version), hashes)| {
fetch_json::<HashMap<String, Version>>(
fetch_json::<HashMap<String, Vec<Version>>>(
Method::POST,
concat!(env!("MODRINTH_API_URL"), "version_files/update"),
concat!(env!("MODRINTH_API_URL"), "version_files/update_many"),
None,
Some(serde_json::json!({
"algorithm": "sha1",
@@ -1330,28 +1330,30 @@ impl CachedEntry {
&filtered_keys[index];
for hash in hashes {
let version = variation.remove(hash);
let versions = variation.remove(hash);
if let Some(version) = version {
let version_id = version.id.clone();
vals.push((
CacheValue::Version(version).get_entry(),
false,
));
if let Some(versions) = versions {
for version in versions {
let version_id = version.id.clone();
vals.push((
CacheValue::Version(version).get_entry(),
false,
));
vals.push((
CacheValue::FileUpdate(CachedFileUpdate {
hash: hash.clone(),
game_version: game_version.clone(),
loaders: loaders_key
.split('+')
.map(|x| x.to_string())
.collect(),
update_version_id: version_id,
})
.get_entry(),
true,
));
vals.push((
CacheValue::FileUpdate(CachedFileUpdate {
hash: hash.clone(),
game_version: game_version.clone(),
loaders: loaders_key
.split('+')
.map(|x| x.to_string())
.collect(),
update_version_id: version_id,
})
.get_entry(),
true,
));
}
} else {
vals.push((
CacheValueType::FileUpdate.get_empty_entry(

View File

@@ -1,5 +1,4 @@
use crate::ErrorKind;
use crate::LAUNCHER_USER_AGENT;
use crate::data::ModrinthCredentials;
use crate::event::FriendPayload;
use crate::event::emit::emit_friend;
@@ -85,7 +84,7 @@ impl FriendsSocket {
request.headers_mut().insert(
"User-Agent",
HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap(),
HeaderValue::from_str(&crate::launcher_user_agent()).unwrap(),
);
let res = connect_async(request).await;

View File

@@ -1009,17 +1009,15 @@ impl Profile {
initial_file.file_name
);
let update_version_id = if let Some(update) = file_updates
.iter()
.find(|x| x.hash == hash.hash)
.map(|x| x.update_version_id.clone())
{
if let Some(metadata) = &file {
if metadata.version_id != update {
Some(update)
} else {
None
}
let update_version_id = if let Some(metadata) = &file {
let update_ids: Vec<String> = file_updates
.iter()
.filter(|x| x.hash == hash.hash)
.map(|x| x.update_version_id.clone())
.collect();
if !update_ids.contains(&metadata.version_id) {
update_ids.into_iter().next()
} else {
None
}

View File

@@ -12,6 +12,7 @@ pub struct Settings {
pub max_concurrent_writes: usize,
pub theme: Theme,
pub locale: String,
pub default_page: DefaultPage,
pub collapsed_navigation: bool,
pub hide_nametag_skins_page: bool,
@@ -66,7 +67,7 @@ impl Settings {
"
SELECT
max_concurrent_writes, max_concurrent_downloads,
theme, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
theme, locale, default_page, collapsed_navigation, hide_nametag_skins_page, advanced_rendering, native_decorations,
discord_rpc, developer_mode, telemetry, personalized_ads,
onboarded,
json(extra_launch_args) extra_launch_args, json(custom_env_vars) custom_env_vars,
@@ -85,6 +86,7 @@ impl Settings {
max_concurrent_downloads: res.max_concurrent_downloads as usize,
max_concurrent_writes: res.max_concurrent_writes as usize,
theme: Theme::from_string(&res.theme),
locale: res.locale,
default_page: DefaultPage::from_string(&res.default_page),
collapsed_navigation: res.collapsed_navigation == 1,
hide_nametag_skins_page: res.hide_nametag_skins_page == 1,
@@ -157,47 +159,49 @@ impl Settings {
max_concurrent_downloads = $2,
theme = $3,
default_page = $4,
collapsed_navigation = $5,
advanced_rendering = $6,
native_decorations = $7,
locale = $4,
default_page = $5,
collapsed_navigation = $6,
advanced_rendering = $7,
native_decorations = $8,
discord_rpc = $8,
developer_mode = $9,
telemetry = $10,
personalized_ads = $11,
discord_rpc = $9,
developer_mode = $10,
telemetry = $11,
personalized_ads = $12,
onboarded = $12,
onboarded = $13,
extra_launch_args = jsonb($13),
custom_env_vars = jsonb($14),
mc_memory_max = $15,
mc_force_fullscreen = $16,
mc_game_resolution_x = $17,
mc_game_resolution_y = $18,
hide_on_process_start = $19,
extra_launch_args = jsonb($14),
custom_env_vars = jsonb($15),
mc_memory_max = $16,
mc_force_fullscreen = $17,
mc_game_resolution_x = $18,
mc_game_resolution_y = $19,
hide_on_process_start = $20,
hook_pre_launch = $20,
hook_wrapper = $21,
hook_post_exit = $22,
hook_pre_launch = $21,
hook_wrapper = $22,
hook_post_exit = $23,
custom_dir = $23,
prev_custom_dir = $24,
migrated = $25,
custom_dir = $24,
prev_custom_dir = $25,
migrated = $26,
toggle_sidebar = $26,
feature_flags = $27,
hide_nametag_skins_page = $28,
toggle_sidebar = $27,
feature_flags = $28,
hide_nametag_skins_page = $29,
skipped_update = $29,
pending_update_toast_for_version = $30,
auto_download_updates = $31,
skipped_update = $30,
pending_update_toast_for_version = $31,
auto_download_updates = $32,
version = $32
version = $33
",
max_concurrent_writes,
max_concurrent_downloads,
theme,
self.locale,
default_page,
self.collapsed_navigation,
self.advanced_rendering,

View File

@@ -1,12 +1,15 @@
//! Functions for fetching information from the Internet
use super::io::{self, IOError};
use crate::ErrorKind;
use crate::LAUNCHER_USER_AGENT;
use crate::event::LoadingBarId;
use crate::event::emit::emit_loading;
use bytes::Bytes;
use chrono::{DateTime, TimeDelta, Utc};
use parking_lot::Mutex;
use rand::Rng;
use reqwest::Method;
use serde::de::DeserializeOwned;
use std::collections::VecDeque;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::sync::LazyLock;
@@ -19,10 +22,120 @@ pub struct IoSemaphore(pub Semaphore);
#[derive(Debug)]
pub struct FetchSemaphore(pub Semaphore);
struct FetchFence {
inner: Mutex<FenceInner>,
}
impl FetchFence {
pub fn is_blocked(&self) -> bool {
self.inner.lock().is_blocked()
}
pub fn record_ok(&self) {
self.inner.lock().record_ok()
}
pub fn record_fail(&self) {
self.inner.lock().record_fail()
}
}
struct FenceInner {
failures: VecDeque<DateTime<Utc>>,
block_until: Option<DateTime<Utc>>,
block_factor: i32,
}
impl FenceInner {
const FAILURE_WINDOW: TimeDelta = TimeDelta::minutes(3);
const FAILURE_THRESHOLD: usize = 4;
const BLOCK_DURATION_MIN_BASE: TimeDelta = TimeDelta::minutes(2);
const BLOCK_DURATION_MAX_BASE: TimeDelta = TimeDelta::minutes(5);
const BLOCK_DURATION_MAX_FACTOR: i32 = 3;
pub fn new() -> Self {
Self {
failures: VecDeque::new(),
block_until: None,
block_factor: 0,
}
}
pub fn is_blocked(&mut self) -> bool {
if let Some(until) = self.block_until {
if until > Utc::now() {
return true;
} else {
self.block_until = None;
}
}
false
}
pub fn record_ok(&mut self) {
self.prune(Utc::now());
}
pub fn record_fail(&mut self) {
self.prune(Utc::now());
self.failures.push_back(Utc::now());
if self.failures.len() >= Self::FAILURE_THRESHOLD {
self.trigger_block();
}
}
/// Blocks further requests for a random duration between the min and max base durations, scaled by a factor
/// of how many blocks have been triggered in this session.
///
/// As such, for the first block, the duration will be between 2 and 5 minutes.
/// - For the second block, between 4 and 10 minutes.
/// - For the third block and any further blocks, between 6 and 15 minutes.
fn trigger_block(&mut self) {
self.block_factor =
i32::min(self.block_factor + 1, Self::BLOCK_DURATION_MAX_FACTOR);
let min = Self::BLOCK_DURATION_MIN_BASE
.checked_mul(self.block_factor)
.unwrap_or(Self::BLOCK_DURATION_MIN_BASE);
let max = Self::BLOCK_DURATION_MAX_BASE
.checked_mul(self.block_factor)
.unwrap_or(Self::BLOCK_DURATION_MAX_BASE);
let delta_seconds = (max - min).as_seconds_f64()
* rand::thread_rng().gen_range(0.0..=1.0);
let duration =
min + TimeDelta::milliseconds((delta_seconds * 1000.0) as i64);
self.block_until = Some(Utc::now() + duration);
}
/// Removes all failure points older than the failure window
fn prune(&mut self, now: DateTime<Utc>) {
let cutoff = now - Self::FAILURE_WINDOW;
while let Some(&front) = self.failures.front() {
if front < cutoff {
self.failures.pop_front();
} else {
break;
}
}
}
}
static GLOBAL_FETCH_FENCE: LazyLock<FetchFence> =
LazyLock::new(|| FetchFence {
inner: Mutex::new(FenceInner::new()),
});
pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
let mut headers = reqwest::header::HeaderMap::new();
let header =
reqwest::header::HeaderValue::from_str(LAUNCHER_USER_AGENT).unwrap();
reqwest::header::HeaderValue::from_str(&crate::launcher_user_agent())
.unwrap();
headers.insert(reqwest::header::USER_AGENT, header);
reqwest::Client::builder()
.tcp_keepalive(Some(time::Duration::from_secs(10)))
@@ -30,7 +143,8 @@ pub static REQWEST_CLIENT: LazyLock<reqwest::Client> = LazyLock::new(|| {
.build()
.expect("Reqwest Client Building Failed")
});
const FETCH_ATTEMPTS: usize = 3;
const FETCH_ATTEMPTS: usize = 2;
#[tracing::instrument(skip(semaphore))]
pub async fn fetch(
@@ -78,12 +192,13 @@ pub async fn fetch_advanced(
) -> crate::Result<Bytes> {
let _permit = semaphore.0.acquire().await?;
let is_api_url = url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3"));
let creds = if header
.as_ref()
.is_none_or(|x| &*x.0.to_lowercase() != "authorization")
&& (url.starts_with("https://cdn.modrinth.com")
|| url.starts_with(env!("MODRINTH_API_URL"))
|| url.starts_with(env!("MODRINTH_API_URL_V3")))
&& (url.starts_with("https://cdn.modrinth.com") || is_api_url)
{
crate::state::ModrinthCredentials::get_active(exec).await?
} else {
@@ -91,6 +206,10 @@ pub async fn fetch_advanced(
};
for attempt in 1..=(FETCH_ATTEMPTS + 1) {
if is_api_url && GLOBAL_FETCH_FENCE.is_blocked() {
return Err(ErrorKind::ApiIsDownError.into());
}
let mut req = REQWEST_CLIENT.request(method.clone(), url);
if let Some(body) = json_body.clone() {
@@ -108,10 +227,16 @@ pub async fn fetch_advanced(
let result = req.send().await;
match result {
Ok(resp) => {
if resp.status().is_server_error() && attempt <= FETCH_ATTEMPTS
{
continue;
if resp.status().is_server_error() {
if is_api_url {
GLOBAL_FETCH_FENCE.record_fail();
}
if attempt <= FETCH_ATTEMPTS {
continue;
}
}
if resp.status().is_client_error()
|| resp.status().is_server_error()
{
@@ -166,6 +291,11 @@ pub async fn fetch_advanced(
}
tracing::trace!("Done downloading URL {url}");
if is_api_url {
GLOBAL_FETCH_FENCE.record_ok();
}
return Ok(bytes);
} else if attempt <= FETCH_ATTEMPTS {
continue;
@@ -325,3 +455,124 @@ pub async fn sha1_async(bytes: Bytes) -> crate::Result<String> {
Ok(hash)
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{TimeDelta, Utc};
#[test]
fn test_fence_block_after_4_fails() {
// Update tests if the FenceInner constants change
let mut fence = FenceInner::new();
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(fence.is_blocked());
}
#[test]
fn test_fence_block_after_4_fails_with_oks() {
// Update tests if the FenceInner constants change
let mut fence = FenceInner::new();
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_ok();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(fence.is_blocked());
}
#[test]
fn test_fence_not_blocked_after_fails_expire() {
// Update tests if the FenceInner constants change
let mut fence = FenceInner::new();
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.prune(Utc::now() + TimeDelta::seconds(60 * 3 + 55)); // Should prune all failures
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(!fence.is_blocked());
fence.record_fail();
assert!(fence.is_blocked());
}
#[test]
fn test_fence_trigger_block_windows() {
// brute force flukes
for i in 0..128 {
let mut fence = FenceInner::new();
fence.trigger_block();
assert!(fence.is_blocked(), "Should be blocked (attempt {i})");
let block_until = fence.block_until.unwrap();
assert!(
block_until > Utc::now() + TimeDelta::seconds(60 + 55),
"Should be more than 2 minutes (with some leeway) (attempt {i})"
); // more than 2 minutes (with some leeway)
assert!(
block_until < Utc::now() + TimeDelta::seconds(60 * 5),
"Should be less than 5 minutes (attempt {i})"
); // less than 5 minutes
fence.block_until = None;
fence.trigger_block();
let block_until = fence.block_until.unwrap();
assert!(
block_until > Utc::now() + TimeDelta::seconds(60 * 3 + 55),
"Should be more than 4 minutes (with some leeway) (attempt {i})"
); // more than 4 minutes (with some leeway)
assert!(
block_until < Utc::now() + TimeDelta::seconds(60 * 10),
"Should be less than 10 minutes (attempt {i})"
); // less than 10 minutes
fence.block_until = None;
fence.trigger_block();
let block_until = fence.block_until.unwrap();
assert!(
block_until > Utc::now() + TimeDelta::seconds(60 * 5 + 55),
"Should be more than 6 minutes (with some leeway) (attempt {i})"
); // more than 6 minutes (with some leeway)
assert!(
block_until < Utc::now() + TimeDelta::seconds(60 * 15),
"Should be less than 15 minutes (attempt {i})"
); // less than 15 minutes
}
}
}