校验与断点续传
下载中断、源坏了、文件损了——这三件事的处理分布在两条物理路径上,而且两条路径的行为并不一致。这一页讲 SHA1 校验、.sc-tmp 临时文件、15 秒空闲判死,以及两条路径在续传上的差别。
临时文件先落地
两条路径都不直接写目标文件,而是先写 <dest>.sc-tmp,校验通过后才 File.Move 落盘:
private static void VerifyAndCommit(string tmp, string destFile, string? sha1)
{
if (!string.IsNullOrEmpty(sha1))
{
using var fs = File.OpenRead(tmp);
var actual = Convert.ToHexString(SHA1.HashData(fs));
if (!actual.Equals(sha1, StringComparison.OrdinalIgnoreCase))
throw new IOException($"SHA1 校验失败: {Path.GetFileName(destFile)}");
}
File.Move(tmp, destFile, overwrite: true);
}好处是目标位置永远不会出现一个半截文件。SHA1 校验只在有期望哈希时做——Mojang 的版本清单里每个文件都带 SHA1,第三方源不一定给,这时 sha1 为 null,校验跳过。LocalFileReuse 和 P2P 复用是同一套标准:只认 SHA1。
整文件路径:断点续传与 15 秒判死
FetchWholeOnceAsync 做的事:看 .sc-tmp 存不存在,存在就把长度当断点发 Range: <resume>-;服务器回 416(RequestedRangeNotSatisfiable)说明本地已下满,直接进校验;服务器无视 Range 回 200 则断点作废,从头写。
var tmp = destFile + ".sc-tmp";
var resume = File.Exists(tmp) ? new FileInfo(tmp).Length : 0;
if (resume > 0)
req.Headers.Range = new System.Net.Http.Headers.RangeHeaderValue(resume, null);读循环用 IdleTimeout 看门狗,15 秒读不到字节就判连接卡死,抛异常让外层换源:
using var idle = new IdleTimeout(ct, 15_000);
...
catch (OperationCanceledException) when (!ct.IsCancellationRequested)
{
throw new IOException("连接 15 秒无数据,已保留断点(重试将从续传开始)");
}
idle.Touch();那句「已保留断点」说明 .sc-tmp 是故意留着的——整文件路径的失败不删临时文件,因为断点有价值:下载到 80% 卡死,重试从 80% 继续。这也是为什么整文件路径没有总时长超时,连接健康完全交给 15 秒空闲判死。换源时断点也不换:.sc-tmp 是本地文件,新源照样从 resume 继续。
换源之后,坏断点会一直复用
这是整文件路径上一个真实的坑:SHA1 校验失败时 FinishWhole 直接抛错,不删 .sc-tmp。坏文件留在原地,下一次重试读到它的长度当断点,接着从那个位置往后下——中间那段坏内容永远不会被重新拉取。每轮重试都在给同一个坏文件追加新字节,SHA1 依然不过。
分片路径没这个问题:ChunkedFileDownloader.DownloadAsync 的 catch 块里有 TryDelete(tmp),失败即删。
修法也很直接——整文件路径的校验失败应该一并 TryDelete(tmp),让下一次从零开始,而不是续传一个已知损坏的文件。
分片路径:失败即删
catch
{
TryDelete(tmp);
TaskCenterService.Instance.ReportRacingEnd(token, false, usage.Values.Sum());
throw;
}分片路径不续传,因为它有更完整的失败语义:完整性闸门要求成功字节数必须等于文件大小,少一个字节都不算成功;再叠一层整文件 SHA1 校验;失败时删临时文件,让调用方退化到整文件路径重来。
var got = usage.Values.Sum();
if (got < size)
throw new IOException($"分片不完整:成功 {got / 1048576.0:F1} MB / 应为 {size / 1048576.0:F1} MB");这条闸门抓的是「静默缺失」:某片请求返回了 206,但内容比声明的短,或者响应提前结束。分片重试只会计多不会计少,总字节数偏小就一定有问题。
探测失败不写记忆
ProbeRangeAsync 只在明确的 206 / 200 时更新 MarkRangeSupport,网络故障、超时、异常状态码都不写:
catch
{
return (0, false); // 探测失败不影响下载本身:按整文件路径走
}理由很实在:false 意味着「这个主机不支持分片,之后所有文件都走单连接整文件」。一次网络抖动就把好源记成不支持,这个主机在本次进程内就再也分不了片了。(false 和「未知」在返回类型上能区分:未知时 RangeSupport(host) 返回 null。)
本地复用只建文件名索引
LocalFileReuse.Scan 只列目录、记文件名,不算哈希。哈希是等到真正命中候选时才现算的,而且先比大小再算哈希,省掉绝大多数计算:
public string? FindByHash(string destPath, string? expectedSha1, long size)
{
if (string.IsNullOrEmpty(expectedSha1)) return null;
var candidates = new List<string>();
if (_byName.TryGetValue(Path.GetFileName(destPath), out var sameName))
candidates.AddRange(sameName);
if (IsVersionJar(destPath)) candidates.AddRange(_versionJars);
foreach (var candidate in candidates)
{
if (size > 0 && new FileInfo(candidate).Length != size) continue; // 先比大小
if (Sha1Matches(candidate, expectedSha1)) return candidate;
}
return null;
}那为什么要单独维护一份 _versionJars?因为版本 jar 的文件名不固定:不同实例的 client jar 分别叫 1.20.1.jar、1.20.1-2.jar,文件名索引对不上,只能靠内容 SHA1 认亲。
这也是本页开头那个原则的注解:安全线是 SHA1,不是文件名相似度。