mirror of
https://github.com/Yuyi-Oak/BlueArchiveToolkit.git
synced 2026-09-18 06:34:54 +08:00
fix(sync): 移除多线程下载并补齐staging复用回归
This commit is contained in:
@@ -4471,8 +4471,6 @@ fn daemon_child_args(options: &CliOptions) -> Vec<String> {
|
||||
}
|
||||
args.push("--curl".to_string());
|
||||
args.push(config.curl_command.to_string_lossy().to_string());
|
||||
args.push("--download-concurrency".to_string());
|
||||
args.push(config.download_concurrency.to_string());
|
||||
match config.curl_proxy.mode() {
|
||||
CurlProxyMode::Auto if options.proxy_option_explicit => {
|
||||
args.push("--proxy".to_string());
|
||||
@@ -4959,9 +4957,6 @@ BAT_AUTO_DISCOVER=1
|
||||
# 正常检查间隔与失败重试间隔(秒)
|
||||
#BAT_INTERVAL_SECONDS=3600
|
||||
#BAT_ERROR_RETRY_SECONDS=60
|
||||
# 下载并发度(并行网络下载数,1~256;默认 4,对官方 CDN 礼貌)
|
||||
#BAT_DOWNLOAD_CONCURRENCY=4
|
||||
|
||||
# ---- 网络 ----
|
||||
# 显式代理 URL(支持 http/https/socks4/socks4a/socks5/socks5h)。
|
||||
# 不设则自动检测 HTTPS_PROXY / ALL_PROXY / HTTP_PROXY(也可写在本文件里)。
|
||||
@@ -5145,9 +5140,6 @@ fn apply_bat_env_overrides(
|
||||
if let Some(v) = value("BAT_UNZIP") {
|
||||
options.config.unzip_command = PathBuf::from(v);
|
||||
}
|
||||
if let Some(v) = value("BAT_DOWNLOAD_CONCURRENCY") {
|
||||
options.config.download_concurrency = parse_download_concurrency(&v)?;
|
||||
}
|
||||
if let Some(v) = value("BAT_PROXY") {
|
||||
options.config.curl_proxy = parse_proxy_config(&v)?;
|
||||
}
|
||||
@@ -5325,11 +5317,6 @@ fn parse_args_with_env(
|
||||
"--unzip" => {
|
||||
options.config.unzip_command = PathBuf::from(next_option_value(&mut args, &flag)?);
|
||||
}
|
||||
"--download-concurrency" => {
|
||||
let value = next_option_value(&mut args, &flag)?;
|
||||
options.config.download_concurrency = parse_download_concurrency(&value)?;
|
||||
options.sync_option_explicit = true;
|
||||
}
|
||||
"--dry-run" => {
|
||||
options.config.dry_run = true;
|
||||
options.sync_option_explicit = true;
|
||||
@@ -5668,7 +5655,6 @@ fn print_usage(binary: &str) {
|
||||
eprintln!(" --proxy <URL|auto|none> curl proxy override (default: auto from env)");
|
||||
eprintln!(" --no-proxy Force direct curl connections");
|
||||
eprintln!(" --unzip <PATH> unzip executable (default: unzip)");
|
||||
eprintln!(" --download-concurrency <N> Parallel downloads, 1..=256 (default: 4)");
|
||||
eprintln!(" --dry-run Do not write sync state");
|
||||
eprintln!(" --plan Include planned URLs in dry-run");
|
||||
eprintln!(" --force Force download/refresh");
|
||||
@@ -5743,17 +5729,6 @@ fn parse_platforms(value: &str) -> Result<Vec<PatchPlatform>, String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 解析下载并发度:正整数,钳制到 `1..=256`。
|
||||
fn parse_download_concurrency(value: &str) -> anyhow::Result<usize> {
|
||||
let parsed = value
|
||||
.parse::<usize>()
|
||||
.map_err(|error| anyhow::anyhow!("下载并发度无效:{error}"))?;
|
||||
if parsed == 0 {
|
||||
return Err(anyhow::anyhow!("下载并发度必须大于 0"));
|
||||
}
|
||||
Ok(parsed.min(256))
|
||||
}
|
||||
|
||||
fn parse_platform(value: &str) -> Result<PatchPlatform, String> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"windows" | "win" => Ok(PatchPlatform::Windows),
|
||||
@@ -6036,51 +6011,6 @@ mod tests {
|
||||
assert_eq!(registry.get("task-1-2").unwrap().status, "succeeded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_download_concurrency_flag_and_env() {
|
||||
// 默认值(与 OfficialUpdateConfig 默认一致)。
|
||||
assert_eq!(
|
||||
parse(&["bat"]).unwrap().config.download_concurrency,
|
||||
OfficialUpdateConfig::default().download_concurrency
|
||||
);
|
||||
// CLI 显式设置 + 上限钳制。
|
||||
assert_eq!(
|
||||
parse(&["bat", "--download-concurrency", "16"])
|
||||
.unwrap()
|
||||
.config
|
||||
.download_concurrency,
|
||||
16
|
||||
);
|
||||
assert_eq!(
|
||||
parse(&["bat", "--download-concurrency", "9999"])
|
||||
.unwrap()
|
||||
.config
|
||||
.download_concurrency,
|
||||
256
|
||||
);
|
||||
// 0 与非数字报错。
|
||||
assert!(parse(&["bat", "--download-concurrency", "0"]).is_err());
|
||||
assert!(parse(&["bat", "--download-concurrency", "abc"]).is_err());
|
||||
// 环境变量(含 .env)设置默认值;CLI 覆盖之。
|
||||
assert_eq!(
|
||||
parse_with_env(&["bat"], &[("BAT_DOWNLOAD_CONCURRENCY", "12")])
|
||||
.unwrap()
|
||||
.config
|
||||
.download_concurrency,
|
||||
12
|
||||
);
|
||||
assert_eq!(
|
||||
parse_with_env(
|
||||
&["bat", "--download-concurrency", "3"],
|
||||
&[("BAT_DOWNLOAD_CONCURRENCY", "12")]
|
||||
)
|
||||
.unwrap()
|
||||
.config
|
||||
.download_concurrency,
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_auto_discover_sync_args() {
|
||||
let options = parse(&[
|
||||
@@ -6326,8 +6256,6 @@ mod tests {
|
||||
"http://127.0.0.1:7890",
|
||||
"--unzip",
|
||||
"/usr/bin/unzip",
|
||||
"--download-concurrency",
|
||||
"40",
|
||||
"--interval",
|
||||
"30m",
|
||||
"--error-retry",
|
||||
@@ -6350,9 +6278,6 @@ mod tests {
|
||||
assert!(args
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--platforms", "Windows,Android"]));
|
||||
assert!(args
|
||||
.windows(2)
|
||||
.any(|pair| pair == ["--download-concurrency", "40"]));
|
||||
// 代理凭据不得进入子进程 argv:只放不含凭据的 flag,URL 经环境变量下传。
|
||||
assert!(args.contains(&PROXY_FROM_ENV_FLAG.to_string()));
|
||||
assert!(!args.iter().any(|arg| arg.contains("127.0.0.1:7890")));
|
||||
|
||||
@@ -445,8 +445,8 @@ fn retry_backoff(busy: bool, attempt: usize) -> std::time::Duration {
|
||||
/// 退避时长(毫秒)的纯计算,便于独立于 cfg 门控的基值做单测。
|
||||
///
|
||||
/// - `ETXTBSY`(fork/exec 竞态):极短固定退避,只为让兄弟进程完成 execve。
|
||||
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),并发下载
|
||||
/// 时对官方 CDN 更礼貌,避免 N 个连接失败后同时立即重发。
|
||||
/// - 其余网络类可重试失败:指数退避(`base·2^(attempt-1)`,上限 5s),避免
|
||||
/// 连续失败后立即重发。
|
||||
fn backoff_delay_ms(base: u64, busy: bool, attempt: usize) -> u64 {
|
||||
if busy {
|
||||
return 5 * attempt as u64;
|
||||
|
||||
@@ -16,9 +16,6 @@ use std::fs::{self, File};
|
||||
use std::io::Read;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
|
||||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// 官方资源下载错误:携带统一错误码,便于 CLI/RPC 归类。
|
||||
@@ -64,10 +61,6 @@ const DOWNLOAD_QUARANTINE_FILE: &str = "official-download-quarantine.json";
|
||||
const DOWNLOAD_MANIFEST_VERSION: u32 = 1;
|
||||
const DOWNLOAD_QUARANTINE_VERSION: u32 = 1;
|
||||
const DEFAULT_RETRY_ATTEMPTS: usize = 3;
|
||||
/// 默认下载并发度。保守取值,对官方 CDN 礼貌;可经配置调到 1..=256。
|
||||
pub(crate) const DEFAULT_DOWNLOAD_CONCURRENCY: usize = 4;
|
||||
/// 下载并发度上限。
|
||||
const MAX_DOWNLOAD_CONCURRENCY: usize = 256;
|
||||
|
||||
/// Outcome for one official resource pull item.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
@@ -493,7 +486,6 @@ pub struct OfficialResourcePullService {
|
||||
curl_command: PathBuf,
|
||||
curl_proxy: CurlProxyConfig,
|
||||
retry_attempts: usize,
|
||||
download_concurrency: usize,
|
||||
}
|
||||
|
||||
impl OfficialResourcePullService {
|
||||
@@ -512,7 +504,6 @@ impl OfficialResourcePullService {
|
||||
curl_command: curl_command.into(),
|
||||
curl_proxy: CurlProxyConfig::default(),
|
||||
retry_attempts: DEFAULT_RETRY_ATTEMPTS,
|
||||
download_concurrency: DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -528,17 +519,6 @@ impl OfficialResourcePullService {
|
||||
self
|
||||
}
|
||||
|
||||
/// 设置下载并发度(并行执行的网络下载数),钳制到 `1..=256`。
|
||||
pub fn with_download_concurrency(mut self, concurrency: usize) -> Self {
|
||||
self.download_concurrency = concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
|
||||
self
|
||||
}
|
||||
|
||||
/// 返回当前下载并发度。
|
||||
pub fn download_concurrency(&self) -> usize {
|
||||
self.download_concurrency
|
||||
}
|
||||
|
||||
/// Returns the output root used for downloaded files.
|
||||
pub fn output_root(&self) -> &Path {
|
||||
&self.output_root
|
||||
@@ -625,11 +605,8 @@ impl OfficialResourcePullService {
|
||||
});
|
||||
}
|
||||
|
||||
// Phase B:并发下载 need-download 项(各 URL 目标/`.part` 相互独立,
|
||||
// 天然可并行)。worker 只做只读 `&self` 的网络下载,经 mpsc 把结果送回
|
||||
// 主线程;manifest/quarantine 簿记与进度回调全部在主线程串行完成,无需
|
||||
// 加锁。首个失败或 `should_cancel` 会置 cancel 标志,其余 worker 在下一
|
||||
// 个任务边界停止,保持 fail-fast 与「不发布不完整资源」不变量。
|
||||
// Phase B:顺序下载 need-download 项。每个 URL 的目标和 `.part` 都是独立
|
||||
// 的,但这里保留单线程执行,便于维持稳定进度、稳定日志和简单的失败恢复。
|
||||
let download_indices: Vec<usize> = planned
|
||||
.iter()
|
||||
.enumerate()
|
||||
@@ -640,113 +617,68 @@ impl OfficialResourcePullService {
|
||||
(0..planned.len()).map(|_| None).collect();
|
||||
let mut completed_count = 0usize;
|
||||
|
||||
if !download_indices.is_empty() {
|
||||
let concurrency = self.download_concurrency.clamp(1, MAX_DOWNLOAD_CONCURRENCY);
|
||||
let cursor = AtomicUsize::new(0);
|
||||
let cancel = AtomicBool::new(false);
|
||||
let (sender, receiver) = mpsc::channel::<WorkerMessage>();
|
||||
|
||||
thread::scope(|scope| {
|
||||
for _ in 0..concurrency.min(download_indices.len()) {
|
||||
let sender = sender.clone();
|
||||
let cursor = &cursor;
|
||||
let cancel = &cancel;
|
||||
let download_indices = &download_indices;
|
||||
let planned = &planned;
|
||||
let service = &*self;
|
||||
scope.spawn(move || loop {
|
||||
if cancel.load(Ordering::Relaxed) {
|
||||
break;
|
||||
}
|
||||
let slot = cursor.fetch_add(1, Ordering::Relaxed);
|
||||
let Some(&plan_index) = download_indices.get(slot) else {
|
||||
break;
|
||||
};
|
||||
let item = &planned[plan_index];
|
||||
if sender.send(WorkerMessage::Started { plan_index }).is_err() {
|
||||
break;
|
||||
}
|
||||
let result = service.pull_one(&item.url, &item.destination);
|
||||
if result.is_err() {
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
if sender
|
||||
.send(WorkerMessage::Done { plan_index, result })
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
});
|
||||
}
|
||||
// 主线程持有的 sender 副本必须丢弃,否则 receiver 永不结束。
|
||||
drop(sender);
|
||||
|
||||
for message in receiver {
|
||||
match message {
|
||||
WorkerMessage::Started { plan_index } => {
|
||||
let item = &planned[plan_index];
|
||||
progress(OfficialResourcePullProgress::started(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
));
|
||||
// 停止请求:置 cancel,让 worker 在下个任务边界退出。
|
||||
if should_cancel() {
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
WorkerMessage::Done { plan_index, result } => {
|
||||
if let Ok(pull_result) = &result {
|
||||
let item = &planned[plan_index];
|
||||
// manifest/quarantine 簿记在主线程串行执行。
|
||||
if let Err(error) = self
|
||||
.clear_quarantine_entry(&item.url)
|
||||
.and_then(|_| {
|
||||
self.record_download_manifest_entry(
|
||||
&mut manifest,
|
||||
&item.url,
|
||||
&item.destination,
|
||||
)
|
||||
})
|
||||
.and_then(|_| self.write_download_manifest(&manifest))
|
||||
{
|
||||
// 簿记失败:记为该项错误并触发 fail-fast。
|
||||
cancel.store(true, Ordering::Relaxed);
|
||||
download_results[plan_index] = Some(Err(PullOneError::plain(
|
||||
format!("记录下载 manifest 失败:{error}"),
|
||||
)));
|
||||
continue;
|
||||
}
|
||||
completed_count += 1;
|
||||
progress(OfficialResourcePullProgress::finished(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
pull_result.status,
|
||||
pull_result.bytes,
|
||||
pull_result.transferred_bytes,
|
||||
));
|
||||
}
|
||||
download_results[plan_index] = Some(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if should_cancel() && download_results.iter().any(Option::is_none) {
|
||||
for &plan_index in &download_indices {
|
||||
let item = &planned[plan_index];
|
||||
progress(OfficialResourcePullProgress::started(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
));
|
||||
if should_cancel() {
|
||||
return Err("官方资源拉取已被停止请求中断".to_string().into());
|
||||
}
|
||||
|
||||
// fail-fast:按 plan 顺序取首个失败项,记 quarantine、发 failed 进度并中止。
|
||||
for &plan_index in &download_indices {
|
||||
if let Some(Some(Err(error))) = download_results.get(plan_index) {
|
||||
let item = &planned[plan_index];
|
||||
self.record_quarantine_entry(&item.url, &item.destination, error)?;
|
||||
let result = self.pull_one(&item.url, &item.destination);
|
||||
match result {
|
||||
Ok(pull_result) => {
|
||||
if let Err(error) = self
|
||||
.clear_quarantine_entry(&item.url)
|
||||
.and_then(|_| {
|
||||
self.record_download_manifest_entry(
|
||||
&mut manifest,
|
||||
&item.url,
|
||||
&item.destination,
|
||||
)
|
||||
})
|
||||
.and_then(|_| self.write_download_manifest(&manifest))
|
||||
{
|
||||
let error = PullOneError::plain(format!("记录下载 manifest 失败:{error}"));
|
||||
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
|
||||
progress(OfficialResourcePullProgress::failed(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
&error,
|
||||
));
|
||||
return Err(DownloadError::new(
|
||||
error.error_code(),
|
||||
format!(
|
||||
"官方资源下载失败:URL 已进入 quarantine,中止本轮同步、不发布不完整资源;url={} quarantine={};{}",
|
||||
item.url,
|
||||
self.download_quarantine_path().display(),
|
||||
error.message
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
completed_count += 1;
|
||||
progress(OfficialResourcePullProgress::finished(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
pull_result.status,
|
||||
pull_result.bytes,
|
||||
pull_result.transferred_bytes,
|
||||
));
|
||||
download_results[plan_index] = Some(Ok(pull_result));
|
||||
}
|
||||
Err(error) => {
|
||||
self.record_quarantine_entry(&item.url, &item.destination, &error)?;
|
||||
progress(OfficialResourcePullProgress::failed(
|
||||
completed_count,
|
||||
total,
|
||||
item.url.clone(),
|
||||
error,
|
||||
&error,
|
||||
));
|
||||
return Err(DownloadError::new(
|
||||
error.error_code(),
|
||||
@@ -1771,17 +1703,6 @@ struct PlannedDownload {
|
||||
existing: Option<PullOneResult>,
|
||||
}
|
||||
|
||||
/// worker 线程经 mpsc 送回主线程的消息。
|
||||
enum WorkerMessage {
|
||||
/// worker 已领取某计划项、即将下载(主线程据此发 started 进度)。
|
||||
Started { plan_index: usize },
|
||||
/// 某计划项下载结束(成功或失败)。
|
||||
Done {
|
||||
plan_index: usize,
|
||||
result: Result<PullOneResult, PullOneError>,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
struct PullOneResult {
|
||||
bytes: u64,
|
||||
@@ -3084,16 +3005,14 @@ exit 22
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn downloads_run_concurrently_and_each_url_reports_once() {
|
||||
fn downloads_run_sequentially_and_each_url_reports_once() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
let bin_dir = TempDir::new().unwrap();
|
||||
let curl_path = bin_dir.path().join("curl");
|
||||
write_fake_curl(&curl_path);
|
||||
|
||||
// 并发度 8:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。
|
||||
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path)
|
||||
.with_download_concurrency(8);
|
||||
assert_eq!(service.download_concurrency(), 8);
|
||||
// 顺序下载:每个 URL 恰好一次 started + 一次 finished,全部文件落盘。
|
||||
let service = OfficialResourcePullService::with_curl_command(out_dir.path(), &curl_path);
|
||||
let plan = build_official_pull_plan_for_platforms(
|
||||
discovery_plan(),
|
||||
inventory(),
|
||||
@@ -3127,24 +3046,6 @@ exit 22
|
||||
assert_eq!(manifest.entries.len(), all_urls.len());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn download_concurrency_is_clamped() {
|
||||
let service = OfficialResourcePullService::with_curl_command("/tmp/unused", "curl");
|
||||
assert_eq!(service.download_concurrency(), DEFAULT_DOWNLOAD_CONCURRENCY);
|
||||
assert_eq!(
|
||||
OfficialResourcePullService::with_curl_command("/tmp/unused", "curl")
|
||||
.with_download_concurrency(0)
|
||||
.download_concurrency(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
OfficialResourcePullService::with_curl_command("/tmp/unused", "curl")
|
||||
.with_download_concurrency(9999)
|
||||
.download_concurrency(),
|
||||
MAX_DOWNLOAD_CONCURRENCY
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_transient_download_failures() {
|
||||
let out_dir = TempDir::new().unwrap();
|
||||
|
||||
@@ -96,8 +96,6 @@ pub struct OfficialUpdateConfig {
|
||||
pub audit_local: bool,
|
||||
/// Repair local files when the local manifest audit fails.
|
||||
pub repair: bool,
|
||||
/// 下载并发度(并行执行的网络下载数)。默认 4,钳制到 `1..=256`。
|
||||
pub download_concurrency: usize,
|
||||
}
|
||||
|
||||
impl Default for OfficialUpdateConfig {
|
||||
@@ -119,7 +117,6 @@ impl Default for OfficialUpdateConfig {
|
||||
force: false,
|
||||
audit_local: true,
|
||||
repair: true,
|
||||
download_concurrency: crate::official_download::DEFAULT_DOWNLOAD_CONCURRENCY,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -901,8 +898,7 @@ impl OfficialUpdateService {
|
||||
&active_resource_root,
|
||||
&config.curl_command,
|
||||
)
|
||||
.with_proxy_config(config.curl_proxy.clone())
|
||||
.with_download_concurrency(config.download_concurrency);
|
||||
.with_proxy_config(config.curl_proxy.clone());
|
||||
let snapshot_path = snapshot_path_for(config, &active_resource_root);
|
||||
let bootstrap_cache_path = config.bootstrap_cache_path();
|
||||
|
||||
@@ -1341,8 +1337,7 @@ impl OfficialUpdateService {
|
||||
&publish_plan.staging_path,
|
||||
&config.curl_command,
|
||||
)
|
||||
.with_proxy_config(config.curl_proxy.clone())
|
||||
.with_download_concurrency(config.download_concurrency);
|
||||
.with_proxy_config(config.curl_proxy.clone());
|
||||
report.staging_path = Some(publish_plan.staging_path.clone());
|
||||
report.snapshot_path = staging_snapshot_path.clone();
|
||||
report.download_manifest = staging_fetcher.download_manifest_path();
|
||||
|
||||
@@ -330,6 +330,75 @@ fn official_update_second_run_audits_existing_resources_before_reuse() {
|
||||
assert!(version_state.in_progress_version.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn official_update_reuses_failed_staging_after_interrupted_download() {
|
||||
let harness = TestHarness::new();
|
||||
let bootstrap = harness.fetch_bootstrap();
|
||||
let fetcher = harness.fetcher();
|
||||
let server_info_url = bootstrap
|
||||
.game_main_config
|
||||
.server_info_data_url
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.clone();
|
||||
let server_info_bytes = fetcher.fetch_bytes(&server_info_url).unwrap();
|
||||
let server_info = YostarJpServerInfo::from_slice(&server_info_bytes).unwrap();
|
||||
let discovery = server_info
|
||||
.discovery_plan(
|
||||
bootstrap
|
||||
.game_main_config
|
||||
.default_connection_group
|
||||
.as_deref()
|
||||
.unwrap(),
|
||||
&bootstrap.game_config.game_latest_version,
|
||||
&verified_official_platforms(),
|
||||
)
|
||||
.unwrap();
|
||||
let inventory = fetch_platform_inventory(&fetcher, &discovery);
|
||||
let plan = build_official_pull_plan_from_platform_inventory(discovery, inventory);
|
||||
let all_urls = plan.all_urls().unwrap();
|
||||
let resume_url = all_urls
|
||||
.iter()
|
||||
.find(|url| url.ends_with("/Windows_PatchPack/catalog_StandaloneWindows64.zip"))
|
||||
.expect("fixture plan should contain Windows addressables catalog zip")
|
||||
.clone();
|
||||
let resources_assets_path = harness.temp.path().join("resources.assets");
|
||||
let zip_fixture = harness.temp.path().join("downloaded.zip");
|
||||
let curl_failure_state = harness.temp.path().join("curl-failure.state");
|
||||
write_executable(
|
||||
&harness.curl_script,
|
||||
&official_curl_script(
|
||||
&harness.curl_log,
|
||||
&zip_fixture,
|
||||
&resources_assets_path,
|
||||
fs::metadata(&resources_assets_path).unwrap().len() as usize,
|
||||
TEST_LAUNCHER_MANIFEST_SOURCE,
|
||||
Some(&curl_failure_state),
|
||||
),
|
||||
);
|
||||
|
||||
let config = harness.sync_config("failed-staging-output");
|
||||
let first_error = OfficialUpdateService::new().run(&config).unwrap_err();
|
||||
assert!(first_error.to_string().contains("quarantine"));
|
||||
assert!(first_error.to_string().contains("simulated failure"));
|
||||
|
||||
let first_log = fs::read_to_string(&harness.curl_log).unwrap();
|
||||
assert!(first_log.contains(&resume_url));
|
||||
|
||||
let report = OfficialUpdateService::new().run(&config).unwrap();
|
||||
assert_eq!(report.update_status, OfficialUpdateStatus::Downloaded);
|
||||
|
||||
let second_log = fs::read_to_string(&harness.curl_log).unwrap();
|
||||
assert_eq!(second_log.matches(&resume_url).count(), 1);
|
||||
|
||||
let version_state = read_version_state(&report.version_state_path)
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert!(version_state.failed_versions.is_empty());
|
||||
assert!(version_state.in_progress_version.is_none());
|
||||
assert!(version_state.current_completed_version.is_some());
|
||||
}
|
||||
|
||||
struct TestHarness {
|
||||
temp: TempDir,
|
||||
curl_script: std::path::PathBuf,
|
||||
@@ -362,6 +431,7 @@ impl TestHarness {
|
||||
&resources_assets_path,
|
||||
resources_assets.len(),
|
||||
manifest_source,
|
||||
None,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -478,11 +548,16 @@ fn official_curl_script(
|
||||
resources_assets_fixture: &Path,
|
||||
resources_assets_size: usize,
|
||||
manifest_source: &str,
|
||||
failure_state: Option<&Path>,
|
||||
) -> String {
|
||||
let failure_state = failure_state
|
||||
.map(shell_quote)
|
||||
.unwrap_or_else(|| "''".to_string());
|
||||
format!(
|
||||
r#"#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
log={}
|
||||
failure_state={failure_state}
|
||||
printf '%s\n' "$*" >> "$log"
|
||||
output=""
|
||||
url=""
|
||||
@@ -522,6 +597,23 @@ emit_resources_assets_fixture() {{
|
||||
cp {} "$output"
|
||||
}}
|
||||
|
||||
maybe_fail_once() {{
|
||||
if [[ -z "$failure_state" ]]; then
|
||||
return 0
|
||||
fi
|
||||
local attempt=0
|
||||
if [[ -f "$failure_state" ]]; then
|
||||
attempt="$(cat "$failure_state")"
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
mkdir -p "$(dirname "$failure_state")"
|
||||
printf '%s' "$attempt" > "$failure_state"
|
||||
if [[ "$attempt" -le 3 ]]; then
|
||||
echo "simulated failure for $url attempt=$attempt" >&2
|
||||
exit 22
|
||||
fi
|
||||
}}
|
||||
|
||||
if [[ "$url" == "https://api-launcher-jp.yo-star.com/api/launcher/game/config" ]]; then
|
||||
cat <<'JSON'
|
||||
{{"code":200,"message":"ok","data":{{"game_latest_version":"{launcher_latest_version}","game_latest_file_path":"{launcher_latest_file_path}"}}}}
|
||||
@@ -573,6 +665,7 @@ elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.bytes
|
||||
elif [[ "$url" == "{addressables_root}/MediaResources/Catalog/MediaCatalog.hash" ]]; then
|
||||
emit_text "{android_media_catalog_hash}"
|
||||
elif [[ -n "$output" && "$url" == "{addressables_root}/"* ]]; then
|
||||
maybe_fail_once
|
||||
filename="${{url##*/}}"
|
||||
if [[ "$filename" == *.zip ]]; then
|
||||
emit_zip_fixture
|
||||
@@ -604,6 +697,7 @@ fi
|
||||
windows_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Win.zip"),
|
||||
android_bundle_catalog_hash = xxhash32(b"FullPatch_001.zip"),
|
||||
android_media_catalog_hash = xxhash32(b"GameData\\Audio\\VOC_JP\\JP_Airi_Android.zip"),
|
||||
failure_state = failure_state,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user