Files
Unlucky-Launcher/crates/core/src/download/atomic.rs
T
AskaEth bc41dfcf7d
CI / fmt (rustfmt --check) (push) Canceled after 0s
CI / clippy (ubuntu-latest) (push) Canceled after 0s
CI / clippy (windows-latest) (push) Canceled after 0s
CI / test (ubuntu-latest) (push) Canceled after 0s
CI / test (windows-latest) (push) Canceled after 0s
CI / build release (ubuntu-latest) (push) Canceled after 0s
CI / build release (windows-latest) (push) Canceled after 0s
Guards / core 零 UI 守门 (push) Canceled after 0s
Guards / 文件行数 ≤400 守门 (push) Canceled after 0s
Guards / 依赖许可与来源(cargo-deny) (push) Canceled after 0s
Unlucky Launcher v0.0.1-alpha
Rust 原生单文件 Minecraft 启动器:CLI 完整可用(install/launch/accounts/java/diagnose/agent),
MCP server 接入外部 Agent 工具,GUI 开发中。
2026-10-31 21:54:10 +08:00

385 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 原子落盘:临时文件守卫 + `tmp → rename`。
//!
//! 设计文档 §4 的 HMCL 坑之一:没有「先校验后落盘」,崩溃/取消会留下「看起来存在但内容坏了」
//! 的文件,之后所有「存在即跳过」的判断全部失效。这里的规矩:
//!
//! * 下载永远写进同级中间文件,校验通过才 `rename` 到目标路径;
//! * `PartFile` 在 `Drop` 时自动清理(除非已 `commit`),取消/early-return 都不会留垃圾;
//! * 小文件(索引、健康度)走 `write_atomic`,同样先写临时文件再 `rename`。
//!
//! # 中间文件的身份
//!
//! 中间文件名是 `<name>.ullpart-<session>-<seq>`:`session` 是进程号,`seq` 是进程内
//! 自增序号(一个 `PartFile` 一个)。旧版命名只由目标路径决定(`<name>.ullpart`),
//! 同一数据根同时跑两个启动器进程时两边共用一个中间文件:先提交的一方 `rename` 走,
//! 另一方就拿到 `os error 2`(M35 真机:两个 `ull install` 并行装同一数据根,
//! 各失败 819 / 1116 个资源)。
//!
//! 单进程语义(幂等 / 断点续传 / `current_bytes` 跳过判定)刻意保持不变:
//!
//! * [`PartFile::for_dest`] 先在目标目录里找这个目标的旧中间文件并**收养**(本进程的、
//! 旧版无主的、以及外进程但静置超过 [`ADOPT_MIN_AGE`] 的),改名成本次路径;后续
//! 续传判定完全照旧,跨进程/跨版本中断的下载仍然续得上。
//! * 孤儿清理只碰两类:本会话留下又没人在用的副本,和无主(旧版命名)且超过
//! [`ORPHAN_MAX_AGE`] 的残留。本进程正在用的路径登记在活跃表里一律让开;外进程的
//! 中间文件除了「静置超过 [`ORPHAN_MAX_AGE`] 且同一个目标」的,一概不删——没有进程
//! 存活性探测就无法证明那边已经死了,宁可再生点垃圾也不重犯 `os error 2`。
use std::collections::HashSet;
use std::ffi::OsString;
use std::fs::{self, File, OpenOptions};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::{Mutex, OnceLock};
use std::time::{Duration, SystemTime};
/// 下载中间文件后缀(中间文件家族的前缀:`<name>.ullpart` 或 `<name>.ullpart-<session>-<seq>`)。
pub const PART_SUFFIX: &str = ".ullpart";
/// 续传元数据后缀(挂在中间文件路径后面)。
pub const META_SUFFIX: &str = ".ullmeta.json";
/// 收养阈值:别的进程留下的中间文件静置这么久才敢接管。
///
/// 活着的下载每读到一个块就写一次(读超时默认 30s 就报错退出),所以「10 分钟没写」
/// 基本等价于「那边已经死了」。
pub const ADOPT_MIN_AGE: Duration = Duration::from_secs(10 * 60);
/// 孤儿阈值:无主(旧版命名)中间文件超过这个岁数才清。
pub const ORPHAN_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
/// 同进程内中间文件/临时文件的去重序号。
static SEQ: AtomicU64 = AtomicU64::new(0);
fn sibling(dest: &Path, suffix: &str) -> PathBuf {
let mut name = dest.file_name().map_or_else(OsString::new, OsString::from);
name.push(suffix);
match dest.parent() {
Some(parent) => parent.join(name),
None => PathBuf::from(name),
}
}
/// 本进程序号(中间文件与 `write_atomic` 的临时文件共用)。
fn next_seq() -> u64 {
SEQ.fetch_add(1, Ordering::Relaxed)
}
/// 本进程此刻仍在用的中间文件路径(清理与收养都必须让开)。
fn live_parts() -> &'static Mutex<HashSet<PathBuf>> {
static LIVE: OnceLock<Mutex<HashSet<PathBuf>>> = OnceLock::new();
LIVE.get_or_init(|| Mutex::new(HashSet::new()))
}
fn mark_live(path: &Path, live: bool) {
let mut set = live_parts()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner);
if live {
set.insert(path.to_path_buf());
} else {
set.remove(path);
}
}
fn is_live(path: &Path) -> bool {
live_parts()
.lock()
.unwrap_or_else(std::sync::PoisonError::into_inner)
.contains(path)
}
/// 中间文件属主(从文件名解析)。
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Owner {
/// 旧版命名:没有进程标识(加标识之前留下的续传材料)。
Legacy,
/// 某个进程(会话)留下的中间文件。
Session(u32),
}
fn strip_meta(name: &str) -> &str {
name.strip_suffix(META_SUFFIX).unwrap_or(name)
}
/// 名字里指向的中间文件属主;不是我们认识的名字形状 → `None`(一概不碰)。
fn owner_of(file_name: &str) -> Option<Owner> {
let base = strip_meta(file_name);
let index = base.rfind(PART_SUFFIX)?;
if index == 0 {
return None;
}
let tail = &base[index + PART_SUFFIX.len()..];
if tail.is_empty() {
return Some(Owner::Legacy);
}
let mut pieces = tail.strip_prefix('-')?.split('-');
let session = pieces.next()?.parse::<u32>().ok()?;
pieces.next()?.parse::<u64>().ok()?;
if pieces.next().is_some() {
return None;
}
Some(Owner::Session(session))
}
/// 本次会话的中间文件路径:`<name>.ullpart-<session>-<seq>`。
fn part_path(dest: &Path, session: u32, seq: u64) -> PathBuf {
let mut name = dest.file_name().map_or_else(OsString::new, OsString::from);
name.push(format!("{PART_SUFFIX}-{session}-{seq}"));
match dest.parent() {
Some(parent) => parent.join(name),
None => PathBuf::from(name),
}
}
/// 文件距上次修改多久(取不到就当刚写过:清理与收养一律保守)。
fn age_of(path: &Path) -> Duration {
fs::metadata(path)
.and_then(|meta| meta.modified())
.ok()
.and_then(|modified| SystemTime::now().duration_since(modified).ok())
.unwrap_or_default()
}
/// 删掉一个中间文件连同它挂的续传元数据。
fn remove_family(part: &Path) {
let _ = fs::remove_file(part);
let _ = fs::remove_file(sibling(part, META_SUFFIX));
}
/// 目录扫描的三类结果。
#[derive(Default)]
struct Scan {
/// 可收养的旧中间文件(最年轻的那个)。
adopted: Option<PathBuf>,
/// 本目标的全部中间文件副本(含被收养者)。
leftovers: Vec<(PathBuf, Owner, Duration)>,
/// 无主且超龄的孤儿。
orphans: Vec<PathBuf>,
}
fn scan(dest: &Path, session: u32) -> Scan {
let mut scan = Scan::default();
let Some(dir) = dest
.parent()
.filter(|parent| !parent.as_os_str().is_empty())
else {
return scan;
};
let Some(dest_name) = dest.file_name() else {
return scan;
};
let family = format!("{}{PART_SUFFIX}", dest_name.to_string_lossy());
let Ok(entries) = fs::read_dir(dir) else {
return scan;
};
let mut newest: Option<Duration> = None;
for entry in entries.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.ends_with(META_SUFFIX) {
continue; // 元数据跟着它挂的中间文件一起处理
}
let Some(owner) = owner_of(&name) else {
continue;
};
let file = entry.path();
if !file.is_file() {
continue;
}
let age = age_of(&file);
if !name.starts_with(&family) {
if owner == Owner::Legacy && age >= ORPHAN_MAX_AGE {
scan.orphans.push(file);
}
continue;
}
let adoptable = match owner {
// 本会话的旧副本:没人在用就能接管(保留「同一进程内重跑可续传」)。
Owner::Session(owner_session) if owner_session == session => !is_live(&file),
// 外进程的:静置够久才敢动手,否则可能正在被写。
Owner::Session(_) => age >= ADOPT_MIN_AGE,
Owner::Legacy => true,
};
if adoptable && newest.is_none_or(|best| age < best) {
newest = Some(age);
scan.adopted = Some(file.clone());
}
scan.leftovers.push((file, owner, age));
}
scan
}
/// 为本次下载准备中间文件:收养可续传的旧文件,清掉本会话多余副本与无主孤儿。
///
/// 全程尽力而为:清理失败绝不影响下载本身。
fn prepare(dest: &Path, path: &Path, session: u32) {
let scan = scan(dest, session);
if let Some(found) = scan.adopted {
// 同一目录内改名,跨进程也是原子的:谁先动手谁拿到这份续传材料。
if found != *path && fs::rename(&found, path).is_ok() {
let meta = sibling(&found, META_SUFFIX);
if meta.is_file() {
let _ = fs::rename(&meta, sibling(path, META_SUFFIX));
}
}
}
for (leftover, owner, age) in scan.leftovers {
if leftover == *path || is_live(&leftover) {
continue;
}
// 本会话落败的副本留着没意义;外进程的只有超龄才敢清。
if owner == Owner::Session(session) || age >= ORPHAN_MAX_AGE {
remove_family(&leftover);
}
}
for orphan in scan.orphans {
remove_family(&orphan);
}
}
/// 下载中间文件的守卫。
#[derive(Debug)]
pub struct PartFile {
path: PathBuf,
committed: bool,
preserved: bool,
}
impl PartFile {
/// 为目标路径派生中间文件(名字带进程标识,并顺手收养旧的续传材料)。
#[must_use]
pub fn for_dest(dest: &Path) -> Self {
let session = std::process::id();
let path = part_path(dest, session, next_seq());
mark_live(&path, true);
prepare(dest, &path, session);
Self {
path,
committed: false,
preserved: false,
}
}
/// 中间文件路径。
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
/// 续传元数据路径。
#[must_use]
pub fn meta_path(&self) -> PathBuf {
sibling(&self.path, META_SUFFIX)
}
/// 中间文件是否存在。
#[must_use]
pub fn exists(&self) -> bool {
self.path.is_file()
}
/// 已写入的字节数(不存在时为 0)。
#[must_use]
pub fn len(&self) -> u64 {
fs::metadata(&self.path).map_or(0, |meta| meta.len())
}
/// 是否为空(续传判定要区分「文件不存在」和「文件存在但 0 字节」)。
#[must_use]
pub fn is_empty(&self) -> bool {
self.len() == 0
}
/// 以追加方式打开(续传)。
pub fn open_append(&self) -> io::Result<File> {
ensure_parent(&self.path)?;
OpenOptions::new()
.create(true)
.append(true)
.open(&self.path)
}
/// 以截断方式打开(从头下载)。
pub fn open_truncate(&self) -> io::Result<File> {
ensure_parent(&self.path)?;
OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(&self.path)
}
/// 校验通过后提交:把中间文件 `rename` 到目标路径(同一目录,跨文件系统不会发生)。
pub fn commit(mut self, dest: &Path) -> io::Result<()> {
ensure_parent(dest)?;
fs::rename(&self.path, dest)?;
self.committed = true;
// rename 后中间文件已不存在,元数据是纯冗余,顺手清掉。
let _ = fs::remove_file(self.meta_path());
Ok(())
}
/// 主动丢弃中间文件(换源/校验失败)。
pub fn discard(&mut self) {
remove_family(&self.path);
}
/// 确保中间文件的父目录存在(异步版)。
///
/// 单连接路径由 [`Self::open_append`] / [`Self::open_truncate`] 顺手建目录,
/// 但**分块路径**是先 `tokio::fs::OpenOptions` 预分配整文件、再并发写段——
/// 它不走那两个入口,所以得显式补这一步,否则「下到还不存在的目录」会全部源失败
/// (现场故障:`data/java-managed/.cache/` 尚未创建时 6 个候选源瞬间全挂)。
pub(crate) async fn ensure_parent_async(&self) -> io::Result<()> {
let Some(parent) = self.path.parent() else {
return Ok(());
};
if parent.as_os_str().is_empty() {
return Ok(());
}
tokio::fs::create_dir_all(parent).await
}
/// 保留中间文件(跨会话续传用):`Drop` 时不再清理。
pub fn preserve(&mut self) {
self.preserved = true;
}
}
impl Drop for PartFile {
fn drop(&mut self) {
if !self.committed && !self.preserved {
remove_family(&self.path);
}
mark_live(&self.path, false);
}
}
/// 原子写小文件:`<path>.tmp-<session>-<seq>` → `rename(path)`。
///
/// 临时名同样带进程标识:两个进程同时写同一份小文件(健康度、续传元数据)时
/// 不会互相把对方的临时文件 `rename` 走。
pub fn write_atomic(path: &Path, bytes: &[u8]) -> io::Result<()> {
ensure_parent(path)?;
let tmp = sibling(path, &format!(".tmp-{}-{}", std::process::id(), next_seq()));
{
let mut file = File::create(&tmp)?;
file.write_all(bytes)?;
file.flush()?;
file.sync_all()?;
}
fs::rename(&tmp, path).inspect_err(|_| {
let _ = fs::remove_file(&tmp);
})
}
fn ensure_parent(path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
fs::create_dir_all(parent)?;
}
}
Ok(())
}