Skip to content

校验与断点续传 ​

下载中断、源坏了、文件损了——这三件事的处理分布在两条物理路径上,而且两条路径的行为并不一致。这一页讲 SHA1 校验、.sc-tmp 临时文件、15 秒空闲判死,以及两条路径在续传上的差别。

临时文件先落地 ​

两条路径都不直接写目标文件,而是先写 <dest>.sc-tmp,校验通过后才 File.Move 落盘:

csharp
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 则断点作废,从头写。

csharp
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 秒读不到字节就判连接卡死,抛异常让外层换源:

csharp
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),让下一次从零开始,而不是续传一个已知损坏的文件。

分片路径:失败即删 ​

csharp
catch
{
    TryDelete(tmp);
    TaskCenterService.Instance.ReportRacingEnd(token, false, usage.Values.Sum());
    throw;
}

分片路径不续传,因为它有更完整的失败语义:完整性闸门要求成功字节数必须等于文件大小,少一个字节都不算成功;再叠一层整文件 SHA1 校验;失败时删临时文件,让调用方退化到整文件路径重来。

csharp
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,网络故障、超时、异常状态码都不写:

csharp
catch
{
    return (0, false); // 探测失败不影响下载本身:按整文件路径走
}

理由很实在:false 意味着「这个主机不支持分片,之后所有文件都走单连接整文件」。一次网络抖动就把好源记成不支持,这个主机在本次进程内就再也分不了片了。(false 和「未知」在返回类型上能区分:未知时 RangeSupport(host) 返回 null。)

本地复用只建文件名索引 ​

LocalFileReuse.Scan 只列目录、记文件名,不算哈希。哈希是等到真正命中候选时才现算的,而且先比大小再算哈希,省掉绝大多数计算:

csharp
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,不是文件名相似度。

相关链接:下载引擎总览、分片与竞速

基于 MIT 许可证(有修改)开源