fix(core): bmclapi 库镜像补 /maven;install 进度行改说实话

D1(high):mirrors.json 的 bmclapi-libraries 少了 inject,官方
libraries.minecraft.net/<path> 被重写成镜像根 + <path>(BMCLAPI 的 maven 仓库挂在
/maven 下),实测 70/70 个库全 404、全部回落官方源(单请求 EMA 6.3 s)。本轮实测:
guava:32.1.2-jre 无 /maven → 404 / 9 B;带 /maven → 200(2 次 302)/ 3041591 B,
sha1 与官方逐字节一致。补法与 bmclapi-assets-objects 的 /assets 同一机制(inject),
不把 /maven 拼进 to(to 是镜像根)。

守卫两道,抓不同的东西:
- 形态:凡 from 指向 maven 仓库的规则,to+inject 必须以 /maven 结尾(并点名覆盖的
  5 条规则 id,删/改名都会红);
- 行为:4 个真实官方 maven 根展开后必须逐字等于 /maven/<子路径>。
原有 prefix_rule_preserves_sub_path 的期望值曾把 D1 的 bug 钉成期望,一并改正。
变异(删掉 inject)实测三条同时红,恢复后 mirrors 14 passed。

D11(low):进度回调只累加「处理到第几个文件」,还打「下载 {done}/{total}」,
重装同版本(全部已就绪)时会连打 3961 行「下载」而一个字节都没下。拆出
install/progress.rs:计数全部来自 FileOutcome.status(Downloaded/Skipped/Failed),
下载数为 0 时人读行不出现「下载」二字(改说「已就绪 d/t(跳过 N 个)」),--json 的
progress 事件补 downloaded/skipped/failed。用例构造 96 个全 Skipped ⇒ 断言每行都不含
「下载」且末行为「已就绪 96/96(跳过 96 个)」;变异(退回旧文本)实测红。

其它镜像表(assets / jar / version_manifest / 索引 / fabric / forge)的 inject 形状
实测一致,另有 liteloader 目标路径 404、quilt maven 无规则两条【不同类】观察,
不在本轮范围,报告里交回 tower。
This commit is contained in:
AskaEth committed 2026-11-09 13:57:32 +08:00
1 parent 391ebf88b7
commit d8ca411117
8 files changed
+414 -81

No files matched your search

+3 -3
View File
@@ -26,8 +26,8 @@ use unlucky_core::game::install::execute;
use super::report_loader::ProcessorSection;
use super::{
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error,
register_instance, report, report_loader, vanilla, CONCURRENCY,
build_engine, ensure_name_free, failure_error, loader_processors, map_install_error, progress,
register_instance, report_loader, vanilla, CONCURRENCY,
};
use crate::cli::InstallArgs;
use crate::context::Ctx;
@@ -119,7 +119,7 @@ pub(super) fn run(ctx: &Ctx, args: &InstallArgs, kind: LoaderKind) -> Result<Rep
}
}
let sink = report::progress_sink(args.progress, args.json_progress, plan.files().len());
let sink = progress::progress_sink(args.progress, args.json_progress, plan.files().len());
let install_report = block_on(execute(&engine, &plan.install, CONCURRENCY, sink))
.map_err(|error| map_install_error(&error, ctx))?;
if !install_report.is_complete() {
@@ -25,7 +25,7 @@ use unlucky_core::download::DownloadEngine;
use unlucky_core::game::arguments::Environment;
use unlucky_core::game::install::InstallReport;
use super::{failure_error, report, CONCURRENCY};
use super::{failure_error, progress, CONCURRENCY};
use crate::cli::InstallArgs;
use crate::context::Ctx;
use crate::error::CliError;
@@ -86,7 +86,7 @@ pub(super) fn execute(
plan: &ProcessorPlan,
args: &InstallArgs,
) -> Result<(InstallReport, ProcessorRun), CliError> {
let sink = report::progress_sink(args.progress, args.json_progress, plan.libraries.len());
let sink = progress::progress_sink(args.progress, args.json_progress, plan.libraries.len());
let libraries = block_on(processors::download_libraries(
engine,
&plan.libraries,
+1
View File
@@ -12,6 +12,7 @@
mod loader;
mod loader_processors;
mod progress;
mod report;
mod report_loader;
mod vanilla;
+300
View File
@@ -0,0 +1,300 @@
//! stderr 实时进度(原版安装 / 加载器库 / processor 依赖库三段共用)。
//!
//! **M210/D11 的教训**:进度行的数字必须是**已经发生的事实**(每个文件的 [`FileStatus`]),
//! 不能拿「处理到第几个文件」冒充「下载了几个」。旧实现在回调里只累加一个 `done` 就打印
//! `下载 {done}/{total}`,于是重装同版本(3961 个文件全部已就绪、一个字节都没下)时会连打
//! `下载 32/3961 … 3961/3961`——进度条在跑,下载量是 0,用户在读一句假话。
//!
//! 现在分三个计数(下载 / 跳过 / 失败),人读行的措辞随计数走:下载数为 0 时**不出现「下载」
//! 二字**,改说「已就绪 N/M(跳过 N 个)」。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use unlucky_core::game::install::{FileOutcome, FileStatus, ProgressSink};
use crate::output::progress_line;
/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。
const PROGRESS_EVERY: usize = 32;
/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。
const MAX_PROGRESS_FAILURES: usize = 5;
/// 一次进度快照:每个字段都是**真实结局**的计数。
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(crate) struct Snapshot {
/// 已处理文件数(含跳过与失败)。
pub done: usize,
/// 计划文件总数。
pub total: usize,
/// 本次真的下载并落盘的文件数。
pub downloaded: usize,
/// 已存在且校验通过、本次跳过的文件数。
pub skipped: usize,
/// 失败文件数。
pub failed: usize,
}
impl Snapshot {
/// 人读进度行。
///
/// 下载数为 0 时**不提「下载」**:本次一个字节都没下,再写「下载 0/N」也只是换个说法的假进度。
pub(crate) fn text(&self) -> String {
let failure = if self.failed > 0 {
format!(" / 失败 {}", self.failed)
} else {
String::new()
};
if self.downloaded == 0 {
format!(
"已就绪 {}/{}(跳过 {} 个{failure})",
self.done, self.total, self.skipped
)
} else {
format!(
"下载 {} 个 / 跳过 {} 个(进度 {}/{}{failure})",
self.downloaded, self.skipped, self.done, self.total
)
}
}
/// `--json` 模式的进度事件(字段名稳定,`done`/`total` 保持旧名以兼容脚本)。
fn json(&self) -> serde_json::Value {
serde_json::json!({
"event": "progress",
"done": self.done,
"total": self.total,
"downloaded": self.downloaded,
"skipped": self.skipped,
"failed": self.failed,
})
}
}
/// 计数:回调来自多个下载线程,全部原子累加。
#[derive(Debug, Default)]
struct Tally {
done: AtomicUsize,
downloaded: AtomicUsize,
skipped: AtomicUsize,
failed: AtomicUsize,
failure_lines: AtomicUsize,
}
impl Tally {
/// 记一个结局,返回记完之后的快照。
fn observe(&self, outcome: &FileOutcome, total: usize) -> Snapshot {
let done = self.done.fetch_add(1, Ordering::Relaxed) + 1;
match outcome.status {
FileStatus::Downloaded => {
self.downloaded.fetch_add(1, Ordering::Relaxed);
}
FileStatus::Skipped => {
self.skipped.fetch_add(1, Ordering::Relaxed);
}
FileStatus::Failed => {
self.failed.fetch_add(1, Ordering::Relaxed);
}
}
Snapshot {
done,
total,
downloaded: self.downloaded.load(Ordering::Relaxed),
skipped: self.skipped.load(Ordering::Relaxed),
failed: self.failed.load(Ordering::Relaxed),
}
}
}
/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。
///
/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流),
/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。
///
/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。
pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option<ProgressSink> {
sink_with(enabled, json, total, Arc::new(progress_line))
}
/// 可注入输出端的构造器(测试据此抓行;生产只有一个调用者 [`progress_sink`])。
fn sink_with(
enabled: bool,
json: bool,
total: usize,
out: Arc<dyn Fn(&str) + Send + Sync>,
) -> Option<ProgressSink> {
if !enabled {
return None;
}
let tally = Arc::new(Tally::default());
Some(Arc::new(move |outcome: &FileOutcome| {
let snapshot = tally.observe(outcome, total);
if let Some(failure) = &outcome.failure {
let seen = tally.failure_lines.fetch_add(1, Ordering::Relaxed);
if seen < MAX_PROGRESS_FAILURES {
emit(
json,
serde_json::json!({
"event": "file_failed",
"done": snapshot.done,
"total": total,
"name": outcome.name,
"role": outcome.role.as_str(),
"kind": failure.kind.as_str(),
"message": failure.message,
}),
&format!(
"失败 {}/{} {}:{}",
snapshot.done, total, outcome.name, failure.message
),
&out,
);
} else if seen == MAX_PROGRESS_FAILURES {
emit(
json,
serde_json::json!({
"event": "progress_truncated",
"suppressed_after": MAX_PROGRESS_FAILURES,
}),
"(更多失败不再逐条打印,见最终错误汇总)",
&out,
);
}
return;
}
if snapshot.done.is_multiple_of(PROGRESS_EVERY) || snapshot.done == total {
emit(json, snapshot.json(), &snapshot.text(), &out);
}
}))
}
/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。
fn emit(
json_mode: bool,
record: serde_json::Value,
text: &str,
out: &Arc<dyn Fn(&str) + Send + Sync>,
) {
if json_mode {
let line = record.to_string();
out(&line);
} else {
out(text);
}
}
#[cfg(test)]
mod tests {
use std::sync::Mutex;
use unlucky_core::download::DownloadErrorKind;
use unlucky_core::game::install::{FileFailure, FileRole};
use super::*;
/// 抓行的输出端。
fn recorder() -> (Arc<dyn Fn(&str) + Send + Sync>, Arc<Mutex<Vec<String>>>) {
let lines = Arc::new(Mutex::new(Vec::new()));
let sink_lines = Arc::clone(&lines);
let out: Arc<dyn Fn(&str) + Send + Sync> =
Arc::new(move |line: &str| sink_lines.lock().expect("锁").push(line.to_string()));
(out, lines)
}
fn outcome(name: &str, status: FileStatus) -> FileOutcome {
FileOutcome {
role: FileRole::Asset,
name: name.to_string(),
dest: std::path::PathBuf::from(format!("assets/{name}")),
status,
bytes: 1234,
source: Some("cache".to_string()),
failure: None,
}
}
/// **D11 用例**:全部已存在(重装同版本)⇒ 进度行里不出现「下载」,下载计数为 0。
///
/// 抓得住:把「处理序号」当下载量打印的旧实现(它会打出 `下载 32/96` 这类行);
/// 以及任何「下载数 > 0 却说没下载」的表述。抓不住:行里出现的其它夸张词(措辞审查),
/// 也不管单行是否真的落到 stderr(那是 [`crate::output::progress_line`] 的事)。
#[test]
fn all_skipped_progress_never_claims_downloads() {
const TOTAL: usize = 96; // 3 的倍数,必然跨过 PROGRESS_EVERY
let (out, lines) = recorder();
let sink = sink_with(true, false, TOTAL, out).expect("进度开关打开时必须有 sink");
for index in 0..TOTAL {
sink(&outcome(&format!("asset-{index}"), FileStatus::Skipped));
}
let lines = lines.lock().expect("锁").clone();
assert!(!lines.is_empty(), "应当有进度行");
for line in &lines {
assert!(
!line.contains("下载"),
"全部跳过时进度行不许提「下载」:{line}"
);
}
let last = lines.last().expect("至少一行");
assert!(
last.contains(&format!("已就绪 {TOTAL}/{TOTAL}"))
&& last.contains(&format!("跳过 {TOTAL}")),
"全跳过应当说「已就绪 N/N(跳过 N 个)」:{last}"
);
}
/// 同一场景的 `--json` 形态:`downloaded` 必须是 0(脚本据此判断「其实没下」)。
#[test]
fn all_skipped_json_progress_reports_zero_downloads() {
const TOTAL: usize = 32;
let (out, lines) = recorder();
let sink = sink_with(true, true, TOTAL, out).expect("进度开关打开时必须有 sink");
for index in 0..TOTAL {
sink(&outcome(&format!("lib-{index}"), FileStatus::Skipped));
}
let records: Vec<serde_json::Value> = lines
.lock()
.expect("锁")
.iter()
.map(|line| serde_json::from_str(line).expect("JSONL"))
.collect();
let progress = records
.iter()
.find(|record| record["event"] == "progress")
.expect("应当有 progress 事件");
assert_eq!(progress["downloaded"], 0);
assert_eq!(progress["skipped"], TOTAL);
assert_eq!(progress["done"], TOTAL);
assert_eq!(progress["total"], TOTAL);
}
/// 真的有下载时,计数与措辞都跟着事实走(避免「一刀切不打印」这种假修复)。
#[test]
fn mixed_outcomes_report_real_counts() {
let (out, lines) = recorder();
let sink = sink_with(true, false, 4, out).expect("sink");
sink(&outcome("client.jar", FileStatus::Downloaded));
let failed = FileOutcome {
failure: Some(FileFailure {
kind: DownloadErrorKind::Network,
message: "连接被重置".to_string(),
}),
..outcome("b", FileStatus::Failed)
};
sink(&failed);
sink(&outcome("c", FileStatus::Skipped));
sink(&outcome("d", FileStatus::Skipped));
let lines = lines.lock().expect("锁").clone();
// 失败有自己的行(`失败 2/4 b:…`),进度行不拿它冒充下载量。
assert!(
lines.iter().any(|line| line.starts_with("失败 2/4 b")),
"{lines:?}"
);
let last = lines.last().expect("至少一行");
// done == total 时无条件打最后一行。
assert!(last.contains("下载 1 个"), "{last}");
assert!(last.contains("跳过 2 个"), "{last}");
assert!(last.contains("失败 1"), "{last}");
assert!(last.contains("进度 4/4"), "{last}");
}
}
+3 -72
View File
@@ -1,84 +1,15 @@
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷,外加 stderr 实时进度。
//! 安装结果的两种呈现形态:人类可读行 + `--json` 载荷(stderr 实时进度见 [`super::progress`])。
//!
//! 拆出来是因为 `install` 的流程(规划 → 下载 → 登记)与排版彼此独立:
//! 前者要能读懂,后者要能被脚本断言。`--dry-run` 与真实安装共用同一份排版,
//! 差别只是多出逐文件清单与源选择。
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::Arc;
use serde_json::json;
use unlucky_core::download::DownloadEngine;
use unlucky_core::game::install::{
FileOutcome, InstallPlan, InstallReport, ProgressSink, Registration,
};
use unlucky_core::game::install::{FileOutcome, InstallPlan, InstallReport, Registration};
use crate::context::Ctx;
use crate::output::{progress_line, Report};
/// 每处理多少个文件打一行进度(3000 个资源下约 90 行,够用又不刷屏)。
const PROGRESS_EVERY: usize = 32;
/// 进度里最多逐条打印几条失败(其余交给最终错误汇总)。
const MAX_PROGRESS_FAILURES: usize = 5;
/// 实时进度回调(只写 stderr,不污染 `--json` 的 stdout)。
///
/// 两种形态:人类模式打文本行;`--json` 模式打 **JSONL**(一行一条事件,`event` 字段分流),
/// 这样脚本既能吃 stdout 的最终信封,也能实时跟进而不必解析文本。
///
/// 失败行只打前几条:整源不可达时会一口气失败上千个文件,全打出来既没用又淹没原因。
pub fn progress_sink(enabled: bool, json: bool, total: usize) -> Option<ProgressSink> {
if !enabled {
return None;
}
let done = Arc::new(AtomicUsize::new(0));
let failures = Arc::new(AtomicUsize::new(0));
Some(Arc::new(move |outcome: &FileOutcome| {
let index = done.fetch_add(1, Ordering::Relaxed) + 1;
if let Some(failure) = &outcome.failure {
let seen = failures.fetch_add(1, Ordering::Relaxed);
if seen < MAX_PROGRESS_FAILURES {
emit(
json,
json!({
"event": "file_failed",
"done": index,
"total": total,
"name": outcome.name,
"role": outcome.role.as_str(),
"kind": failure.kind.as_str(),
"message": failure.message,
}),
&format!("失败 {index}/{total} {}:{}", outcome.name, failure.message),
);
} else if seen == MAX_PROGRESS_FAILURES {
emit(
json,
json!({"event": "progress_truncated", "suppressed_after": MAX_PROGRESS_FAILURES}),
"(更多失败不再逐条打印,见最终错误汇总)",
);
}
return;
}
if index.is_multiple_of(PROGRESS_EVERY) || index == total {
emit(
json,
json!({"event": "progress", "done": index, "total": total}),
&format!("下载 {index}/{total}"),
);
}
}))
}
/// 一条进度:JSONL 模式打序列化后的一行,人类模式打短文本。
fn emit(json_mode: bool, record: serde_json::Value, text: &str) {
if json_mode {
progress_line(&record.to_string());
} else {
progress_line(text);
}
}
use crate::output::Report;
/// 排版一次安装结果。
#[allow(clippy::too_many_arguments)]
+2 -2
View File
@@ -21,7 +21,7 @@ use unlucky_core::game::install::{
};
use super::{
build_engine, ensure_name_free, failure_error, map_install_error, report, CONCURRENCY,
build_engine, ensure_name_free, failure_error, map_install_error, progress, report, CONCURRENCY,
};
use crate::cli::InstallArgs;
use crate::context::Ctx;
@@ -142,7 +142,7 @@ pub(super) fn plan_and_install(
"旧版(pre-1.6)清单兜底待补;可先用官方启动器装好该版本再用 `%prog% launch`",
));
}
let sink = report::progress_sink(progress, json_progress, plan.file_count());
let sink = progress::progress_sink(progress, json_progress, plan.file_count());
let report = execute(engine, &plan, CONCURRENCY, sink).await?;
Ok(Attempt {
plan,