From 74ca6852bea727f95ed3cc4660139dc060d186ac Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:11:34 +0800 Subject: [PATCH 01/20] =?UTF-8?q?refactor(downloader):=20=E9=87=8D?= =?UTF-8?q?=E6=9E=84=E5=A4=A7=E6=96=87=E4=BB=B6=E4=B8=8B=E8=BD=BD=E5=88=86?= =?UTF-8?q?=E6=AE=B5=E4=B8=8E=E7=B3=BB=E7=BB=9F=E8=B5=84=E6=BA=90=E5=88=86?= =?UTF-8?q?=E9=85=8D=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- PCL.Core/App/Config.cs | 2 + .../App/Localization/Languages/en-US.xaml | 12 +- .../App/Localization/Languages/zh-CN.xaml | 12 +- .../Modules/Base/ModSetup.cs | 11 +- .../Modules/Minecraft/ModSkin.cs | 4 +- .../Downloader/AdaptiveRangeDownloader.cs | 420 ++++++++++++++++++ .../Downloader/DownloadResourceManager.cs | 145 ++++++ .../Network/Downloader/FileDownloader.cs | 228 ++++++---- .../Modules/Network/Facade/ModNet.cs | 21 +- .../Modules/Network/Http/Requester.cs | 8 +- .../Modules/Network/Loaders/LoaderDownload.cs | 6 +- .../Modules/Network/Management/NetManager.cs | 5 +- .../Pages/PageSetup/PageSetupGameManage.xaml | 18 +- .../PageSetup/PageSetupGameManage.xaml.cs | 3 +- .../Pages/PageSpeedLeft.xaml.cs | 8 +- 15 files changed, 783 insertions(+), 120 deletions(-) create mode 100644 Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs create mode 100644 Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs diff --git a/PCL.Core/App/Config.cs b/PCL.Core/App/Config.cs index 1137f4eaa2..c684b521f5 100644 --- a/PCL.Core/App/Config.cs +++ b/PCL.Core/App/Config.cs @@ -72,7 +72,9 @@ public static partial class Config /// [ConfigGroup("Download")] partial class DownloadConfigGroup { + // 保留原配置键,升级后仍沿用用户已设置的数值(值 + 1 为连接上限)。 [ConfigItem("ToolDownloadThread", 63)] public partial int ThreadLimit { get; set; } + [ConfigItem("ToolDownloadFileConnection", 7)] public partial int FileConnectionLimit { get; set; } [ConfigItem("ToolDownloadSpeed", 42)] public partial int SpeedLimit { get; set; } [ConfigItem("ToolDownloadSource", 1)] public partial int FileSource { get; set; } [ConfigItem("ToolDownloadVersion", 1)] public partial int VersionListSource { get; set; } diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index c090e7a13e..14b639853c 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -2672,10 +2672,12 @@ Set a download speed cap to prevent other programs that depend on the internet from freezing during downloads. Target folder Go to Launch → Selection → Folder list to change the download target folder. Right-click a folder or game instance to open the corresponding folder. - Max threads + Max concurrent connections I see - Setting too many download threads may cause serious lag during downloads. 64 threads are generally enough for most download needs. Unless you know what you are doing, it is not recommended to set a higher value! - More threads can speed up rate-limited downloads, but too many may cause serious lag during downloads. In general, 64 threads are enough for most download needs. + A high connection limit can trigger server throttling, network lag, or disk pressure. 64 connections are usually sufficient unless you know that your sources and network benefit from more. + Limits the total network connections used by all downloads. Segment sizing and slow-connection retries are adjusted automatically; 64 connections are usually sufficient. + Max connections per file + The most connections a large file may use at once. A higher value can help sources that cap each connection, but may also trigger source throttling. Unlimited @@ -3226,7 +3228,7 @@ Error details copied! A task manager operation failed. See the details below. Remaining files - Remaining threads + Active connections Download speed Total progress @@ -3570,4 +3572,4 @@ Minecraft window loaded: {0} ({1}) Minecraft window maximized: {0} - \ No newline at end of file + diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index 9bae29f93c..de7077e87d 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -2672,10 +2672,12 @@ 设置下载的速度上限,以避免在下载时导致其他需要联网的程序卡死 目标文件夹 请在 启动 → 实例选择 → 文件夹列表 中更改下载目标文件夹。 在某个文件夹或游戏实例上右键,即可选择打开对应文件夹。 - 最大线程数 + 最大并发连接数 我知道了 - 如果设置过多的下载线程,可能会导致下载时出现非常严重的卡顿。 一般设置 64 线程即可满足大多数下载需求,除非你知道你在干什么,否则不建议设置更多的线程数! - 线程数越多,限速的文件下载越快,但过高的线程数可能会造成下载时非常严重的卡顿。 一般而言,64 线程已可以保证足够的下载速度。 + 过高的并发连接数可能导致服务器限速、网络卡顿或磁盘压力增加。 64 个连接通常已足够,除非你确认下载源和网络环境能够从更多连接中受益。 + 限制所有下载任务合计使用的网络连接数。分段大小和慢速重试会自动调整;64 个连接通常已足够。 + 单文件最大连接数 + 大文件最多同时使用的连接数。提高该值可能加快被单连接限速的下载,但也更容易被下载源限速。 无限制 @@ -3226,7 +3228,7 @@ 已复制错误详情! 任务管理操作失败。请查看错误详情。 剩余文件 - 剩余线程 + 活动连接 下载速度 总进度 @@ -3570,4 +3572,4 @@ Minecraft 窗口已加载:{0}({1}) 已最大化 Minecraft 窗口:{0} - \ No newline at end of file + diff --git a/Plain Craft Launcher 2/Modules/Base/ModSetup.cs b/Plain Craft Launcher 2/Modules/Base/ModSetup.cs index 0591d203f5..33a2875afd 100644 --- a/Plain Craft Launcher 2/Modules/Base/ModSetup.cs +++ b/Plain Craft Launcher 2/Modules/Base/ModSetup.cs @@ -31,6 +31,8 @@ public ModSetup() // === Tool === Config.Download.ThreadLimitConfig.Observe(new ConfigObserver(ConfigEvent.Changed, e => ToolDownloadThread((int)e.Value!))); + Config.Download.FileConnectionLimitConfig.Observe(new ConfigObserver(ConfigEvent.Changed, + e => ToolDownloadFileConnection((int)e.Value!))); Config.Download.SpeedLimitConfig.Observe(new ConfigObserver(ConfigEvent.Changed, e => ToolDownloadSpeed((int)e.Value!))); @@ -113,6 +115,7 @@ public static void ApplyAll() // Tool ToolDownloadThread(Config.Download.ThreadLimit); + ToolDownloadFileConnection(Config.Download.FileConnectionLimit); ToolDownloadSpeed(Config.Download.SpeedLimit); // UI - Launcher @@ -195,7 +198,13 @@ public static void LaunchRamType(int type) public static void ToolDownloadThread(int value) { - ModNet.NetTaskThreadLimit = value + 1; + ModNet.NetTaskConnectionLimit = Math.Clamp(value + 1, 1, ModNet.NetTaskConnectionLimitMax); + } + + public static void ToolDownloadFileConnection(int value) + { + ModNet.NetTaskSingleFileConnectionLimit = Math.Clamp(value + 1, 1, + ModNet.NetTaskSingleFileConnectionLimitMax); } public static void ToolDownloadSpeed(int value) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs index 8cfcce2474..565e58c0e2 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs @@ -153,9 +153,9 @@ public static string McSkinDownload(string address) { if (!File.Exists(fileAddress)) { - FileDownloader.DownloadAsync(address, fileAddress + ModNet.netDownloadEnd).GetAwaiter().GetResult(); + FileDownloader.DownloadAsync(address, fileAddress + ModNet.NetDownloadEnd).GetAwaiter().GetResult(); File.Delete(fileAddress); - FileSystem.Rename(fileAddress + ModNet.netDownloadEnd, fileAddress); + FileSystem.Rename(fileAddress + ModNet.NetDownloadEnd, fileAddress); ModBase.Log("[Minecraft] 皮肤下载成功:" + fileAddress); } diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs new file mode 100644 index 0000000000..76be53b9a6 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -0,0 +1,420 @@ +using System.Buffers; +using System.Collections.Concurrent; +using System.Diagnostics; +using System.IO; +using System.Net; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Channels; +using Microsoft.Win32.SafeHandles; + +namespace PCL.Network; + +/// 为支持 HTTP Range 的大文件提供动态分段下载与慢连接恢复。 +internal sealed class AdaptiveRangeDownloader +{ + private const long SmallFileThreshold = 32L * 1024 * 1024; + private const long TargetSegmentSize = 8L * 1024 * 1024; + private const int MaxSegmentCount = 1024; + private const int MaxExpandedSegmentCount = MaxSegmentCount * 2; + private const int BufferSize = 64 * 1024; + private const int ReadTimeoutMilliseconds = 15_000; + private const int SlowCheckSeconds = 8; + private const long SlowSplitThreshold = 2L * 1024 * 1024; + private const long MinimumRestartRemaining = 1L * 1024 * 1024; + private const int MaxSegmentRetries = 2; + + private readonly string _url; + private readonly string _tempPath; + private readonly bool _useBrowserUserAgent; + private readonly string _customUserAgent; + private readonly DownloadFile? _trackedFile; + + private AdaptiveRangeDownloader(string url, string localPath, bool useBrowserUserAgent, string customUserAgent, + DownloadFile? trackedFile) + { + _url = url; + _tempPath = localPath + ModNet.NetDownloadEnd; + _useBrowserUserAgent = useBrowserUserAgent; + _customUserAgent = customUserAgent; + _trackedFile = trackedFile; + } + + /// 若文件适合 Range 分段下载则完成下载并返回 true,否则返回 false 交给顺序下载器处理。 + public static async Task TryDownloadAsync(string url, string localPath, bool useBrowserUserAgent, + string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile) + { + var downloader = new AdaptiveRangeDownloader(url, localPath, useBrowserUserAgent, customUserAgent, trackedFile); + var probe = await downloader.ProbeAsync(cancellationToken).ConfigureAwait(false); + if (probe is null || probe.Value.Size < SmallFileThreshold) + return false; + + await downloader.DownloadAsync(probe.Value.Size, cancellationToken).ConfigureAwait(false); + return true; + } + + private async Task ProbeAsync(CancellationToken cancellationToken) + { + using var connection = await DownloadResourceManager.AcquireConnectionAsync(_url, cancellationToken) + .ConfigureAwait(false); + using var request = CreateRequest(HttpMethod.Get); + request.Headers.Range = new RangeHeaderValue(0, 0); + using var response = await FileDownloader.GetHttpClient(_url) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + + if (response.StatusCode != HttpStatusCode.PartialContent) + return null; + + var range = response.Content.Headers.ContentRange; + if (range?.Unit != "bytes" || range.From != 0 || range.To != 0 || range.Length is not > 0) + return null; + + return new RangeProbe(range.Length.Value); + } + + private async Task DownloadAsync(long totalSize, CancellationToken cancellationToken) + { + NotifyStarted(totalSize); + var segments = Channel.CreateUnbounded(new UnboundedChannelOptions + { + SingleReader = false, + SingleWriter = false, + AllowSynchronousContinuations = false + }); + + var segmentCount = EnqueueInitialSegments(segments.Writer, totalSize); + var workerCount = Math.Min(segmentCount, Math.Clamp(ModNet.NetTaskSingleFileConnectionLimit, 1, + ModNet.NetTaskSingleFileConnectionLimitMax)); + using var linkedCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + await using var output = new FileStream(_tempPath, FileMode.Create, FileAccess.Write, FileShare.Read, + bufferSize: 1, FileOptions.Asynchronous | FileOptions.RandomAccess); + output.SetLength(totalSize); + + var session = new DownloadSession(this, segments, totalSize, segmentCount, output.SafeFileHandle, + linkedCancellation); + var workers = Enumerable.Range(0, workerCount).Select(_ => session.RunWorkerAsync()).ToArray(); + try + { + await Task.WhenAll(workers).ConfigureAwait(false); + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + } + finally + { + segments.Writer.TryComplete(); + } + + if (session.DownloadedBytes != totalSize) + throw new IOException($"分段下载不完整:已写入 {session.DownloadedBytes},应为 {totalSize}"); + + NotifyProgress(totalSize, 0, force: true); + } + + private int EnqueueInitialSegments(ChannelWriter writer, long totalSize) + { + var count = (int)Math.Clamp((totalSize + TargetSegmentSize - 1) / TargetSegmentSize, 1, MaxSegmentCount); + var segmentSize = (totalSize + count - 1) / count; + var id = 0; + for (long start = 0; start < totalSize; start += segmentSize) + { + var end = Math.Min(totalSize - 1, start + segmentSize - 1); + writer.TryWrite(new DownloadSegment(Interlocked.Increment(ref id), start, end)); + } + + return id; + } + + private HttpRequestMessage CreateRequest(HttpMethod method) + { + var request = FileDownloader.CreateDownloadRequest(_url, _useBrowserUserAgent, _customUserAgent); + request.Method = method; + request.Headers.AcceptEncoding.Clear(); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("identity")); + return request; + } + + private void NotifyStarted(long totalSize) + { + if (_trackedFile is null) + return; + + _trackedFile.State = NetState.Downloading; + _trackedFile.TotalSize = totalSize; + _trackedFile.IsUnknownSize = false; + _trackedFile.DownloadedBytes = 0; + _trackedFile.Speed = 0; + _trackedFile.ActiveThreads = 0; + } + + private void NotifyProgress(long downloaded, int activeConnections, bool force = false) + { + if (_trackedFile is null) + return; + + _trackedFile.State = NetState.Downloading; + _trackedFile.DownloadedBytes = downloaded; + _trackedFile.ActiveThreads = activeConnections; + } + + private readonly record struct RangeProbe(long Size); + + private sealed class DownloadSegment(int id, long start, long end) + { + public int Id { get; } = id; + public long Start { get; } = start; + public long End { get; set; } = end; + public long Downloaded { get; set; } + public int FailureCount { get; set; } + public long Remaining => End - Start - Downloaded + 1; + public long CurrentOffset => Start + Downloaded; + } + + private sealed class DownloadSession + { + private readonly AdaptiveRangeDownloader _owner; + private readonly Channel _segments; + private readonly long _totalSize; + private readonly SafeFileHandle _fileHandle; + private readonly CancellationTokenSource _cancellation; + private readonly ConcurrentDictionary _rates = new(); + // 尾段可能已没有其他活跃连接,保留近期完成分段作为比较基线。 + private readonly ConcurrentQueue _completedRates = new(); + private readonly object _progressLock = new(); + private int _outstandingSegments; + private int _nextSegmentId; + private int _activeConnections; + private long _downloadedBytes; + private long _lastProgressBytes; + private long _lastProgressTick = Stopwatch.GetTimestamp(); + private Exception? _failure; + + public DownloadSession(AdaptiveRangeDownloader owner, Channel segments, long totalSize, + int segmentCount, SafeFileHandle fileHandle, CancellationTokenSource cancellation) + { + _owner = owner; + _segments = segments; + _totalSize = totalSize; + _outstandingSegments = segmentCount; + _nextSegmentId = segmentCount; + _fileHandle = fileHandle; + _cancellation = cancellation; + } + + public long DownloadedBytes => Interlocked.Read(ref _downloadedBytes); + + public async Task RunWorkerAsync() + { + try + { + while (await _segments.Reader.WaitToReadAsync(_cancellation.Token).ConfigureAwait(false)) + { + while (_segments.Reader.TryRead(out var segment)) + { + try + { + await DownloadSegmentAsync(segment, _cancellation.Token).ConfigureAwait(false); + if (Interlocked.Decrement(ref _outstandingSegments) == 0) + _segments.Writer.TryComplete(); + } + catch (OperationCanceledException) when (_cancellation.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + if (TryRecover(segment, ex)) + { + ModBase.Log(ex, $"[Download] 分段下载缓慢或失败,正在重试:{_owner._url}", ModBase.LogLevel.Debug); + await Task.Delay(Random.Shared.Next(250, 1001), _cancellation.Token).ConfigureAwait(false); + continue; + } + + Interlocked.CompareExchange(ref _failure, ex, null); + _segments.Writer.TryComplete(ex); + _cancellation.Cancel(); + throw; + } + } + } + } + catch (OperationCanceledException) when (_failure is not null) + { + throw _failure; + } + } + + private bool TryRecover(DownloadSegment segment, Exception exception) + { + if (exception is not (SlowSegmentException or IOException or HttpRequestException or TaskCanceledException)) + return false; + if (++segment.FailureCount > MaxSegmentRetries || segment.Remaining <= 0) + return false; + + if (segment.Remaining >= SlowSplitThreshold && Volatile.Read(ref _nextSegmentId) < MaxExpandedSegmentCount) + { + var splitStart = segment.CurrentOffset + segment.Remaining / 2; + var split = new DownloadSegment(Interlocked.Increment(ref _nextSegmentId), splitStart, segment.End); + segment.End = splitStart - 1; + _segments.Writer.TryWrite(segment); + _segments.Writer.TryWrite(split); + Interlocked.Increment(ref _outstandingSegments); + } + else + { + _segments.Writer.TryWrite(segment); + } + + return true; + } + + private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationToken cancellationToken) + { + using var connection = await DownloadResourceManager.AcquireConnectionAsync(_owner._url, cancellationToken) + .ConfigureAwait(false); + var activeConnections = Interlocked.Increment(ref _activeConnections); + var startedAt = Stopwatch.GetTimestamp(); + var attemptBytes = 0L; + try + { + using var request = _owner.CreateRequest(HttpMethod.Get); + request.Headers.Range = new RangeHeaderValue(segment.CurrentOffset, segment.End); + using var response = await FileDownloader.GetHttpClient(_owner._url) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + ValidateRangeResponse(response, segment); + + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + while (segment.Remaining > 0) + { + using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(BufferSize, cancellationToken) + .ConfigureAwait(false); + var buffer = ArrayPool.Shared.Rent(BufferSize); + try + { + int read; + using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + { + readTimeout.CancelAfter(ReadTimeoutMilliseconds); + try + { + read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), readTimeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && + readTimeout.IsCancellationRequested) + { + throw new SlowSegmentException("分段在等待数据时超时"); + } + } + + if (read == 0) + break; + read = (int)Math.Min(read, segment.Remaining); + + await DownloadResourceManager.ThrottleAsync(read, cancellationToken).ConfigureAwait(false); + await RandomAccess.WriteAsync(_fileHandle, buffer.AsMemory(0, read), segment.CurrentOffset, + cancellationToken).ConfigureAwait(false); + + segment.Downloaded += read; + attemptBytes += read; + var downloaded = Interlocked.Add(ref _downloadedBytes, read); + ReportRateAndProgress(segment, attemptBytes, startedAt, downloaded, activeConnections); + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + if (segment.Remaining > 0) + throw new IOException($"分段 {segment.Id} 提前结束,还剩 {segment.Remaining} 字节"); + + AddCompletedRate(attemptBytes, startedAt); + } + finally + { + _rates.TryRemove(segment.Id, out _); + activeConnections = Interlocked.Decrement(ref _activeConnections); + ReportProgress(Interlocked.Read(ref _downloadedBytes), activeConnections, force: true); + } + } + + private void ReportRateAndProgress(DownloadSegment segment, long attemptBytes, long startedAt, long downloaded, + int activeConnections) + { + var now = Stopwatch.GetTimestamp(); + var elapsedSeconds = Math.Max(0.001, (double)(now - startedAt) / Stopwatch.Frequency); + _rates[segment.Id] = new RateSample(attemptBytes / elapsedSeconds, now); + + if (IsSignificantlySlow(segment, attemptBytes, startedAt, now)) + throw new SlowSegmentException("分段速度明显低于其他活跃连接"); + + ReportProgress(downloaded, activeConnections); + } + + private bool IsSignificantlySlow(DownloadSegment segment, long attemptBytes, long startedAt, long now) + { + if (ModNet.NetTaskSpeedLimitHigh > 0 || segment.Remaining < MinimumRestartRemaining) + return false; + + var elapsedSeconds = (double)(now - startedAt) / Stopwatch.Frequency; + if (elapsedSeconds < SlowCheckSeconds) + return false; + + var rates = _rates.Where(pair => pair.Key != segment.Id && + now - pair.Value.UpdatedAt <= Stopwatch.Frequency * 2 && + pair.Value.BytesPerSecond > 0) + .Select(pair => pair.Value.BytesPerSecond) + .ToArray(); + if (rates.Length == 0) + rates = _completedRates.Where(rate => rate > 0).ToArray(); + if (rates.Length == 0) + return false; + + Array.Sort(rates); + var median = rates[rates.Length / 2]; + var ownRate = attemptBytes / elapsedSeconds; + return median >= 128 * 1024 && ownRate * 4 < median; + } + + private void AddCompletedRate(long attemptBytes, long startedAt) + { + var elapsedSeconds = Math.Max(0.001, (double)(Stopwatch.GetTimestamp() - startedAt) / Stopwatch.Frequency); + _completedRates.Enqueue(attemptBytes / elapsedSeconds); + while (_completedRates.Count > 8) + _completedRates.TryDequeue(out _); + } + + private void ReportProgress(long downloaded, int activeConnections, bool force = false) + { + lock (_progressLock) + { + var now = Stopwatch.GetTimestamp(); + if (!force && now - _lastProgressTick < Stopwatch.Frequency / 5) + return; + + var elapsed = Math.Max(1L, now - _lastProgressTick); + var speed = Math.Max(0L, (long)((downloaded - _lastProgressBytes) * (double)Stopwatch.Frequency / elapsed)); + _owner.NotifyProgress(downloaded, activeConnections, force); + if (_owner._trackedFile is not null) + _owner._trackedFile.Speed = speed; + + _lastProgressBytes = downloaded; + _lastProgressTick = now; + } + } + + private void ValidateRangeResponse(HttpResponseMessage response, DownloadSegment segment) + { + if (response.StatusCode != HttpStatusCode.PartialContent) + throw new IOException($"服务器未按 Range 返回分段,状态码:{(int)response.StatusCode}"); + + var range = response.Content.Headers.ContentRange; + if (range?.Unit != "bytes" || range.From != segment.CurrentOffset || range.To != segment.End || + range.Length != _totalSize) + throw new IOException("服务器返回的 Content-Range 与请求分段不一致"); + } + + private readonly record struct RateSample(double BytesPerSecond, long UpdatedAt); + } + + private sealed class SlowSegmentException(string message) : IOException(message); +} diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs new file mode 100644 index 0000000000..e31ecbc23b --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs @@ -0,0 +1,145 @@ +using System.Diagnostics; +using System.Collections.Concurrent; + +namespace PCL.Network; + +/// 协调所有下载任务共享的连接数、缓冲区和限速额度。 +internal static class DownloadResourceManager +{ + private static readonly AsyncQuota ConnectionQuota = new(); + private static readonly AsyncQuota BufferQuota = new(); + private static readonly ConcurrentDictionary HostConnectionQuotas = new(StringComparer.OrdinalIgnoreCase); + private static readonly object BandwidthLock = new(); + private static long _nextBandwidthTick; + + public static async ValueTask AcquireConnectionAsync(string url, + CancellationToken cancellationToken) + { + var host = Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : url; + var hostQuota = HostConnectionQuotas.GetOrAdd(host, static _ => new AsyncQuota()); + var hostLease = await hostQuota.AcquireAsync(1, () => ModNet.NetTaskConnectionsPerHostLimit, cancellationToken) + .ConfigureAwait(false); + try + { + var globalLease = await ConnectionQuota.AcquireAsync(1, + () => Math.Clamp(ModNet.NetTaskConnectionLimit, 1, ModNet.NetTaskConnectionLimitMax), cancellationToken) + .ConfigureAwait(false); + return new DownloadConnectionLease(globalLease, hostLease); + } + catch + { + hostLease.Dispose(); + throw; + } + } + + public static ValueTask ReserveBufferAsync(int bytes, CancellationToken cancellationToken) + { + return BufferQuota.AcquireAsync(bytes, () => ModNet.NetTaskBufferBudgetBytes, cancellationToken); + } + + public static Task ThrottleAsync(int bytes, CancellationToken cancellationToken) + { + var limit = ModNet.NetTaskSpeedLimitHigh; + if (limit <= 0 || bytes <= 0) + return Task.CompletedTask; + + long delayTicks; + lock (BandwidthLock) + { + var now = Stopwatch.GetTimestamp(); + var availableAt = Math.Max(now, _nextBandwidthTick); + var duration = Math.Max(1L, (long)Math.Ceiling((double)bytes * Stopwatch.Frequency / limit)); + _nextBandwidthTick = availableAt + duration; + delayTicks = availableAt - now; + } + + return delayTicks <= 0 + ? Task.CompletedTask + : Task.Delay(TimeSpan.FromSeconds((double)delayTicks / Stopwatch.Frequency), cancellationToken); + } +} + +internal sealed class DownloadConnectionLease(DownloadQuotaLease globalLease, DownloadQuotaLease hostLease) : IDisposable +{ + private DownloadQuotaLease? _globalLease = globalLease; + private DownloadQuotaLease? _hostLease = hostLease; + + public void Dispose() + { + Interlocked.Exchange(ref _globalLease, null)?.Dispose(); + Interlocked.Exchange(ref _hostLease, null)?.Dispose(); + } +} + +internal sealed class DownloadQuotaLease : IDisposable +{ + private AsyncQuota? _quota; + private readonly long _amount; + + internal DownloadQuotaLease(AsyncQuota quota, long amount) + { + _quota = quota; + _amount = amount; + } + + public void Dispose() + { + Interlocked.Exchange(ref _quota, null)?.Release(_amount); + } +} + +internal sealed class AsyncQuota +{ + private readonly object _lock = new(); + private readonly List _waiters = new(); + private long _used; + + public async ValueTask AcquireAsync(long amount, Func getCapacity, + CancellationToken cancellationToken) + { + ArgumentOutOfRangeException.ThrowIfNegativeOrZero(amount); + + while (true) + { + TaskCompletionSource? waiter = null; + lock (_lock) + { + var capacity = Math.Max(amount, getCapacity()); + if (_used + amount <= capacity) + { + _used += amount; + return new DownloadQuotaLease(this, amount); + } + + waiter = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + _waiters.Add(waiter); + } + + try + { + await waiter.Task.WaitAsync(cancellationToken).ConfigureAwait(false); + } + catch + { + lock (_lock) + _waiters.Remove(waiter); + throw; + } + } + } + + public void Release(long amount) + { + TaskCompletionSource[] waiters; + lock (_lock) + { + _used = Math.Max(0, _used - amount); + waiters = _waiters.ToArray(); + _waiters.Clear(); + } + + foreach (var waiter in waiters) + waiter.TrySetResult(); + } +} diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 794a76dd66..a016a9ece6 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -1,6 +1,8 @@ +using System.Buffers; +using System.Diagnostics; using System.IO; +using System.Net.Http.Headers; using System.Net.Http; -using Downloader; using PCL.Core.IO.Net; @@ -77,118 +79,168 @@ private static async Task DownloadSingleAsync(string url, string localPath, bool ModBase.Log($"[Download] 开始下载:{url} -> {localPath}"); CleanupTempFiles(localPath); - var perFileThreadLimit = enableParallelChunks ? Math.Max(1, ModNet.NetTaskThreadLimit) : 1; - // 限制最大分块数,防止大文件下载时内存爆炸 - var chunkCount = Math.Min(perFileThreadLimit, 4); - var configuration = new DownloadConfiguration + if (enableParallelChunks && await AdaptiveRangeDownloader.TryDownloadAsync(url, localPath, + useBrowserUserAgent, customUserAgent, cancellationToken, trackedFile).ConfigureAwait(false)) { - ChunkCount = chunkCount, - ParallelCount = chunkCount, - ParallelDownload = chunkCount > 1, - MaximumBytesPerSecond = ModNet.NetTaskSpeedLimitHigh > 0 ? ModNet.NetTaskSpeedLimitHigh : 0, - MaxTryAgainOnFailure = 2, - BlockTimeout = 60000, - DownloadFileExtension = ModNet.netDownloadEnd, - EnableAutoResumeDownload = false, - CustomHttpClientFactory = () => GetHttpClient(url), - MinimumSizeOfChunking = 1024 * 1024L, - MaximumMemoryBufferBytes = 256L * 1024 * 1024, - }; - - using var downloader = new DownloadService(configuration); - using var cancelReg = cancellationToken.Register(() => - { - try { downloader.CancelAsync(); } catch { } // 忽略 - }); - var tcs = new TaskCompletionSource(); - void UpdateDownloadStat(DownloadProgressChangedEventArgs args) - { - if (trackedFile is null) - return; - - trackedFile.State = PCL.Network.NetState.Downloading; - trackedFile.TotalSize = Math.Max(trackedFile.TotalSize, args.TotalBytesToReceive); - trackedFile.IsUnknownSize = trackedFile.TotalSize <= 0; - trackedFile.DownloadedBytes = Math.Max(trackedFile.DownloadedBytes, args.ReceivedBytesSize); - trackedFile.Speed = Math.Max(0L, (long)Math.Round(args.BytesPerSecondSpeed)); - trackedFile.ActiveThreads = Math.Max(0, args.ActiveChunks); + PromoteTempFile(localPath); + if (!File.Exists(localPath)) + throw new IOException($"分段下载未产生任何文件:{localPath}"); + MarkDownloadCompleted(trackedFile); + ModBase.Log($"[Download] 分段下载成功:{localPath}"); + return; } - downloader.DownloadStarted += (_, args) => - { - if (trackedFile is null) - return; + await DownloadSequentiallyAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, + trackedFile).ConfigureAwait(false); + } + + private static async Task DownloadSequentiallyAsync(string url, string localPath, bool useBrowserUserAgent, + string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile) + { + const int bufferSize = 64 * 1024; + const int readTimeoutMilliseconds = 60_000; + using var connection = await DownloadResourceManager.AcquireConnectionAsync(url, cancellationToken) + .ConfigureAwait(false); + using var request = CreateDownloadRequest(url, useBrowserUserAgent, customUserAgent); + request.Headers.AcceptEncoding.Clear(); + request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("identity")); + using var response = await GetHttpClient(url) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + throw new HttpRequestException($"下载请求失败:{(int)response.StatusCode} {response.ReasonPhrase}"); - trackedFile.State = PCL.Network.NetState.Reading; - trackedFile.TotalSize = Math.Max(trackedFile.TotalSize, args.TotalBytesToReceive); - trackedFile.IsUnknownSize = args.TotalBytesToReceive <= 0; + var totalSize = response.Content.Headers.ContentLength ?? -1; + if (trackedFile is not null) + { + trackedFile.State = PCL.Network.NetState.Downloading; + trackedFile.TotalSize = totalSize; + trackedFile.IsUnknownSize = totalSize <= 0; trackedFile.DownloadedBytes = 0; trackedFile.Speed = 0; - trackedFile.ActiveThreads = 0; - }; - downloader.DownloadProgressChanged += (_, args) => UpdateDownloadStat(args); - downloader.ChunkDownloadProgressChanged += (_, args) => UpdateDownloadStat(args); - downloader.DownloadFileCompleted += (_, args) => - { - if (trackedFile is not null) - { - trackedFile.Speed = 0; - trackedFile.ActiveThreads = 0; - trackedFile.DownloadedBytes = Math.Max(trackedFile.DownloadedBytes, trackedFile.TotalSize); - } + trackedFile.ActiveThreads = 1; + } - if (args.Cancelled) - tcs.TrySetCanceled(); - else if (args.Error != null) - tcs.TrySetException(args.Error); - else - tcs.TrySetResult(true); - }; - try + long downloaded = 0; + long lastProgressBytes = 0; + var lastProgressTick = Stopwatch.GetTimestamp(); + await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using var output = new FileStream(localPath + ModNet.NetDownloadEnd, FileMode.Create, FileAccess.Write, + FileShare.Read, bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan); + while (true) { - await downloader.DownloadFileTaskAsync(url, localPath, cancellationToken).ConfigureAwait(false); - await tcs.Task.ConfigureAwait(false); - var tempPath = localPath + ModNet.netDownloadEnd; - if (!File.Exists(localPath) && File.Exists(tempPath)) + using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(bufferSize, cancellationToken) + .ConfigureAwait(false); + var buffer = ArrayPool.Shared.Rent(bufferSize); + try { - for (var retry = 0; retry < 5; retry++) + int read; + using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) { + readTimeout.CancelAfter(readTimeoutMilliseconds); try { - File.Move(tempPath, localPath, true); - break; + read = await input.ReadAsync(buffer.AsMemory(0, bufferSize), readTimeout.Token) + .ConfigureAwait(false); } - catch (IOException) + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && + readTimeout.IsCancellationRequested) { - Thread.Sleep(100); + throw new TimeoutException($"下载超时({url})"); } } + + if (read == 0) + break; + + await DownloadResourceManager.ThrottleAsync(read, cancellationToken).ConfigureAwait(false); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + downloaded += read; + UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); + } + finally + { + ArrayPool.Shared.Return(buffer); } - if (!File.Exists(localPath)) - throw new IOException($"下载未产生任何文件:{localPath}"); - ModBase.Log($"[Download] 下载成功:{localPath}"); - } - catch (TaskCanceledException ex) when (cancellationToken.IsCancellationRequested) - { - throw new OperationCanceledException(cancellationToken); - } - catch (TaskCanceledException ex) - { - throw new TimeoutException($"下载超时({url})", ex); } - catch (OperationCanceledException) + + await output.FlushAsync(cancellationToken).ConfigureAwait(false); + await output.DisposeAsync().ConfigureAwait(false); + if (totalSize >= 0 && downloaded != totalSize) + throw new IOException($"下载不完整:已写入 {downloaded},应为 {totalSize}"); + + if (trackedFile is not null && totalSize <= 0) { - throw; + trackedFile.TotalSize = downloaded; + trackedFile.IsUnknownSize = false; + trackedFile.DownloadedBytes = downloaded; } - catch (Exception ex) + + PromoteTempFile(localPath); + if (!File.Exists(localPath)) + throw new IOException($"下载未产生任何文件:{localPath}"); + MarkDownloadCompleted(trackedFile); + ModBase.Log($"[Download] 顺序下载成功:{localPath}"); + } + + private static void UpdateSequentialProgress(DownloadFile? trackedFile, long downloaded, long totalSize, + ref long lastProgressBytes, ref long lastProgressTick) + { + if (trackedFile is null) + return; + + var now = Stopwatch.GetTimestamp(); + if (now - lastProgressTick < Stopwatch.Frequency / 5 && (totalSize <= 0 || downloaded < totalSize)) + return; + + var elapsed = Math.Max(1L, now - lastProgressTick); + trackedFile.DownloadedBytes = downloaded; + trackedFile.Speed = Math.Max(0L, (long)((downloaded - lastProgressBytes) * (double)Stopwatch.Frequency / elapsed)); + trackedFile.ActiveThreads = 1; + lastProgressBytes = downloaded; + lastProgressTick = now; + } + + internal static HttpRequestMessage CreateDownloadRequest(string url, bool useBrowserUserAgent, + string customUserAgent) + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + RequestSigning.SecretHeadersSign(url, ref request, useBrowserUserAgent, customUserAgent); + return request; + } + + private static void MarkDownloadCompleted(DownloadFile? trackedFile) + { + if (trackedFile is null) + return; + + trackedFile.Speed = 0; + trackedFile.ActiveThreads = 0; + trackedFile.DownloadedBytes = Math.Max(trackedFile.DownloadedBytes, trackedFile.TotalSize); + } + + private static void PromoteTempFile(string localPath) + { + var tempPath = localPath + ModNet.NetDownloadEnd; + if (File.Exists(localPath) || !File.Exists(tempPath)) + return; + + for (var retry = 0; retry < 5; retry++) { - throw new IOException($"下载失败:{url}", ex); + try + { + File.Move(tempPath, localPath, true); + return; + } + catch (IOException) when (retry < 4) + { + Thread.Sleep(100); + } } } private static void CleanupTempFiles(string localPath) { - var tempPath = localPath + ModNet.netDownloadEnd; + var tempPath = localPath + ModNet.NetDownloadEnd; TryDeleteFile(localPath); TryDeleteFile(tempPath); } @@ -210,7 +262,7 @@ private static void TryDeleteFile(string path) } } - private static HttpClient GetHttpClient(string url) + internal static HttpClient GetHttpClient(string url) { if (Uri.TryCreate(url, UriKind.Absolute, out var parsedUri) && parsedUri.Host is "edge.forgecdn.net" or "mediafilez.forgecdn.net" or "forgecdn.net" or "api.curseforge.com") diff --git a/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs b/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs index fe461a8af3..67882123bd 100644 --- a/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs +++ b/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs @@ -5,8 +5,25 @@ namespace PCL.Network; public static class ModNet { - public const string netDownloadEnd = ".PCLDownloading"; - public static int NetTaskThreadLimit { get; set; } = 16; + public const string NetDownloadEnd = ".PCLDownloading"; + public const int NetTaskConnectionLimitMax = 256; + public const int NetTaskSingleFileConnectionLimitMax = 8; + public const int NetTaskConnectionsPerHostLimit = 8; + public const long NetTaskBufferBudgetBytes = 512L * 1024 * 1024; + + /// 所有下载任务共享的最大活跃 HTTP 连接数。 + public static int NetTaskConnectionLimit { get; set; } = 64; + + /// 单个大文件可同时使用的最大连接数。 + public static int NetTaskSingleFileConnectionLimit { get; set; } = 8; + + [Obsolete("请使用 NetTaskConnectionLimit。")] + public static int NetTaskThreadLimit + { + get => NetTaskConnectionLimit; + set => NetTaskConnectionLimit = value; + } + public static long NetTaskSpeedLimitLow { get; set; } = 256 * 1024L; public static long NetTaskSpeedLimitHigh { get; set; } = -1; public static long NetTaskSpeedLimitLeft { get; set; } = -1; diff --git a/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs b/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs index 7b90d80e17..b42eb33aa3 100644 --- a/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs +++ b/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs @@ -145,16 +145,18 @@ public static async Task DownloadFileOnceAsync(string url, string filePath) public static DownloadService CreateDownloadService(string url, bool useBrowserUserAgent = false) { - var chunkCount = Math.Min(Math.Max(1, ModNet.NetTaskThreadLimit), 4); + var chunkCount = Math.Min(Math.Clamp(ModNet.NetTaskConnectionLimit, 1, + ModNet.NetTaskSingleFileConnectionLimitMax), ModNet.NetTaskSingleFileConnectionLimit); return new DownloadService(new DownloadConfiguration { ChunkCount = chunkCount, ParallelCount = chunkCount, ParallelDownload = chunkCount > 1, MaximumBytesPerSecond = ModNet.NetTaskSpeedLimitHigh > 0 ? ModNet.NetTaskSpeedLimitHigh : 0, - DownloadFileExtension = ModNet.netDownloadEnd, + DownloadFileExtension = ModNet.NetDownloadEnd, EnableAutoResumeDownload = false, - MaximumMemoryBufferBytes = 256L * 1024 * 1024, + MaximumMemoryBufferBytes = 2L * 1024 * 1024, + BufferBlockSize = 64 * 1024, RequestConfiguration = DownloadRequestFactory.Create(url, useBrowserUserAgent) }); } diff --git a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs index 6ed38c24a5..3e15b22e6a 100644 --- a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs +++ b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs @@ -105,7 +105,8 @@ private void Run(CancellationToken cancellationToken) private int GetMaxParallelFiles() { - return Math.Max(1, Math.Min(files.Count, Math.Clamp(ModNet.NetTaskThreadLimit, 1, 64))); + return Math.Max(1, Math.Min(files.Count, + Math.Clamp(ModNet.NetTaskConnectionLimit, 1, ModNet.NetTaskConnectionLimitMax))); } private async Task ProcessFileAsync(PCL.Network.DownloadFile file, CancellationToken cancellationToken) @@ -130,7 +131,8 @@ private async Task ProcessFileAsync(PCL.Network.DownloadFile file, CancellationT } file.State = PCL.Network.NetState.Connecting; - var enableParallelChunks = files.Count <= 1; + // 全局资源管理器会限制总连接数,因此多个大文件也可安全地使用分段下载。 + const bool enableParallelChunks = true; for (var retry = 0; retry < 4; retry++) { cancellationToken.ThrowIfCancellationRequested(); diff --git a/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs b/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs index f12ea9745b..a9034889e6 100644 --- a/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs @@ -41,7 +41,7 @@ public long Speed } } - public int ThreadCount + public int ConnectionCount { get { @@ -50,6 +50,9 @@ public int ThreadCount } } + [Obsolete("请使用 ConnectionCount。")] + public int ThreadCount => ConnectionCount; + public void Start(PCL.Network.Loaders.LoaderDownload task) { lock (LockFiles) diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml index 4ca2f95b14..566a13eed7 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml @@ -23,6 +23,7 @@ + - + + - - - - + + diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs index 9498381bae..5a39fe6c1a 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs @@ -125,6 +125,7 @@ private static void SetByTag(string tag, object value) private void SliderLoad() { SliderDownloadThread.getHintText = new Func(v => (int)v + 1); + SliderDownloadFileConnection.getHintText = new Func(v => (int)v + 1); SliderDownloadSpeed.getHintText = new Func(v => { int value = (int)v; @@ -144,7 +145,7 @@ private void SliderLoad() private void SliderDownloadThread_PreviewChange(object sender, ModBase.RouteEventArgs e) { - if (SliderDownloadThread.Value < 100) + if (SliderDownloadThread.Value < 64) return; if (!States.Hint.LargeDownloadThread) { diff --git a/Plain Craft Launcher 2/Pages/PageSpeedLeft.xaml.cs b/Plain Craft Launcher 2/Pages/PageSpeedLeft.xaml.cs index f35f55fefc..ed87ac5b10 100644 --- a/Plain Craft Launcher 2/Pages/PageSpeedLeft.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageSpeedLeft.xaml.cs @@ -39,7 +39,7 @@ private void Page_Loaded(object sender, RoutedEventArgs e) timer.Tick += (_, _) => Watcher(); timer.Start(); - // 非调试模式隐藏线程数 + // 非调试模式隐藏连接数 if (!ModBase.modeDebug) { RowDefinitions[12].Height = new GridLength(0d); @@ -63,7 +63,7 @@ private void Watcher() LabProgress.Text = Lang.Number(1d, "P0"); LabSpeed.Text = ModBase.GetString(0) + "/s"; LabFile.Text = Lang.Number(0, "N0"); - LabThread.Text = Lang.Number(0, "N0") + " / " + Lang.Number(ModNet.NetTaskThreadLimit, "N0"); + LabThread.Text = Lang.Number(0, "N0") + " / " + Lang.Number(ModNet.NetTaskConnectionLimit, "N0"); } else { @@ -78,8 +78,8 @@ private void Watcher() LabProgress.Text = rawPercent > 0.999999d ? Lang.Number(1d, "P0") : predictText; LabSpeed.Text = ModBase.GetString(ModNet.NetManager.Speed) + "/s"; LabFile.Text = ModNet.NetManager.FileRemain < 0 ? "0*" : Lang.Number(ModNet.NetManager.FileRemain, "N0"); - LabThread.Text = Lang.Number(ModNet.NetManager.ThreadCount, "N0") + " / " + - Lang.Number(ModNet.NetTaskThreadLimit, "N0"); + LabThread.Text = Lang.Number(ModNet.NetManager.ConnectionCount, "N0") + " / " + + Lang.Number(ModNet.NetTaskConnectionLimit, "N0"); } } From faf9a2a67e31c837d164e9a21df0f6eab4839a82 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:45:42 +0800 Subject: [PATCH 02/20] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=B9=B6=E5=8F=91?= =?UTF-8?q?=E9=99=90=E5=88=B6=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Network/Downloader/AdaptiveRangeDownloader.cs | 7 ++++++- .../Modules/Network/Downloader/FileDownloader.cs | 3 ++- Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs | 3 ++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index 76be53b9a6..1dc01a0fde 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -42,8 +42,13 @@ private AdaptiveRangeDownloader(string url, string localPath, bool useBrowserUse /// 若文件适合 Range 分段下载则完成下载并返回 true,否则返回 false 交给顺序下载器处理。 public static async Task TryDownloadAsync(string url, string localPath, bool useBrowserUserAgent, - string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile) + string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile, + long expectedSize = -1) { + // 下载清单已有大小时,小文件直接走顺序请求,避免额外的 Range 探测往返。 + if (expectedSize >= 0 && expectedSize < SmallFileThreshold) + return false; + var downloader = new AdaptiveRangeDownloader(url, localPath, useBrowserUserAgent, customUserAgent, trackedFile); var probe = await downloader.ProbeAsync(cancellationToken).ConfigureAwait(false); if (probe is null || probe.Value.Size < SmallFileThreshold) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index a016a9ece6..95bfc44830 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -80,7 +80,8 @@ private static async Task DownloadSingleAsync(string url, string localPath, bool CleanupTempFiles(localPath); if (enableParallelChunks && await AdaptiveRangeDownloader.TryDownloadAsync(url, localPath, - useBrowserUserAgent, customUserAgent, cancellationToken, trackedFile).ConfigureAwait(false)) + useBrowserUserAgent, customUserAgent, cancellationToken, trackedFile, + trackedFile?.Check?.actualSize ?? -1).ConfigureAwait(false)) { PromoteTempFile(localPath); if (!File.Exists(localPath)) diff --git a/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs b/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs index 67882123bd..6d5176828b 100644 --- a/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs +++ b/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs @@ -8,7 +8,8 @@ public static class ModNet public const string NetDownloadEnd = ".PCLDownloading"; public const int NetTaskConnectionLimitMax = 256; public const int NetTaskSingleFileConnectionLimitMax = 8; - public const int NetTaskConnectionsPerHostLimit = 8; + // 主机级限制不再低于全局设置;默认仍由 NetTaskConnectionLimit(64)控制。 + public const int NetTaskConnectionsPerHostLimit = NetTaskConnectionLimitMax; public const long NetTaskBufferBudgetBytes = 512L * 1024 * 1024; /// 所有下载任务共享的最大活跃 HTTP 连接数。 From 9e6235f7e5468f4d5634b868bdbb30ef2531c5b3 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Wed, 5 Aug 2026 21:58:49 +0800 Subject: [PATCH 03/20] fix performance issues --- .../Downloader/AdaptiveRangeDownloader.cs | 35 ++++++++++--------- .../Downloader/DownloadResourceManager.cs | 12 ++++--- .../Network/Downloader/FileDownloader.cs | 25 ++++++------- .../Modules/Network/Loaders/LoaderDownload.cs | 5 +-- 4 files changed, 39 insertions(+), 38 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index 1dc01a0fde..e57eb2355d 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -13,7 +13,7 @@ namespace PCL.Network; /// 为支持 HTTP Range 的大文件提供动态分段下载与慢连接恢复。 internal sealed class AdaptiveRangeDownloader { - private const long SmallFileThreshold = 32L * 1024 * 1024; + internal const long SmallFileThreshold = 32L * 1024 * 1024; private const long TargetSegmentSize = 8L * 1024 * 1024; private const int MaxSegmentCount = 1024; private const int MaxExpandedSegmentCount = MaxSegmentCount * 2; @@ -169,6 +169,7 @@ private sealed class DownloadSegment(int id, long start, long end) public long End { get; set; } = end; public long Downloaded { get; set; } public int FailureCount { get; set; } + public long LastRateSampleTick { get; set; } public long Remaining => End - Start - Downloaded + 1; public long CurrentOffset => Start + Downloaded; } @@ -287,6 +288,7 @@ private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationTok ValidateRangeResponse(response, segment); await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); while (segment.Remaining > 0) { using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(BufferSize, cancellationToken) @@ -295,19 +297,16 @@ private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationTok try { int read; - using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + readTimeout.CancelAfter(ReadTimeoutMilliseconds); + try { - readTimeout.CancelAfter(ReadTimeoutMilliseconds); - try - { - read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), readTimeout.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && - readTimeout.IsCancellationRequested) - { - throw new SlowSegmentException("分段在等待数据时超时"); - } + read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), readTimeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && + readTimeout.IsCancellationRequested) + { + throw new SlowSegmentException("分段在等待数据时超时"); } if (read == 0) @@ -347,10 +346,14 @@ private void ReportRateAndProgress(DownloadSegment segment, long attemptBytes, l { var now = Stopwatch.GetTimestamp(); var elapsedSeconds = Math.Max(0.001, (double)(now - startedAt) / Stopwatch.Frequency); - _rates[segment.Id] = new RateSample(attemptBytes / elapsedSeconds, now); + if (now - segment.LastRateSampleTick >= Stopwatch.Frequency / 2) + { + segment.LastRateSampleTick = now; + _rates[segment.Id] = new RateSample(attemptBytes / elapsedSeconds, now); - if (IsSignificantlySlow(segment, attemptBytes, startedAt, now)) - throw new SlowSegmentException("分段速度明显低于其他活跃连接"); + if (IsSignificantlySlow(segment, attemptBytes, startedAt, now)) + throw new SlowSegmentException("分段速度明显低于其他活跃连接"); + } ReportProgress(downloaded, activeConnections); } diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs index e31ecbc23b..f6288affeb 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs @@ -131,15 +131,17 @@ public async ValueTask AcquireAsync(long amount, Func public void Release(long amount) { - TaskCompletionSource[] waiters; + TaskCompletionSource? waiter = null; lock (_lock) { _used = Math.Max(0, _used - amount); - waiters = _waiters.ToArray(); - _waiters.Clear(); + if (_waiters.Count > 0) + { + waiter = _waiters[0]; + _waiters.RemoveAt(0); + } } - foreach (var waiter in waiters) - waiter.TrySetResult(); + waiter?.TrySetResult(); } } diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 95bfc44830..6ef7ee82fc 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -1,7 +1,6 @@ using System.Buffers; using System.Diagnostics; using System.IO; -using System.Net.Http.Headers; using System.Net.Http; using PCL.Core.IO.Net; @@ -103,8 +102,6 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath using var connection = await DownloadResourceManager.AcquireConnectionAsync(url, cancellationToken) .ConfigureAwait(false); using var request = CreateDownloadRequest(url, useBrowserUserAgent, customUserAgent); - request.Headers.AcceptEncoding.Clear(); - request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("identity")); using var response = await GetHttpClient(url) .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) @@ -125,6 +122,7 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath long lastProgressBytes = 0; var lastProgressTick = Stopwatch.GetTimestamp(); await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + using var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await using var output = new FileStream(localPath + ModNet.NetDownloadEnd, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan); while (true) @@ -135,19 +133,16 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath try { int read; - using (var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken)) + readTimeout.CancelAfter(readTimeoutMilliseconds); + try { - readTimeout.CancelAfter(readTimeoutMilliseconds); - try - { - read = await input.ReadAsync(buffer.AsMemory(0, bufferSize), readTimeout.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && - readTimeout.IsCancellationRequested) - { - throw new TimeoutException($"下载超时({url})"); - } + read = await input.ReadAsync(buffer.AsMemory(0, bufferSize), readTimeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && + readTimeout.IsCancellationRequested) + { + throw new TimeoutException($"下载超时({url})"); } if (read == 0) diff --git a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs index 3e15b22e6a..8fc2a5ee49 100644 --- a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs +++ b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs @@ -131,8 +131,9 @@ private async Task ProcessFileAsync(PCL.Network.DownloadFile file, CancellationT } file.State = PCL.Network.NetState.Connecting; - // 全局资源管理器会限制总连接数,因此多个大文件也可安全地使用分段下载。 - const bool enableParallelChunks = true; + // 批量任务中未知大小的文件直接下载,避免小文件逐个产生一次 Range 探测。 + var expectedSize = file.Check?.actualSize ?? -1; + var enableParallelChunks = files.Count <= 1 || expectedSize >= AdaptiveRangeDownloader.SmallFileThreshold; for (var retry = 0; retry < 4; retry++) { cancellationToken.ThrowIfCancellationRequested(); From 86292aa0e9bf6994677b7f9a6215f88a8911ec6c Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:11:23 +0800 Subject: [PATCH 04/20] try fix performance issue again --- .../Downloader/AdaptiveRangeDownloader.cs | 39 ++++++++++++------- .../Downloader/DownloadResourceManager.cs | 16 +++++++- .../Network/Downloader/FileDownloader.cs | 1 + .../Modules/Network/Management/NetManager.cs | 6 +-- 4 files changed, 43 insertions(+), 19 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index e57eb2355d..153ca1b038 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -50,12 +50,25 @@ public static async Task TryDownloadAsync(string url, string localPath, bo return false; var downloader = new AdaptiveRangeDownloader(url, localPath, useBrowserUserAgent, customUserAgent, trackedFile); - var probe = await downloader.ProbeAsync(cancellationToken).ConfigureAwait(false); - if (probe is null || probe.Value.Size < SmallFileThreshold) - return false; + var totalSize = expectedSize; + if (totalSize < 0) + { + var probe = await downloader.ProbeAsync(cancellationToken).ConfigureAwait(false); + if (probe is null || probe.Value.Size < SmallFileThreshold) + return false; + totalSize = probe.Value.Size; + } - await downloader.DownloadAsync(probe.Value.Size, cancellationToken).ConfigureAwait(false); - return true; + try + { + await downloader.DownloadAsync(totalSize, cancellationToken).ConfigureAwait(false); + return true; + } + catch (RangeNotSupportedException ex) + { + ModBase.Log(ex, $"[Download] 下载源不支持可靠的 Range,改用顺序下载:{url}", ModBase.LogLevel.Debug); + return false; + } } private async Task ProbeAsync(CancellationToken cancellationToken) @@ -276,7 +289,7 @@ private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationTok { using var connection = await DownloadResourceManager.AcquireConnectionAsync(_owner._url, cancellationToken) .ConfigureAwait(false); - var activeConnections = Interlocked.Increment(ref _activeConnections); + Interlocked.Increment(ref _activeConnections); var startedAt = Stopwatch.GetTimestamp(); var attemptBytes = 0L; try @@ -320,7 +333,7 @@ await RandomAccess.WriteAsync(_fileHandle, buffer.AsMemory(0, read), segment.Cur segment.Downloaded += read; attemptBytes += read; var downloaded = Interlocked.Add(ref _downloadedBytes, read); - ReportRateAndProgress(segment, attemptBytes, startedAt, downloaded, activeConnections); + ReportRateAndProgress(segment, attemptBytes, startedAt, downloaded); } finally { @@ -336,13 +349,12 @@ await RandomAccess.WriteAsync(_fileHandle, buffer.AsMemory(0, read), segment.Cur finally { _rates.TryRemove(segment.Id, out _); - activeConnections = Interlocked.Decrement(ref _activeConnections); + var activeConnections = Interlocked.Decrement(ref _activeConnections); ReportProgress(Interlocked.Read(ref _downloadedBytes), activeConnections, force: true); } } - private void ReportRateAndProgress(DownloadSegment segment, long attemptBytes, long startedAt, long downloaded, - int activeConnections) + private void ReportRateAndProgress(DownloadSegment segment, long attemptBytes, long startedAt, long downloaded) { var now = Stopwatch.GetTimestamp(); var elapsedSeconds = Math.Max(0.001, (double)(now - startedAt) / Stopwatch.Frequency); @@ -355,7 +367,7 @@ private void ReportRateAndProgress(DownloadSegment segment, long attemptBytes, l throw new SlowSegmentException("分段速度明显低于其他活跃连接"); } - ReportProgress(downloaded, activeConnections); + ReportProgress(downloaded, Volatile.Read(ref _activeConnections)); } private bool IsSignificantlySlow(DownloadSegment segment, long attemptBytes, long startedAt, long now) @@ -413,16 +425,17 @@ private void ReportProgress(long downloaded, int activeConnections, bool force = private void ValidateRangeResponse(HttpResponseMessage response, DownloadSegment segment) { if (response.StatusCode != HttpStatusCode.PartialContent) - throw new IOException($"服务器未按 Range 返回分段,状态码:{(int)response.StatusCode}"); + throw new RangeNotSupportedException($"服务器未按 Range 返回分段,状态码:{(int)response.StatusCode}"); var range = response.Content.Headers.ContentRange; if (range?.Unit != "bytes" || range.From != segment.CurrentOffset || range.To != segment.End || range.Length != _totalSize) - throw new IOException("服务器返回的 Content-Range 与请求分段不一致"); + throw new RangeNotSupportedException("服务器返回的 Content-Range 与请求分段不一致"); } private readonly record struct RateSample(double BytesPerSecond, long UpdatedAt); } private sealed class SlowSegmentException(string message) : IOException(message); + private sealed class RangeNotSupportedException(string message) : Exception(message); } diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs index f6288affeb..6eb8490511 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs @@ -10,8 +10,11 @@ internal static class DownloadResourceManager private static readonly AsyncQuota BufferQuota = new(); private static readonly ConcurrentDictionary HostConnectionQuotas = new(StringComparer.OrdinalIgnoreCase); private static readonly object BandwidthLock = new(); + private static int _activeConnectionCount; private static long _nextBandwidthTick; + public static int ActiveConnectionCount => Volatile.Read(ref _activeConnectionCount); + public static async ValueTask AcquireConnectionAsync(string url, CancellationToken cancellationToken) { @@ -24,6 +27,7 @@ public static async ValueTask AcquireConnectionAsync(st var globalLease = await ConnectionQuota.AcquireAsync(1, () => Math.Clamp(ModNet.NetTaskConnectionLimit, 1, ModNet.NetTaskConnectionLimitMax), cancellationToken) .ConfigureAwait(false); + Interlocked.Increment(ref _activeConnectionCount); return new DownloadConnectionLease(globalLease, hostLease); } catch @@ -38,6 +42,11 @@ public static ValueTask ReserveBufferAsync(int bytes, Cancel return BufferQuota.AcquireAsync(bytes, () => ModNet.NetTaskBufferBudgetBytes, cancellationToken); } + internal static void ReleaseConnection() + { + Interlocked.Decrement(ref _activeConnectionCount); + } + public static Task ThrottleAsync(int bytes, CancellationToken cancellationToken) { var limit = ModNet.NetTaskSpeedLimitHigh; @@ -67,7 +76,12 @@ internal sealed class DownloadConnectionLease(DownloadQuotaLease globalLease, Do public void Dispose() { - Interlocked.Exchange(ref _globalLease, null)?.Dispose(); + var globalLease = Interlocked.Exchange(ref _globalLease, null); + if (globalLease is null) + return; + + DownloadResourceManager.ReleaseConnection(); + globalLease.Dispose(); Interlocked.Exchange(ref _hostLease, null)?.Dispose(); } } diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 6ef7ee82fc..6cfaae6923 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -159,6 +159,7 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath } } + connection.Dispose(); await output.FlushAsync(cancellationToken).ConfigureAwait(false); await output.DisposeAsync().ConfigureAwait(false); if (totalSize >= 0 && downloaded != totalSize) diff --git a/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs b/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs index a9034889e6..5cbf8e811b 100644 --- a/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs @@ -43,11 +43,7 @@ public long Speed public int ConnectionCount { - get - { - lock (LockFiles) - return Files.Values.Sum(file => file.ActiveThreads); - } + get => DownloadResourceManager.ActiveConnectionCount; } [Obsolete("请使用 ConnectionCount。")] From 7c94cf106009092283b092353866ead717798925 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:27:25 +0800 Subject: [PATCH 05/20] fix mc download performance --- .../Downloader/AdaptiveRangeDownloader.cs | 20 +++++++++--------- .../Network/Downloader/FileDownloader.cs | 21 +++++++++---------- 2 files changed, 20 insertions(+), 21 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index 153ca1b038..42e24ed497 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -302,19 +302,19 @@ private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationTok await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - while (segment.Remaining > 0) + using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(BufferSize, cancellationToken) + .ConfigureAwait(false); + var buffer = ArrayPool.Shared.Rent(BufferSize); + try { - using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(BufferSize, cancellationToken) - .ConfigureAwait(false); - var buffer = ArrayPool.Shared.Rent(BufferSize); - try + while (segment.Remaining > 0) { int read; readTimeout.CancelAfter(ReadTimeoutMilliseconds); try { read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), readTimeout.Token) - .ConfigureAwait(false); + .ConfigureAwait(false); } catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && readTimeout.IsCancellationRequested) @@ -335,10 +335,10 @@ await RandomAccess.WriteAsync(_fileHandle, buffer.AsMemory(0, read), segment.Cur var downloaded = Interlocked.Add(ref _downloadedBytes, read); ReportRateAndProgress(segment, attemptBytes, startedAt, downloaded); } - finally - { - ArrayPool.Shared.Return(buffer); - } + } + finally + { + ArrayPool.Shared.Return(buffer); } if (segment.Remaining > 0) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 6cfaae6923..bf911de2a9 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -124,13 +124,13 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await using var output = new FileStream(localPath + ModNet.NetDownloadEnd, FileMode.Create, FileAccess.Write, - FileShare.Read, bufferSize: 1, FileOptions.Asynchronous | FileOptions.SequentialScan); - while (true) + FileShare.Read, bufferSize: bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(bufferSize, cancellationToken) + .ConfigureAwait(false); + var buffer = ArrayPool.Shared.Rent(bufferSize); + try { - using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(bufferSize, cancellationToken) - .ConfigureAwait(false); - var buffer = ArrayPool.Shared.Rent(bufferSize); - try + while (true) { int read; readTimeout.CancelAfter(readTimeoutMilliseconds); @@ -153,14 +153,13 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath downloaded += read; UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); } - finally - { - ArrayPool.Shared.Return(buffer); - } + } + finally + { + ArrayPool.Shared.Return(buffer); } connection.Dispose(); - await output.FlushAsync(cancellationToken).ConfigureAwait(false); await output.DisposeAsync().ConfigureAwait(false); if (totalSize >= 0 && downloaded != totalSize) throw new IOException($"下载不完整:已写入 {downloaded},应为 {totalSize}"); From 252190fcac7555153ba7897339193c89c8b2b53d Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:50:16 +0800 Subject: [PATCH 06/20] fix speedmeter --- .../Downloader/AdaptiveRangeDownloader.cs | 1 + .../Downloader/DownloadResourceManager.cs | 32 +++++++++++++++++++ .../Network/Downloader/FileDownloader.cs | 1 + .../Modules/Network/Management/NetManager.cs | 6 +--- 4 files changed, 35 insertions(+), 5 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index 42e24ed497..2eb284ccf4 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -329,6 +329,7 @@ private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationTok await DownloadResourceManager.ThrottleAsync(read, cancellationToken).ConfigureAwait(false); await RandomAccess.WriteAsync(_fileHandle, buffer.AsMemory(0, read), segment.CurrentOffset, cancellationToken).ConfigureAwait(false); + DownloadResourceManager.RecordDownloadedBytes(read); segment.Downloaded += read; attemptBytes += read; diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs index 6eb8490511..a71a242b8e 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs @@ -10,11 +10,43 @@ internal static class DownloadResourceManager private static readonly AsyncQuota BufferQuota = new(); private static readonly ConcurrentDictionary HostConnectionQuotas = new(StringComparer.OrdinalIgnoreCase); private static readonly object BandwidthLock = new(); + private static readonly object SpeedLock = new(); private static int _activeConnectionCount; + private static long _speedBytes; + private static long _speedSnapshotTick = Stopwatch.GetTimestamp(); + private static long _speed; private static long _nextBandwidthTick; public static int ActiveConnectionCount => Volatile.Read(ref _activeConnectionCount); + public static long DownloadSpeed + { + get + { + lock (SpeedLock) + { + var now = Stopwatch.GetTimestamp(); + var elapsedTicks = now - _speedSnapshotTick; + if (elapsedTicks >= Stopwatch.Frequency / 4) + { + var bytes = Interlocked.Exchange(ref _speedBytes, 0); + _speed = elapsedTicks > 0 + ? Math.Max(0L, (long)(bytes * (double)Stopwatch.Frequency / elapsedTicks)) + : 0L; + _speedSnapshotTick = now; + } + + return _speed; + } + } + } + + internal static void RecordDownloadedBytes(long bytes) + { + if (bytes > 0) + Interlocked.Add(ref _speedBytes, bytes); + } + public static async ValueTask AcquireConnectionAsync(string url, CancellationToken cancellationToken) { diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index bf911de2a9..25bcc6ce0b 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -150,6 +150,7 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath await DownloadResourceManager.ThrottleAsync(read, cancellationToken).ConfigureAwait(false); await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + DownloadResourceManager.RecordDownloadedBytes(read); downloaded += read; UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); } diff --git a/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs b/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs index 5cbf8e811b..390957f887 100644 --- a/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs @@ -34,11 +34,7 @@ public long DownloadDone public long Speed { - get - { - lock (LockFiles) - return Files.Values.Sum(file => file.Speed); - } + get => DownloadResourceManager.DownloadSpeed; } public int ConnectionCount From 00a51d98296c6b690f8f00d2af5cf61534f63729 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:03:35 +0800 Subject: [PATCH 07/20] use http 1.1 for file download --- .../Modules/Minecraft/ModDownload.cs | 4 ++- .../Downloader/AdaptiveRangeDownloader.cs | 2 +- .../Network/Downloader/FileDownloader.cs | 3 +++ .../Modules/Network/Loaders/LoaderDownload.cs | 27 ++++++++++++------- 4 files changed, 24 insertions(+), 12 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModDownload.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModDownload.cs index d6c385e220..4d3ba6f2e9 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModDownload.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModDownload.cs @@ -64,8 +64,10 @@ public static DownloadFile DlClientAssetIndexGet(McInstance version) var indexUrl = (string)(indexInfo["url"] ?? ""); if (string.IsNullOrEmpty(indexUrl)) return null; + // 避免下载器先发一次 Range 探测请求,再回退到顺序下载 + var indexSize = indexInfo["size"] is null ? -1L : (long)indexInfo["size"]; return new DownloadFile(DlSourceLauncherOrMetaGet(indexUrl), indexAddress, - new ModBase.FileChecker(canUseExistsFile: false)); + new ModBase.FileChecker(actualSize: indexSize, canUseExistsFile: false)); } /// diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index 2eb284ccf4..3db40c289c 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -13,7 +13,7 @@ namespace PCL.Network; /// 为支持 HTTP Range 的大文件提供动态分段下载与慢连接恢复。 internal sealed class AdaptiveRangeDownloader { - internal const long SmallFileThreshold = 32L * 1024 * 1024; + internal const long SmallFileThreshold = 4L * 1024 * 1024; private const long TargetSegmentSize = 8L * 1024 * 1024; private const int MaxSegmentCount = 1024; private const int MaxExpandedSegmentCount = MaxSegmentCount * 2; diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 25bcc6ce0b..a7285379ef 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -1,6 +1,7 @@ using System.Buffers; using System.Diagnostics; using System.IO; +using System.Net; using System.Net.Http; using PCL.Core.IO.Net; @@ -202,6 +203,8 @@ internal static HttpRequestMessage CreateDownloadRequest(string url, bool useBro { var request = new HttpRequestMessage(HttpMethod.Get, url); RequestSigning.SecretHeadersSign(url, ref request, useBrowserUserAgent, customUserAgent); + request.Version = HttpVersion.Version11; + request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; return request; } diff --git a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs index 8fc2a5ee49..6b7c26729d 100644 --- a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs +++ b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs @@ -117,17 +117,24 @@ private async Task ProcessFileAsync(PCL.Network.DownloadFile file, CancellationT if (State >= ModBase.LoadState.Finished) return; Directory.CreateDirectory(Path.GetDirectoryName(file.LocalPath) ?? throw new IOException("下载路径无效")); - if (file.Check?.canUseExistsFile == true && file.Check.Check(file.LocalPath) is null) + var checker = file.Check; + if (checker?.canUseExistsFile == true && File.Exists(file.LocalPath)) { - file.IsCopy = true; - file.State = PCL.Network.NetState.Finished; - try { file.TotalSize = new FileInfo(file.LocalPath).Length; } - catch (IOException) { file.TotalSize = -1; } - file.DownloadedBytes = file.TotalSize; - file.Speed = 0; - file.ActiveThreads = 0; - OnFileFinish(file); - return; + var checkResult = string.IsNullOrEmpty(checker.hash) + ? checker.Check(file.LocalPath) + : await Task.Run(() => checker.Check(file.LocalPath), cancellationToken).ConfigureAwait(false); + if (checkResult is null) + { + file.IsCopy = true; + file.State = PCL.Network.NetState.Finished; + try { file.TotalSize = new FileInfo(file.LocalPath).Length; } + catch (IOException) { file.TotalSize = -1; } + file.DownloadedBytes = file.TotalSize; + file.Speed = 0; + file.ActiveThreads = 0; + OnFileFinish(file); + return; + } } file.State = PCL.Network.NetState.Connecting; From 23090af20000d616054c9e7b49d42f50cee7fa86 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Wed, 5 Aug 2026 23:52:03 +0800 Subject: [PATCH 08/20] reduce timeout duration --- .../Modules/Network/Downloader/FileDownloader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index a7285379ef..d18d131acd 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -99,7 +99,7 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile) { const int bufferSize = 64 * 1024; - const int readTimeoutMilliseconds = 60_000; + const int readTimeoutMilliseconds = 30_000; using var connection = await DownloadResourceManager.AcquireConnectionAsync(url, cancellationToken) .ConfigureAwait(false); using var request = CreateDownloadRequest(url, useBrowserUserAgent, customUserAgent); From 5fde46374ff10a08b814be2e9dd35ebcd5b48943 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Thu, 6 Aug 2026 00:05:37 +0800 Subject: [PATCH 09/20] =?UTF-8?q?=E8=B0=83=E6=95=B4=20UI=20=E4=B8=8E?= =?UTF-8?q?=E6=9C=AC=E5=9C=B0=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../App/Localization/Languages/en-US.xaml | 4 +-- .../App/Localization/Languages/zh-CN.xaml | 6 ++--- .../Pages/PageSetup/PageSetupGameManage.xaml | 25 +++++++++++-------- 3 files changed, 19 insertions(+), 16 deletions(-) diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index 14b639853c..d0b5ac15f3 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -2674,8 +2674,8 @@ Go to Launch → Selection → Folder list to change the download target folder. Right-click a folder or game instance to open the corresponding folder. Max concurrent connections I see - A high connection limit can trigger server throttling, network lag, or disk pressure. 64 connections are usually sufficient unless you know that your sources and network benefit from more. - Limits the total network connections used by all downloads. Segment sizing and slow-connection retries are adjusted automatically; 64 connections are usually sufficient. + A high connection limit can lead to severe lag or even stricter throttling strategies from the server. 64 connections are usually sufficient. Do not set it higher unless you know what you are doing! + Limits the total network connections used by all download tasks. 64 connections are usually sufficient. Max connections per file The most connections a large file may use at once. A higher value can help sources that cap each connection, but may also trigger source throttling. Unlimited diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index de7077e87d..c87e4dcaff 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -2674,10 +2674,10 @@ 请在 启动 → 实例选择 → 文件夹列表 中更改下载目标文件夹。 在某个文件夹或游戏实例上右键,即可选择打开对应文件夹。 最大并发连接数 我知道了 - 过高的并发连接数可能导致服务器限速、网络卡顿或磁盘压力增加。 64 个连接通常已足够,除非你确认下载源和网络环境能够从更多连接中受益。 - 限制所有下载任务合计使用的网络连接数。分段大小和慢速重试会自动调整;64 个连接通常已足够。 + 过高的并发连接数可能导致下载时出现非常严重的卡顿,甚至导致服务器使用更严格的限速策略。 64 个连接通常已经可以满足需求。除非你知道自己在做什么,否则不建议设置更高的并发连接数! + 限制所有下载任务合计使用的网络连接数。64 个连接通常已经足够。 单文件最大连接数 - 大文件最多同时使用的连接数。提高该值可能加快被单连接限速的下载,但也更容易被下载源限速。 + 大文件最多同时使用的连接数。提高该值可能加快被单连接限速的下载,但也有可能更容易被下载源限速。 无限制 diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml index 566a13eed7..9dda25a434 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml @@ -18,10 +18,13 @@ - - - + + + + + + @@ -46,23 +49,23 @@ PreviewChange="SliderDownloadThread_PreviewChange" MaxValue="255" Value="63" Grid.Column="1" Change="SliderChange" ToolTip="{DynamicResource Setup.GameManage.Download.Threads.ToolTip}" /> - - - - - - - + + From f553422fcf579cf420e0402afccbf6f96a026d1e Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:47:33 +0800 Subject: [PATCH 10/20] add http version option # Conflicts: # PCL.Core/App/Localization/Languages/zh-CN.xaml # Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml --- PCL.Core/App/Config.cs | 1 + PCL.Core/App/ConfigEnums.cs | 10 +++ .../App/Localization/Languages/en-US.xaml | 7 +++ .../App/Localization/Languages/zh-CN.xaml | 7 +++ PCL.Core/IO/Net/NetworkService.cs | 1 + .../Downloader/AdaptiveRangeDownloader.cs | 8 +-- .../Network/Downloader/FileDownloader.cs | 61 ++++++++++++++++--- .../Pages/PageSetup/PageSetupGameManage.xaml | 39 +++++++----- .../PageSetup/PageSetupGameManage.xaml.cs | 2 + 9 files changed, 110 insertions(+), 26 deletions(-) diff --git a/PCL.Core/App/Config.cs b/PCL.Core/App/Config.cs index c684b521f5..e7fa1468c2 100644 --- a/PCL.Core/App/Config.cs +++ b/PCL.Core/App/Config.cs @@ -76,6 +76,7 @@ public static partial class Config [ConfigItem("ToolDownloadThread", 63)] public partial int ThreadLimit { get; set; } [ConfigItem("ToolDownloadFileConnection", 7)] public partial int FileConnectionLimit { get; set; } [ConfigItem("ToolDownloadSpeed", 42)] public partial int SpeedLimit { get; set; } + [ConfigItem("ToolDownloadHttpMode", DownloadHttpMode.Auto)] public partial DownloadHttpMode HttpMode { get; set; } [ConfigItem("ToolDownloadSource", 1)] public partial int FileSource { get; set; } [ConfigItem("ToolDownloadVersion", 1)] public partial int VersionListSource { get; set; } [ConfigItem("ToolDownloadAutoSelectVersion", true)] public partial bool AutoSelectInstance { get; set; } diff --git a/PCL.Core/App/ConfigEnums.cs b/PCL.Core/App/ConfigEnums.cs index 6b9eef8b29..422279fe83 100644 --- a/PCL.Core/App/ConfigEnums.cs +++ b/PCL.Core/App/ConfigEnums.cs @@ -9,6 +9,16 @@ public enum LinkProtocolPreference Udp } +/// +/// 文件下载使用的 HTTP 协议模式。 +/// +public enum DownloadHttpMode +{ + Auto = 0, + Http11 = 1, + Http2 = 2 +} + /// /// 主题模式(亮/暗/系统) /// diff --git a/PCL.Core/App/Localization/Languages/en-US.xaml b/PCL.Core/App/Localization/Languages/en-US.xaml index d0b5ac15f3..3bbac59743 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -2678,6 +2678,13 @@ Limits the total network connections used by all download tasks. 64 connections are usually sufficient. Max connections per file The most connections a large file may use at once. A higher value can help sources that cap each connection, but may also trigger source throttling. + File download protocol + Automatic (recommended) + Prefer HTTP/2 for small files and batch downloads, and use HTTP/1.1 for large segmented downloads to balance concurrency and compatibility. + HTTP/1.1 + Force HTTP/1.1 for all file downloads. This can help environments where independent connections improve large-file throughput. + Prefer HTTP/2 + Prefer HTTP/2 for all file downloads, falling back to HTTP/1.1 when the source or proxy does not support it. Unlimited diff --git a/PCL.Core/App/Localization/Languages/zh-CN.xaml b/PCL.Core/App/Localization/Languages/zh-CN.xaml index c87e4dcaff..a5392a36dc 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -2678,6 +2678,13 @@ 限制所有下载任务合计使用的网络连接数。64 个连接通常已经足够。 单文件最大连接数 大文件最多同时使用的连接数。提高该值可能加快被单连接限速的下载,但也有可能更容易被下载源限速。 + 文件下载协议 + 自动选择(推荐) + 小文件和批量下载优先使用 HTTP/2;大文件分段下载使用 HTTP/1.1,以兼顾并发和兼容性。 + HTTP/1.1 + 所有文件下载强制使用 HTTP/1.1。适合需要多条独立连接提升大文件速度的网络环境。 + 优先使用 HTTP/2 + 所有文件下载优先使用 HTTP/2;如果下载源或代理不支持,会自动回退到 HTTP/1.1。 无限制 diff --git a/PCL.Core/IO/Net/NetworkService.cs b/PCL.Core/IO/Net/NetworkService.cs index 1788d940d8..fcf972f3fd 100644 --- a/PCL.Core/IO/Net/NetworkService.cs +++ b/PCL.Core/IO/Net/NetworkService.cs @@ -143,6 +143,7 @@ private static void _Stop() AllowAutoRedirect = true, MaxAutomaticRedirections = 20, UseCookies = false, + EnableMultipleHttp2Connections = true, ConnectCallback = Config.Network.EnableDoH ? HostConnectionHandler.Instance.GetConnectionAsync : null }; diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index 3db40c289c..063f1a764e 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -75,7 +75,7 @@ public static async Task TryDownloadAsync(string url, string localPath, bo { using var connection = await DownloadResourceManager.AcquireConnectionAsync(_url, cancellationToken) .ConfigureAwait(false); - using var request = CreateRequest(HttpMethod.Get); + using var request = CreateRequest(HttpMethod.Get, DownloadRequestKind.RangeProbe); request.Headers.Range = new RangeHeaderValue(0, 0); using var response = await FileDownloader.GetHttpClient(_url) .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); @@ -141,9 +141,9 @@ private int EnqueueInitialSegments(ChannelWriter writer, long t return id; } - private HttpRequestMessage CreateRequest(HttpMethod method) + private HttpRequestMessage CreateRequest(HttpMethod method, DownloadRequestKind requestKind) { - var request = FileDownloader.CreateDownloadRequest(_url, _useBrowserUserAgent, _customUserAgent); + var request = FileDownloader.CreateDownloadRequest(_url, _useBrowserUserAgent, _customUserAgent, requestKind); request.Method = method; request.Headers.AcceptEncoding.Clear(); request.Headers.AcceptEncoding.Add(new StringWithQualityHeaderValue("identity")); @@ -294,7 +294,7 @@ private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationTok var attemptBytes = 0L; try { - using var request = _owner.CreateRequest(HttpMethod.Get); + using var request = _owner.CreateRequest(HttpMethod.Get, DownloadRequestKind.RangeSegment); request.Headers.Range = new RangeHeaderValue(segment.CurrentOffset, segment.End); using var response = await FileDownloader.GetHttpClient(_owner._url) .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index d18d131acd..c0049f10d7 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -3,6 +3,7 @@ using System.IO; using System.Net; using System.Net.Http; +using PCL.Core.App; using PCL.Core.IO.Net; @@ -79,9 +80,15 @@ private static async Task DownloadSingleAsync(string url, string localPath, bool ModBase.Log($"[Download] 开始下载:{url} -> {localPath}"); CleanupTempFiles(localPath); + var expectedSize = trackedFile?.Check?.actualSize ?? -1; + var sequentialRequestKind = (expectedSize >= 0 && expectedSize < AdaptiveRangeDownloader.SmallFileThreshold) || + (expectedSize < 0 && !enableParallelChunks) + ? DownloadRequestKind.SmallOrBatch + : DownloadRequestKind.LargeOrUnknown; + if (enableParallelChunks && await AdaptiveRangeDownloader.TryDownloadAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, trackedFile, - trackedFile?.Check?.actualSize ?? -1).ConfigureAwait(false)) + expectedSize).ConfigureAwait(false)) { PromoteTempFile(localPath); if (!File.Exists(localPath)) @@ -92,17 +99,18 @@ private static async Task DownloadSingleAsync(string url, string localPath, bool } await DownloadSequentiallyAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, - trackedFile).ConfigureAwait(false); + trackedFile, sequentialRequestKind).ConfigureAwait(false); } private static async Task DownloadSequentiallyAsync(string url, string localPath, bool useBrowserUserAgent, - string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile) + string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile, + DownloadRequestKind requestKind) { const int bufferSize = 64 * 1024; const int readTimeoutMilliseconds = 30_000; using var connection = await DownloadResourceManager.AcquireConnectionAsync(url, cancellationToken) .ConfigureAwait(false); - using var request = CreateDownloadRequest(url, useBrowserUserAgent, customUserAgent); + using var request = CreateDownloadRequest(url, useBrowserUserAgent, customUserAgent, requestKind); using var response = await GetHttpClient(url) .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) @@ -199,15 +207,46 @@ private static void UpdateSequentialProgress(DownloadFile? trackedFile, long dow } internal static HttpRequestMessage CreateDownloadRequest(string url, bool useBrowserUserAgent, - string customUserAgent) + string customUserAgent, DownloadRequestKind requestKind) { var request = new HttpRequestMessage(HttpMethod.Get, url); RequestSigning.SecretHeadersSign(url, ref request, useBrowserUserAgent, customUserAgent); - request.Version = HttpVersion.Version11; - request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + ApplyHttpVersion(request, requestKind); return request; } + private static void ApplyHttpVersion(HttpRequestMessage request, DownloadRequestKind requestKind) + { + switch (Config.Download.HttpMode) + { + case DownloadHttpMode.Http11: + request.Version = HttpVersion.Version11; + request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + return; + case DownloadHttpMode.Http2: + request.Version = HttpVersion.Version20; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + return; + } + + // 自动模式规则: + // 1. 已知小于 4 MiB 的文件,以及批量任务中的顺序下载,优先 HTTP/2, + // 让大量小文件复用连接并通过多个 Stream 并发传输。 + // 2. Range 探测、Range 分段,以及大小未知的单文件下载使用 HTTP/1.1, + // 让大文件分段获得独立 TCP 连接;Range 不可用时也避免再次切换协议。 + // 3. HTTP/2 使用 RequestVersionOrLower,源站或代理不支持时自动回退到 HTTP/1.1。 + if (requestKind == DownloadRequestKind.SmallOrBatch) + { + request.Version = HttpVersion.Version20; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; + } + else + { + request.Version = HttpVersion.Version11; + request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + } + } + private static void MarkDownloadCompleted(DownloadFile? trackedFile) { if (trackedFile is null) @@ -273,3 +312,11 @@ internal static HttpClient GetHttpClient(string url) return NetworkService.GetClient(); } } + +internal enum DownloadRequestKind +{ + SmallOrBatch, + LargeOrUnknown, + RangeProbe, + RangeSegment +} diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml index 9dda25a434..b504ffbc59 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml @@ -18,13 +18,11 @@ - - - - - + + + + - @@ -49,23 +47,34 @@ PreviewChange="SliderDownloadThread_PreviewChange" MaxValue="255" Value="63" Grid.Column="1" Change="SliderChange" ToolTip="{DynamicResource Setup.GameManage.Download.Threads.ToolTip}" /> - - - + + + + + + - - - - + + @@ -187,4 +196,4 @@ - \ No newline at end of file + diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs index 5a39fe6c1a..7231b1ea24 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml.cs @@ -51,7 +51,9 @@ public void Reload() { // 下载 SliderDownloadThread.Value = Config.Download.ThreadLimit; + SliderDownloadFileConnection.Value = Config.Download.FileConnectionLimit; SliderDownloadSpeed.Value = Config.Download.SpeedLimit; + ComboDownloadHttpMode.SelectedIndex = (int)Config.Download.HttpMode; ComboDownloadSource.SelectedIndex = Config.Download.FileSource; ComboDownloadVersion.SelectedIndex = Config.Download.VersionListSource; CheckDownloadAutoSelectVersion.Checked = Config.Download.AutoSelectInstance; From 24eb3a148fc9caeb5d1e2e6add7c4f5ecc4b1c89 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:09:58 +0800 Subject: [PATCH 11/20] fix ui --- .../Pages/PageSetup/PageSetupGameManage.xaml | 41 ++++++++++--------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml index b504ffbc59..078c945052 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml @@ -20,9 +20,10 @@ + + - @@ -41,20 +42,9 @@ - - - - - - @@ -63,18 +53,29 @@ - + + + + - - - - + + From 4a0776cd62a776aa9f4d1dedeee0d7ae9962b079 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:00:24 +0800 Subject: [PATCH 12/20] =?UTF-8?q?=E8=B0=83=E6=95=B4=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E7=AD=96=E7=95=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Downloader/AdaptiveRangeDownloader.cs | 8 +-- .../Network/Downloader/FileDownloader.cs | 67 ++++++++++++++----- .../Modules/Network/Loaders/LoaderDownload.cs | 22 +----- 3 files changed, 55 insertions(+), 42 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index 063f1a764e..ea72c3d23f 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -77,8 +77,8 @@ public static async Task TryDownloadAsync(string url, string localPath, bo .ConfigureAwait(false); using var request = CreateRequest(HttpMethod.Get, DownloadRequestKind.RangeProbe); request.Headers.Range = new RangeHeaderValue(0, 0); - using var response = await FileDownloader.GetHttpClient(_url) - .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + using var response = await FileDownloader.SendDownloadRequestAsync(_url, request, cancellationToken) + .ConfigureAwait(false); if (response.StatusCode != HttpStatusCode.PartialContent) return null; @@ -296,8 +296,8 @@ private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationTok { using var request = _owner.CreateRequest(HttpMethod.Get, DownloadRequestKind.RangeSegment); request.Headers.Range = new RangeHeaderValue(segment.CurrentOffset, segment.End); - using var response = await FileDownloader.GetHttpClient(_owner._url) - .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + using var response = await FileDownloader.SendDownloadRequestAsync(_owner._url, request, + cancellationToken).ConfigureAwait(false); ValidateRangeResponse(response, segment); await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index c0049f10d7..6f6b190375 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -5,12 +5,16 @@ using System.Net.Http; using PCL.Core.App; using PCL.Core.IO.Net; +using PCL.Core.Utils; namespace PCL.Network; public static class FileDownloader { + private const int RequestTimeoutMilliseconds = 30_000; + private const int MaxDownloadRetries = 3; + public static async Task DownloadAsync(string url, string localPath, bool useBrowserUserAgent = false, string customUserAgent = "", CancellationToken cancellationToken = default, bool enableParallelChunks = true, DownloadFile? trackedFile = null) @@ -50,25 +54,35 @@ private static async Task DownloadCoreAsync(IEnumerable urls, string loc Directory.CreateDirectory(Path.GetDirectoryName(localPath) ?? throw new ArgumentException("下载路径无效", nameof(localPath))); Exception? lastException = null; - foreach (var url in urlList) + for (var retry = 0; retry <= MaxDownloadRetries; retry++) { - try - { - await DownloadSingleAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, - enableParallelChunks, trackedFile).ConfigureAwait(false); - return; - } - catch (OperationCanceledException) - { - CleanupTempFiles(localPath); - throw; - } - catch (Exception ex) + foreach (var url in urlList) { - lastException = ex; - CleanupTempFiles(localPath); - ModBase.Log(ex, $"[Download] 下载失败,尝试下一个源:{url}", ModBase.LogLevel.Debug); + try + { + await DownloadSingleAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, + enableParallelChunks, trackedFile).ConfigureAwait(false); + return; + } + catch (OperationCanceledException) + { + CleanupTempFiles(localPath); + throw; + } + catch (Exception ex) + { + lastException = ex; + CleanupTempFiles(localPath); + ModBase.Log(ex, $"[Download] 下载源失败:{url}", ModBase.LogLevel.Debug); + } } + + if (retry >= MaxDownloadRetries) + break; + + ModBase.Log(lastException, $"[Download] 重试 {retry + 1}/{MaxDownloadRetries}:{localPath}", + ModBase.LogLevel.Debug); + await Task.Delay(RandomUtils.NextInt(300, 500 + retry * 300), cancellationToken).ConfigureAwait(false); } throw new IOException($"下载失败:{localPath}", lastException); @@ -111,8 +125,7 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath using var connection = await DownloadResourceManager.AcquireConnectionAsync(url, cancellationToken) .ConfigureAwait(false); using var request = CreateDownloadRequest(url, useBrowserUserAgent, customUserAgent, requestKind); - using var response = await GetHttpClient(url) - .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); + using var response = await SendDownloadRequestAsync(url, request, cancellationToken).ConfigureAwait(false); if (!response.IsSuccessStatusCode) throw new HttpRequestException($"下载请求失败:{(int)response.StatusCode} {response.ReasonPhrase}"); @@ -215,6 +228,24 @@ internal static HttpRequestMessage CreateDownloadRequest(string url, bool useBro return request; } + internal static async Task SendDownloadRequestAsync(string url, + HttpRequestMessage request, CancellationToken cancellationToken) + { + using var requestTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + requestTimeout.CancelAfter(RequestTimeoutMilliseconds); + try + { + return await GetHttpClient(url) + .SendAsync(request, HttpCompletionOption.ResponseHeadersRead, requestTimeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested && + requestTimeout.IsCancellationRequested) + { + throw new TimeoutException($"等待下载源响应超时(30 秒):{url}", ex); + } + } + private static void ApplyHttpVersion(HttpRequestMessage request, DownloadRequestKind requestKind) { switch (Config.Download.HttpMode) diff --git a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs index 6b7c26729d..197fe136d0 100644 --- a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs +++ b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs @@ -2,7 +2,6 @@ using System.IO; using System.Threading; using System.Threading.Tasks; -using PCL.Core.Utils; namespace PCL.Network.Loaders; @@ -141,25 +140,8 @@ private async Task ProcessFileAsync(PCL.Network.DownloadFile file, CancellationT // 批量任务中未知大小的文件直接下载,避免小文件逐个产生一次 Range 探测。 var expectedSize = file.Check?.actualSize ?? -1; var enableParallelChunks = files.Count <= 1 || expectedSize >= AdaptiveRangeDownloader.SmallFileThreshold; - for (var retry = 0; retry < 4; retry++) - { - cancellationToken.ThrowIfCancellationRequested(); - try - { - await FileDownloader.DownloadAsync(file.Urls, file.LocalPath, file.UseBrowserUserAgent, file.CustomUserAgent, - cancellationToken, enableParallelChunks, file).ConfigureAwait(false); - break; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception ex) when (retry < 3) - { - ModBase.Log(ex, $"[Download] 重试 {retry + 1}/3:{file.LocalPath}", ModBase.LogLevel.Debug); - Thread.Sleep(RandomUtils.NextInt(300, 500 + retry * 300)); - } - } + await FileDownloader.DownloadAsync(file.Urls, file.LocalPath, file.UseBrowserUserAgent, file.CustomUserAgent, + cancellationToken, enableParallelChunks, file).ConfigureAwait(false); try { file.TotalSize = new FileInfo(file.LocalPath).Length; } catch (IOException) { file.TotalSize = -1; } file.IsUnknownSize = file.TotalSize < 0; From be27687a93948dfe2bce38df0e3308d336cd3116 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Thu, 6 Aug 2026 16:35:04 +0800 Subject: [PATCH 13/20] =?UTF-8?q?=E6=9B=B4=E5=8A=A0=E4=B8=A5=E6=A0=BC?= =?UTF-8?q?=E7=9A=84=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Downloader/AdaptiveRangeDownloader.cs | 10 ++++++- .../Network/Downloader/FileDownloader.cs | 26 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index ea72c3d23f..92dcb735d7 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -20,6 +20,8 @@ internal sealed class AdaptiveRangeDownloader private const int BufferSize = 64 * 1024; private const int ReadTimeoutMilliseconds = 15_000; private const int SlowCheckSeconds = 8; + private const long SlowSpeedBytesPerSecond = 50L * 1024; + private const long ClearlyFastSegmentBytesPerSecond = 128L * 1024; private const long SlowSplitThreshold = 2L * 1024 * 1024; private const long MinimumRestartRemaining = 1L * 1024 * 1024; private const int MaxSegmentRetries = 2; @@ -392,8 +394,14 @@ private bool IsSignificantlySlow(DownloadSegment segment, long attemptBytes, lon Array.Sort(rates); var median = rates[rates.Length / 2]; + var fastest = rates[^1]; var ownRate = attemptBytes / elapsedSeconds; - return median >= 128 * 1024 && ownRate * 4 < median; + + // 确保至少一个分片的速度更高,当所有连接都被同一个源限速时,不会让所有分片一起反复重启 + var hasClearlyFasterSegment = fastest >= ClearlyFastSegmentBytesPerSecond && ownRate * 4 < fastest; + var relativelySlow = median >= ClearlyFastSegmentBytesPerSecond && ownRate * 4 < median; + var absolutelySlow = ownRate < SlowSpeedBytesPerSecond && hasClearlyFasterSegment; + return relativelySlow || absolutelySlow; } private void AddCompletedRate(long attemptBytes, long startedAt) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 6f6b190375..2b8d1d438e 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -14,6 +14,9 @@ public static class FileDownloader { private const int RequestTimeoutMilliseconds = 30_000; private const int MaxDownloadRetries = 3; + private const int SequentialSlowCheckSeconds = 3; + private const long SequentialSlowRestartMinimumSize = 50L * 1024; + private const long SlowSpeedBytesPerSecond = 50L * 1024; public static async Task DownloadAsync(string url, string localPath, bool useBrowserUserAgent = false, string customUserAgent = "", CancellationToken cancellationToken = default, @@ -130,6 +133,9 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath throw new HttpRequestException($"下载请求失败:{(int)response.StatusCode} {response.ReasonPhrase}"); var totalSize = response.Content.Headers.ContentLength ?? -1; + // 某些源使用 chunked 传输没有 Content-Length,但清单校验信息仍可能带有文件大小 + // 这个大小只用于慢速判断,完整性仍按响应头的 totalSize 校验 + var slowCheckSize = totalSize > 0 ? totalSize : trackedFile?.Check?.actualSize ?? -1; if (trackedFile is not null) { trackedFile.State = PCL.Network.NetState.Downloading; @@ -150,6 +156,9 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(bufferSize, cancellationToken) .ConfigureAwait(false); var buffer = ArrayPool.Shared.Rent(bufferSize); + var slowCheckEnabled = slowCheckSize > SequentialSlowRestartMinimumSize && ModNet.NetTaskSpeedLimitHigh <= 0; + var slowCheckBytes = 0L; + var slowCheckTick = Stopwatch.GetTimestamp(); try { while (true) @@ -175,6 +184,21 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath DownloadResourceManager.RecordDownloadedBytes(read); downloaded += read; UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); + + if (slowCheckEnabled && downloaded < slowCheckSize) + { + var now = Stopwatch.GetTimestamp(); + var elapsed = (double)(now - slowCheckTick) / Stopwatch.Frequency; + if (elapsed >= SequentialSlowCheckSeconds) + { + var speed = (downloaded - slowCheckBytes) / Math.Max(0.001, elapsed); + if (speed < SlowSpeedBytesPerSecond) + throw new SlowSequentialDownloadException($"顺序下载速度低于 {SlowSpeedBytesPerSecond / 1024} KiB/s"); + + slowCheckBytes = downloaded; + slowCheckTick = now; + } + } } } finally @@ -332,6 +356,8 @@ private static void TryDeleteFile(string path) } } + private sealed class SlowSequentialDownloadException(string message) : IOException(message); + internal static HttpClient GetHttpClient(string url) { if (Uri.TryCreate(url, UriKind.Absolute, out var parsedUri) From 1a2a8f061db5cfe91cd51a889f23d8e674c0ab1a Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Thu, 6 Aug 2026 19:14:38 +0800 Subject: [PATCH 14/20] =?UTF-8?q?=E8=B0=83=E6=95=B4=E9=A1=BA=E5=BA=8F?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E9=87=8D=E5=90=AF=E9=98=88=E5=80=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Network/Downloader/FileDownloader.cs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 2b8d1d438e..bdeb4196b0 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -14,9 +14,9 @@ public static class FileDownloader { private const int RequestTimeoutMilliseconds = 30_000; private const int MaxDownloadRetries = 3; - private const int SequentialSlowCheckSeconds = 3; + private const int SequentialSlowCheckSeconds = 5; private const long SequentialSlowRestartMinimumSize = 50L * 1024; - private const long SlowSpeedBytesPerSecond = 50L * 1024; + private const long SequentialSlowSpeedBytesPerSecond = 40L * 1024; public static async Task DownloadAsync(string url, string localPath, bool useBrowserUserAgent = false, string customUserAgent = "", CancellationToken cancellationToken = default, @@ -192,8 +192,9 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath if (elapsed >= SequentialSlowCheckSeconds) { var speed = (downloaded - slowCheckBytes) / Math.Max(0.001, elapsed); - if (speed < SlowSpeedBytesPerSecond) - throw new SlowSequentialDownloadException($"顺序下载速度低于 {SlowSpeedBytesPerSecond / 1024} KiB/s"); + if (speed < SequentialSlowSpeedBytesPerSecond) + throw new SlowSequentialDownloadException( + $"顺序下载速度连续 {SequentialSlowCheckSeconds} 秒低于 {SequentialSlowSpeedBytesPerSecond / 1024} KiB/s"); slowCheckBytes = downloaded; slowCheckTick = now; From 292138af6fdd09fed43b846a7e259b8c18d3f8e4 Mon Sep 17 00:00:00 2001 From: Pigeon0v0 <60414767+Pigeon0v0@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:12:23 +0800 Subject: [PATCH 15/20] apply suggestions Generated by GPT-5.6 Terra Max --- .../Modules/Minecraft/ModSkin.cs | 4 +- .../Downloader/AdaptiveRangeDownloader.cs | 3 +- .../Downloader/DownloadResourceManager.cs | 167 +++++++++++++++--- .../Network/Downloader/FileDownloader.cs | 35 ++-- 4 files changed, 166 insertions(+), 43 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs index 565e58c0e2..1e31ffbab9 100644 --- a/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs +++ b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs @@ -153,9 +153,7 @@ public static string McSkinDownload(string address) { if (!File.Exists(fileAddress)) { - FileDownloader.DownloadAsync(address, fileAddress + ModNet.NetDownloadEnd).GetAwaiter().GetResult(); - File.Delete(fileAddress); - FileSystem.Rename(fileAddress + ModNet.NetDownloadEnd, fileAddress); + FileDownloader.DownloadAsync(address, fileAddress).GetAwaiter().GetResult(); ModBase.Log("[Minecraft] 皮肤下载成功:" + fileAddress); } diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index 92dcb735d7..ee4dc60694 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -265,7 +265,8 @@ public async Task RunWorkerAsync() private bool TryRecover(DownloadSegment segment, Exception exception) { - if (exception is not (SlowSegmentException or IOException or HttpRequestException or TaskCanceledException)) + if (exception is not (SlowSegmentException or IOException or HttpRequestException or TaskCanceledException or + DownloadRequestTimeoutException)) return false; if (++segment.FailureCount > MaxSegmentRetries || segment.Remaining <= 0) return false; diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs index a71a242b8e..4522aacaf8 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs @@ -8,14 +8,15 @@ internal static class DownloadResourceManager { private static readonly AsyncQuota ConnectionQuota = new(); private static readonly AsyncQuota BufferQuota = new(); - private static readonly ConcurrentDictionary HostConnectionQuotas = new(StringComparer.OrdinalIgnoreCase); + private static readonly ConcurrentDictionary HostConnectionQuotas = new(StringComparer.OrdinalIgnoreCase); private static readonly object BandwidthLock = new(); private static readonly object SpeedLock = new(); private static int _activeConnectionCount; private static long _speedBytes; private static long _speedSnapshotTick = Stopwatch.GetTimestamp(); private static long _speed; - private static long _nextBandwidthTick; + private static readonly LinkedList BandwidthReservations = new(); + private static bool _bandwidthPumpRunning; public static int ActiveConnectionCount => Volatile.Read(ref _activeConnectionCount); @@ -51,20 +52,23 @@ public static async ValueTask AcquireConnectionAsync(st CancellationToken cancellationToken) { var host = Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : url; - var hostQuota = HostConnectionQuotas.GetOrAdd(host, static _ => new AsyncQuota()); - var hostLease = await hostQuota.AcquireAsync(1, () => ModNet.NetTaskConnectionsPerHostLimit, cancellationToken) - .ConfigureAwait(false); + var hostEntry = AcquireHostQuotaEntry(host); + DownloadQuotaLease? hostLease = null; try { + hostLease = await hostEntry.Quota.AcquireAsync(1, () => ModNet.NetTaskConnectionsPerHostLimit, + cancellationToken) + .ConfigureAwait(false); var globalLease = await ConnectionQuota.AcquireAsync(1, () => Math.Clamp(ModNet.NetTaskConnectionLimit, 1, ModNet.NetTaskConnectionLimitMax), cancellationToken) .ConfigureAwait(false); Interlocked.Increment(ref _activeConnectionCount); - return new DownloadConnectionLease(globalLease, hostLease); + return new DownloadConnectionLease(globalLease, hostLease, hostEntry); } catch { - hostLease.Dispose(); + hostLease?.Dispose(); + ReleaseHostQuotaEntry(hostEntry); throw; } } @@ -79,32 +83,108 @@ internal static void ReleaseConnection() Interlocked.Decrement(ref _activeConnectionCount); } - public static Task ThrottleAsync(int bytes, CancellationToken cancellationToken) + public static async Task ThrottleAsync(int bytes, CancellationToken cancellationToken) { var limit = ModNet.NetTaskSpeedLimitHigh; if (limit <= 0 || bytes <= 0) - return Task.CompletedTask; + return; - long delayTicks; + var reservation = new BandwidthReservation(Math.Max(1L, + (long)Math.Ceiling((double)bytes * Stopwatch.Frequency / limit))); lock (BandwidthLock) { - var now = Stopwatch.GetTimestamp(); - var availableAt = Math.Max(now, _nextBandwidthTick); - var duration = Math.Max(1L, (long)Math.Ceiling((double)bytes * Stopwatch.Frequency / limit)); - _nextBandwidthTick = availableAt + duration; - delayTicks = availableAt - now; + reservation.Node = BandwidthReservations.AddLast(reservation); + if (!_bandwidthPumpRunning) + { + _bandwidthPumpRunning = true; + _ = PumpBandwidthReservationsAsync(); + } + } + + try + { + await reservation.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } + catch + { + lock (BandwidthLock) + { + if (reservation.Node?.List is not null) + BandwidthReservations.Remove(reservation.Node); + } + + throw; + } + } - return delayTicks <= 0 - ? Task.CompletedTask - : Task.Delay(TimeSpan.FromSeconds((double)delayTicks / Stopwatch.Frequency), cancellationToken); + private static async Task PumpBandwidthReservationsAsync() + { + while (true) + { + BandwidthReservation? reservation; + lock (BandwidthLock) + { + if (BandwidthReservations.First is null) + { + _bandwidthPumpRunning = false; + return; + } + + reservation = BandwidthReservations.First.Value; + BandwidthReservations.RemoveFirst(); + reservation.Node = null; + } + + reservation.Completion.TrySetResult(); + await Task.Delay(TimeSpan.FromSeconds((double)reservation.DurationTicks / Stopwatch.Frequency)) + .ConfigureAwait(false); + } + } + + private static HostQuotaEntry AcquireHostQuotaEntry(string host) + { + while (true) + { + var entry = HostConnectionQuotas.GetOrAdd(host, static key => new HostQuotaEntry(key)); + var referenceAdded = entry.TryAddReference(); + if (referenceAdded) + { + if (HostConnectionQuotas.TryGetValue(host, out var current) && ReferenceEquals(entry, current)) + return entry; + + ReleaseHostQuotaEntry(entry); + continue; + } + + if (HostConnectionQuotas.TryGetValue(host, out var retiredEntry) && ReferenceEquals(entry, retiredEntry)) + ((ICollection>)HostConnectionQuotas) + .Remove(new KeyValuePair(host, entry)); + } + } + + internal static void ReleaseHostQuotaEntry(HostQuotaEntry entry) + { + if (!entry.ReleaseReference()) + return; + + ((ICollection>)HostConnectionQuotas) + .Remove(new KeyValuePair(entry.Host, entry)); + } + + private sealed class BandwidthReservation(long durationTicks) + { + public long DurationTicks { get; } = durationTicks; + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public LinkedListNode? Node { get; set; } } } -internal sealed class DownloadConnectionLease(DownloadQuotaLease globalLease, DownloadQuotaLease hostLease) : IDisposable +internal sealed class DownloadConnectionLease(DownloadQuotaLease globalLease, DownloadQuotaLease hostLease, + HostQuotaEntry hostEntry) : IDisposable { private DownloadQuotaLease? _globalLease = globalLease; private DownloadQuotaLease? _hostLease = hostLease; + private HostQuotaEntry? _hostEntry = hostEntry; public void Dispose() { @@ -115,6 +195,9 @@ public void Dispose() DownloadResourceManager.ReleaseConnection(); globalLease.Dispose(); Interlocked.Exchange(ref _hostLease, null)?.Dispose(); + var hostEntry = Interlocked.Exchange(ref _hostEntry, null); + if (hostEntry is not null) + DownloadResourceManager.ReleaseHostQuotaEntry(hostEntry); } } @@ -177,17 +260,49 @@ public async ValueTask AcquireAsync(long amount, Func public void Release(long amount) { - TaskCompletionSource? waiter = null; + TaskCompletionSource[] waiters; lock (_lock) { _used = Math.Max(0, _used - amount); - if (_waiters.Count > 0) - { - waiter = _waiters[0]; - _waiters.RemoveAt(0); - } + waiters = _waiters.ToArray(); + _waiters.Clear(); } - waiter?.TrySetResult(); + foreach (var waiter in waiters) + waiter.TrySetResult(); + } +} + +internal sealed class HostQuotaEntry(string host) +{ + private readonly object _lock = new(); + private int _referenceCount; + private bool _retired; + + public string Host { get; } = host; + public AsyncQuota Quota { get; } = new(); + + public bool TryAddReference() + { + lock (_lock) + { + if (_retired) + return false; + + _referenceCount++; + return true; + } + } + + public bool ReleaseReference() + { + lock (_lock) + { + if (--_referenceCount != 0) + return false; + + _retired = true; + return true; + } } } diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index bdeb4196b0..51e2ebb9c5 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -64,7 +64,7 @@ private static async Task DownloadCoreAsync(IEnumerable urls, string loc try { await DownloadSingleAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, - enableParallelChunks, trackedFile).ConfigureAwait(false); + enableParallelChunks, trackedFile, retry == MaxDownloadRetries).ConfigureAwait(false); return; } catch (OperationCanceledException) @@ -92,7 +92,8 @@ await DownloadSingleAsync(url, localPath, useBrowserUserAgent, customUserAgent, } private static async Task DownloadSingleAsync(string url, string localPath, bool useBrowserUserAgent, - string customUserAgent, CancellationToken cancellationToken, bool enableParallelChunks, DownloadFile? trackedFile) + string customUserAgent, CancellationToken cancellationToken, bool enableParallelChunks, DownloadFile? trackedFile, + bool isFinalRetryRound) { ModBase.Log($"[Download] 开始下载:{url} -> {localPath}"); CleanupTempFiles(localPath); @@ -116,12 +117,12 @@ private static async Task DownloadSingleAsync(string url, string localPath, bool } await DownloadSequentiallyAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, - trackedFile, sequentialRequestKind).ConfigureAwait(false); + trackedFile, sequentialRequestKind, isFinalRetryRound).ConfigureAwait(false); } private static async Task DownloadSequentiallyAsync(string url, string localPath, bool useBrowserUserAgent, string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile, - DownloadRequestKind requestKind) + DownloadRequestKind requestKind, bool isFinalRetryRound) { const int bufferSize = 64 * 1024; const int readTimeoutMilliseconds = 30_000; @@ -132,10 +133,12 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath if (!response.IsSuccessStatusCode) throw new HttpRequestException($"下载请求失败:{(int)response.StatusCode} {response.ReasonPhrase}"); - var totalSize = response.Content.Headers.ContentLength ?? -1; - // 某些源使用 chunked 传输没有 Content-Length,但清单校验信息仍可能带有文件大小 - // 这个大小只用于慢速判断,完整性仍按响应头的 totalSize 校验 - var slowCheckSize = totalSize > 0 ? totalSize : trackedFile?.Check?.actualSize ?? -1; + var responseContentLength = response.Content.Headers.ContentLength ?? -1; + var manifestExpectedSize = trackedFile?.Check?.actualSize ?? -1; + if (responseContentLength >= 0 && manifestExpectedSize >= 0 && responseContentLength != manifestExpectedSize) + throw new IOException($"下载大小与清单不一致:响应为 {responseContentLength},清单为 {manifestExpectedSize}"); + + var totalSize = responseContentLength >= 0 ? responseContentLength : manifestExpectedSize; if (trackedFile is not null) { trackedFile.State = PCL.Network.NetState.Downloading; @@ -156,7 +159,8 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(bufferSize, cancellationToken) .ConfigureAwait(false); var buffer = ArrayPool.Shared.Rent(bufferSize); - var slowCheckEnabled = slowCheckSize > SequentialSlowRestartMinimumSize && ModNet.NetTaskSpeedLimitHigh <= 0; + var slowCheckEnabled = !isFinalRetryRound && totalSize > SequentialSlowRestartMinimumSize && + ModNet.NetTaskSpeedLimitHigh <= 0; var slowCheckBytes = 0L; var slowCheckTick = Stopwatch.GetTimestamp(); try @@ -185,7 +189,7 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath downloaded += read; UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); - if (slowCheckEnabled && downloaded < slowCheckSize) + if (slowCheckEnabled && downloaded < totalSize) { var now = Stopwatch.GetTimestamp(); var elapsed = (double)(now - slowCheckTick) / Stopwatch.Frequency; @@ -209,8 +213,10 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath connection.Dispose(); await output.DisposeAsync().ConfigureAwait(false); - if (totalSize >= 0 && downloaded != totalSize) - throw new IOException($"下载不完整:已写入 {downloaded},应为 {totalSize}"); + if (responseContentLength >= 0 && downloaded != responseContentLength) + throw new IOException($"下载不完整:已写入 {downloaded},应为响应声明的 {responseContentLength}"); + if (manifestExpectedSize >= 0 && downloaded != manifestExpectedSize) + throw new IOException($"下载不完整:已写入 {downloaded},应为清单声明的 {manifestExpectedSize}"); if (trackedFile is not null && totalSize <= 0) { @@ -267,7 +273,7 @@ internal static async Task SendDownloadRequestAsync(string catch (OperationCanceledException ex) when (!cancellationToken.IsCancellationRequested && requestTimeout.IsCancellationRequested) { - throw new TimeoutException($"等待下载源响应超时(30 秒):{url}", ex); + throw new DownloadRequestTimeoutException($"等待下载源响应超时(30 秒):{url}", ex); } } @@ -371,6 +377,9 @@ internal static HttpClient GetHttpClient(string url) } } +internal sealed class DownloadRequestTimeoutException(string message, Exception innerException) + : TimeoutException(message, innerException); + internal enum DownloadRequestKind { SmallOrBatch, From 86c0d06f0c350369c581a0aaddb789c7cd153bba Mon Sep 17 00:00:00 2001 From: Big-Cake-jpg Date: Mon, 31 Aug 2026 12:34:30 +0800 Subject: [PATCH 16/20] =?UTF-8?q?refactor(downloader):=20=E7=A7=BB?= =?UTF-8?q?=E9=99=A4=E8=8E=AB=E5=90=8D=E5=85=B6=E5=A6=99=E7=9A=84=E4=BD=8E?= =?UTF-8?q?=E9=80=9F=E6=8E=90=E8=BF=9E=E6=8E=A5=E6=9C=BA=E5=88=B6=E5=B9=B6?= =?UTF-8?q?=E8=B0=83=E6=95=B4=E6=A0=A1=E9=AA=8C=E6=96=B9=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is generated by gpt-5.6-sol (max) --- .../Network/Downloader/FileDownloader.cs | 62 ++++++++----------- 1 file changed, 27 insertions(+), 35 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 51e2ebb9c5..0a334db9a9 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -14,9 +14,6 @@ public static class FileDownloader { private const int RequestTimeoutMilliseconds = 30_000; private const int MaxDownloadRetries = 3; - private const int SequentialSlowCheckSeconds = 5; - private const long SequentialSlowRestartMinimumSize = 50L * 1024; - private const long SequentialSlowSpeedBytesPerSecond = 40L * 1024; public static async Task DownloadAsync(string url, string localPath, bool useBrowserUserAgent = false, string customUserAgent = "", CancellationToken cancellationToken = default, @@ -64,7 +61,7 @@ private static async Task DownloadCoreAsync(IEnumerable urls, string loc try { await DownloadSingleAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, - enableParallelChunks, trackedFile, retry == MaxDownloadRetries).ConfigureAwait(false); + enableParallelChunks, trackedFile).ConfigureAwait(false); return; } catch (OperationCanceledException) @@ -92,13 +89,13 @@ await DownloadSingleAsync(url, localPath, useBrowserUserAgent, customUserAgent, } private static async Task DownloadSingleAsync(string url, string localPath, bool useBrowserUserAgent, - string customUserAgent, CancellationToken cancellationToken, bool enableParallelChunks, DownloadFile? trackedFile, - bool isFinalRetryRound) + string customUserAgent, CancellationToken cancellationToken, bool enableParallelChunks, DownloadFile? trackedFile) { ModBase.Log($"[Download] 开始下载:{url} -> {localPath}"); CleanupTempFiles(localPath); - var expectedSize = trackedFile?.Check?.actualSize ?? -1; + var checker = trackedFile?.Check; + var expectedSize = string.IsNullOrEmpty(checker?.hash) ? checker?.actualSize ?? -1 : -1; var sequentialRequestKind = (expectedSize >= 0 && expectedSize < AdaptiveRangeDownloader.SmallFileThreshold) || (expectedSize < 0 && !enableParallelChunks) ? DownloadRequestKind.SmallOrBatch @@ -108,6 +105,7 @@ private static async Task DownloadSingleAsync(string url, string localPath, bool useBrowserUserAgent, customUserAgent, cancellationToken, trackedFile, expectedSize).ConfigureAwait(false)) { + await ValidateTempFileAsync(localPath, checker, cancellationToken).ConfigureAwait(false); PromoteTempFile(localPath); if (!File.Exists(localPath)) throw new IOException($"分段下载未产生任何文件:{localPath}"); @@ -117,12 +115,12 @@ private static async Task DownloadSingleAsync(string url, string localPath, bool } await DownloadSequentiallyAsync(url, localPath, useBrowserUserAgent, customUserAgent, cancellationToken, - trackedFile, sequentialRequestKind, isFinalRetryRound).ConfigureAwait(false); + trackedFile, sequentialRequestKind).ConfigureAwait(false); } private static async Task DownloadSequentiallyAsync(string url, string localPath, bool useBrowserUserAgent, string customUserAgent, CancellationToken cancellationToken, DownloadFile? trackedFile, - DownloadRequestKind requestKind, bool isFinalRetryRound) + DownloadRequestKind requestKind) { const int bufferSize = 64 * 1024; const int readTimeoutMilliseconds = 30_000; @@ -134,8 +132,10 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath throw new HttpRequestException($"下载请求失败:{(int)response.StatusCode} {response.ReasonPhrase}"); var responseContentLength = response.Content.Headers.ContentLength ?? -1; - var manifestExpectedSize = trackedFile?.Check?.actualSize ?? -1; - if (responseContentLength >= 0 && manifestExpectedSize >= 0 && responseContentLength != manifestExpectedSize) + var checker = trackedFile?.Check; + var manifestExpectedSize = checker?.actualSize ?? -1; + if (string.IsNullOrEmpty(checker?.hash) && responseContentLength >= 0 && manifestExpectedSize >= 0 && + responseContentLength != manifestExpectedSize) throw new IOException($"下载大小与清单不一致:响应为 {responseContentLength},清单为 {manifestExpectedSize}"); var totalSize = responseContentLength >= 0 ? responseContentLength : manifestExpectedSize; @@ -159,10 +159,6 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(bufferSize, cancellationToken) .ConfigureAwait(false); var buffer = ArrayPool.Shared.Rent(bufferSize); - var slowCheckEnabled = !isFinalRetryRound && totalSize > SequentialSlowRestartMinimumSize && - ModNet.NetTaskSpeedLimitHigh <= 0; - var slowCheckBytes = 0L; - var slowCheckTick = Stopwatch.GetTimestamp(); try { while (true) @@ -188,22 +184,6 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath DownloadResourceManager.RecordDownloadedBytes(read); downloaded += read; UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); - - if (slowCheckEnabled && downloaded < totalSize) - { - var now = Stopwatch.GetTimestamp(); - var elapsed = (double)(now - slowCheckTick) / Stopwatch.Frequency; - if (elapsed >= SequentialSlowCheckSeconds) - { - var speed = (downloaded - slowCheckBytes) / Math.Max(0.001, elapsed); - if (speed < SequentialSlowSpeedBytesPerSecond) - throw new SlowSequentialDownloadException( - $"顺序下载速度连续 {SequentialSlowCheckSeconds} 秒低于 {SequentialSlowSpeedBytesPerSecond / 1024} KiB/s"); - - slowCheckBytes = downloaded; - slowCheckTick = now; - } - } } } finally @@ -215,8 +195,8 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath await output.DisposeAsync().ConfigureAwait(false); if (responseContentLength >= 0 && downloaded != responseContentLength) throw new IOException($"下载不完整:已写入 {downloaded},应为响应声明的 {responseContentLength}"); - if (manifestExpectedSize >= 0 && downloaded != manifestExpectedSize) - throw new IOException($"下载不完整:已写入 {downloaded},应为清单声明的 {manifestExpectedSize}"); + + await ValidateTempFileAsync(localPath, checker, cancellationToken).ConfigureAwait(false); if (trackedFile is not null && totalSize <= 0) { @@ -232,6 +212,20 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath ModBase.Log($"[Download] 顺序下载成功:{localPath}"); } + private static async Task ValidateTempFileAsync(string localPath, ModBase.FileChecker? checker, + CancellationToken cancellationToken) + { + if (checker is null) + return; + + var tempPath = localPath + ModNet.NetDownloadEnd; + var checkResult = string.IsNullOrEmpty(checker.hash) + ? checker.Check(tempPath) + : await Task.Run(() => checker.Check(tempPath), cancellationToken).ConfigureAwait(false); + if (checkResult is not null) + throw new IOException($"下载文件校验失败:{checkResult}"); + } + private static void UpdateSequentialProgress(DownloadFile? trackedFile, long downloaded, long totalSize, ref long lastProgressBytes, ref long lastProgressTick) { @@ -363,8 +357,6 @@ private static void TryDeleteFile(string path) } } - private sealed class SlowSequentialDownloadException(string message) : IOException(message); - internal static HttpClient GetHttpClient(string url) { if (Uri.TryCreate(url, UriKind.Absolute, out var parsedUri) From 70d67f1cd3b59e28e861a0d3afe8edc432b3c60e Mon Sep 17 00:00:00 2001 From: Big-Cake-jpg Date: Mon, 31 Aug 2026 13:24:32 +0800 Subject: [PATCH 17/20] =?UTF-8?q?chore:=20=E7=A7=BB=E9=99=A4=E6=97=A0?= =?UTF-8?q?=E7=94=A8=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Modules/Network/Http/Requester.cs | 28 ------------------- 1 file changed, 28 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs b/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs index b42eb33aa3..1b3d16a623 100644 --- a/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs +++ b/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs @@ -133,34 +133,6 @@ private static async Task FetchOnceAsync(string url, FetchParam param) } } - public static async Task DownloadFileAsync(string url, string filePath) - { - await FileDownloader.DownloadAsync(url, filePath).ConfigureAwait(false); - } - - public static async Task DownloadFileOnceAsync(string url, string filePath) - { - await FileDownloader.DownloadAsync(url, filePath).ConfigureAwait(false); - } - - public static DownloadService CreateDownloadService(string url, bool useBrowserUserAgent = false) - { - var chunkCount = Math.Min(Math.Clamp(ModNet.NetTaskConnectionLimit, 1, - ModNet.NetTaskSingleFileConnectionLimitMax), ModNet.NetTaskSingleFileConnectionLimit); - return new DownloadService(new DownloadConfiguration - { - ChunkCount = chunkCount, - ParallelCount = chunkCount, - ParallelDownload = chunkCount > 1, - MaximumBytesPerSecond = ModNet.NetTaskSpeedLimitHigh > 0 ? ModNet.NetTaskSpeedLimitHigh : 0, - DownloadFileExtension = ModNet.NetDownloadEnd, - EnableAutoResumeDownload = false, - MaximumMemoryBufferBytes = 2L * 1024 * 1024, - BufferBlockSize = 64 * 1024, - RequestConfiguration = DownloadRequestFactory.Create(url, useBrowserUserAgent) - }); - } - public static HttpMethod ParseMethod(string? method) { return (method ?? "GET").ToUpperInvariant() switch From 68ccd48643f61283b522483bd461775aedd12a62 Mon Sep 17 00:00:00 2001 From: WhiteCAT <64885812+whitecat346@users.noreply.github.com> Date: Sun, 13 Sep 2026 23:18:52 +0800 Subject: [PATCH 18/20] fix: fix wrong buffer management --- .../Downloader/AdaptiveRangeDownloader.cs | 60 +++++++++---------- .../Downloader/DownloadResourceManager.cs | 32 +++++++--- .../Network/Downloader/FileDownloader.cs | 60 +++++++++---------- 3 files changed, 78 insertions(+), 74 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs index ee4dc60694..feb984fd13 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -1,3 +1,4 @@ +using Microsoft.Win32.SafeHandles; using System.Buffers; using System.Collections.Concurrent; using System.Diagnostics; @@ -6,7 +7,6 @@ using System.Net.Http; using System.Net.Http.Headers; using System.Threading.Channels; -using Microsoft.Win32.SafeHandles; namespace PCL.Network; @@ -305,44 +305,38 @@ private async Task DownloadSegmentAsync(DownloadSegment segment, CancellationTok await using var input = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); using var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(BufferSize, cancellationToken) + using var bufferLease = await DownloadResourceManager.RentBufferAsync(BufferSize, cancellationToken) .ConfigureAwait(false); - var buffer = ArrayPool.Shared.Rent(BufferSize); - try + var buffer = bufferLease.Buffer; + + while (segment.Remaining > 0) { - while (segment.Remaining > 0) + int read; + readTimeout.CancelAfter(ReadTimeoutMilliseconds); + try { - int read; - readTimeout.CancelAfter(ReadTimeoutMilliseconds); - try - { - read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), readTimeout.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && - readTimeout.IsCancellationRequested) - { - throw new SlowSegmentException("分段在等待数据时超时"); - } + read = await input.ReadAsync(buffer.AsMemory(0, BufferSize), readTimeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && + readTimeout.IsCancellationRequested) + { + throw new SlowSegmentException("分段在等待数据时超时"); + } - if (read == 0) - break; - read = (int)Math.Min(read, segment.Remaining); + if (read == 0) + break; + read = (int)Math.Min(read, segment.Remaining); - await DownloadResourceManager.ThrottleAsync(read, cancellationToken).ConfigureAwait(false); - await RandomAccess.WriteAsync(_fileHandle, buffer.AsMemory(0, read), segment.CurrentOffset, - cancellationToken).ConfigureAwait(false); - DownloadResourceManager.RecordDownloadedBytes(read); + await DownloadResourceManager.ThrottleAsync(read, cancellationToken).ConfigureAwait(false); + await RandomAccess.WriteAsync(_fileHandle, buffer.AsMemory(0, read), segment.CurrentOffset, + cancellationToken).ConfigureAwait(false); + DownloadResourceManager.RecordDownloadedBytes(read); - segment.Downloaded += read; - attemptBytes += read; - var downloaded = Interlocked.Add(ref _downloadedBytes, read); - ReportRateAndProgress(segment, attemptBytes, startedAt, downloaded); - } - } - finally - { - ArrayPool.Shared.Return(buffer); + segment.Downloaded += read; + attemptBytes += read; + var downloaded = Interlocked.Add(ref _downloadedBytes, read); + ReportRateAndProgress(segment, attemptBytes, startedAt, downloaded); } if (segment.Remaining > 0) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs index 4522aacaf8..1ce57fc79f 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs @@ -1,5 +1,6 @@ -using System.Diagnostics; +using System.Buffers; using System.Collections.Concurrent; +using System.Diagnostics; namespace PCL.Network; @@ -9,8 +10,8 @@ internal static class DownloadResourceManager private static readonly AsyncQuota ConnectionQuota = new(); private static readonly AsyncQuota BufferQuota = new(); private static readonly ConcurrentDictionary HostConnectionQuotas = new(StringComparer.OrdinalIgnoreCase); - private static readonly object BandwidthLock = new(); - private static readonly object SpeedLock = new(); + private static readonly Lock BandwidthLock = new(); + private static readonly Lock SpeedLock = new(); private static int _activeConnectionCount; private static long _speedBytes; private static long _speedSnapshotTick = Stopwatch.GetTimestamp(); @@ -73,9 +74,10 @@ public static async ValueTask AcquireConnectionAsync(st } } - public static ValueTask ReserveBufferAsync(int bytes, CancellationToken cancellationToken) + public static async ValueTask RentBufferAsync(int minBytes, CancellationToken cancellationToken) { - return BufferQuota.AcquireAsync(bytes, () => ModNet.NetTaskBufferBudgetBytes, cancellationToken); + var lease = await BufferQuota.AcquireAsync(minBytes, () => ModNet.NetTaskBufferBudgetBytes, cancellationToken).ConfigureAwait(false); + return new DownloadBufferLease(lease, ArrayPool.Shared.Rent(minBytes)); } internal static void ReleaseConnection() @@ -201,6 +203,20 @@ public void Dispose() } } +internal sealed class DownloadBufferLease(DownloadQuotaLease lase, byte[] buffer) : IDisposable +{ + public byte[] Buffer { get; } = buffer; + public int Length => Buffer.Length; + + + /// + public void Dispose() + { + ArrayPool.Shared.Return(Buffer); + lase.Dispose(); + } +} + internal sealed class DownloadQuotaLease : IDisposable { private AsyncQuota? _quota; @@ -220,8 +236,8 @@ public void Dispose() internal sealed class AsyncQuota { - private readonly object _lock = new(); - private readonly List _waiters = new(); + private readonly Lock _lock = new(); + private readonly List _waiters = []; private long _used; public async ValueTask AcquireAsync(long amount, Func getCapacity, @@ -275,7 +291,7 @@ public void Release(long amount) internal sealed class HostQuotaEntry(string host) { - private readonly object _lock = new(); + private readonly Lock _lock = new(); private int _referenceCount; private bool _retired; diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index 0a334db9a9..f874ac1db5 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -1,11 +1,10 @@ -using System.Buffers; +using PCL.Core.App; +using PCL.Core.IO.Net; +using PCL.Core.Utils; using System.Diagnostics; using System.IO; using System.Net; using System.Net.Http; -using PCL.Core.App; -using PCL.Core.IO.Net; -using PCL.Core.Utils; namespace PCL.Network; @@ -156,41 +155,36 @@ private static async Task DownloadSequentiallyAsync(string url, string localPath using var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); await using var output = new FileStream(localPath + ModNet.NetDownloadEnd, FileMode.Create, FileAccess.Write, FileShare.Read, bufferSize: bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); - using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(bufferSize, cancellationToken) + using var bufferLease = await DownloadResourceManager.RentBufferAsync(bufferSize, cancellationToken) .ConfigureAwait(false); - var buffer = ArrayPool.Shared.Rent(bufferSize); - try + var buffer = bufferLease.Buffer; + + while (true) { - while (true) + int read; + readTimeout.CancelAfter(readTimeoutMilliseconds); + try { - int read; - readTimeout.CancelAfter(readTimeoutMilliseconds); - try - { - read = await input.ReadAsync(buffer.AsMemory(0, bufferSize), readTimeout.Token) - .ConfigureAwait(false); - } - catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && - readTimeout.IsCancellationRequested) - { - throw new TimeoutException($"下载超时({url})"); - } + read = await input.ReadAsync(buffer.AsMemory(0, bufferSize), readTimeout.Token) + .ConfigureAwait(false); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested && + readTimeout.IsCancellationRequested) + { + throw new TimeoutException($"下载超时({url})"); + } - if (read == 0) - break; + if (read == 0) + break; - await DownloadResourceManager.ThrottleAsync(read, cancellationToken).ConfigureAwait(false); - await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); - DownloadResourceManager.RecordDownloadedBytes(read); - downloaded += read; - UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); - } - } - finally - { - ArrayPool.Shared.Return(buffer); + await DownloadResourceManager.ThrottleAsync(read, cancellationToken).ConfigureAwait(false); + await output.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false); + DownloadResourceManager.RecordDownloadedBytes(read); + downloaded += read; + UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); } + connection.Dispose(); await output.DisposeAsync().ConfigureAwait(false); if (responseContentLength >= 0 && downloaded != responseContentLength) @@ -364,7 +358,7 @@ internal static HttpClient GetHttpClient(string url) { return NetworkService.GetClient(NetworkService.CurseForgeApi); } - + return NetworkService.GetClient(); } } From 9408ce3350bddbfbd43b3b1c639f4179aa7f7df1 Mon Sep 17 00:00:00 2001 From: WhiteCAT <64885812+whitecat346@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:37:23 +0800 Subject: [PATCH 19/20] fix: use token bucket to throttle --- .../Downloader/DownloadResourceManager.cs | 125 +++++++++++------- 1 file changed, 79 insertions(+), 46 deletions(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs index 1ce57fc79f..412b8e3d57 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs @@ -10,14 +10,11 @@ internal static class DownloadResourceManager private static readonly AsyncQuota ConnectionQuota = new(); private static readonly AsyncQuota BufferQuota = new(); private static readonly ConcurrentDictionary HostConnectionQuotas = new(StringComparer.OrdinalIgnoreCase); - private static readonly Lock BandwidthLock = new(); private static readonly Lock SpeedLock = new(); private static int _activeConnectionCount; private static long _speedBytes; private static long _speedSnapshotTick = Stopwatch.GetTimestamp(); private static long _speed; - private static readonly LinkedList BandwidthReservations = new(); - private static bool _bandwidthPumpRunning; public static int ActiveConnectionCount => Volatile.Read(ref _activeConnectionCount); @@ -85,64 +82,107 @@ internal static void ReleaseConnection() Interlocked.Decrement(ref _activeConnectionCount); } - public static async Task ThrottleAsync(int bytes, CancellationToken cancellationToken) + #region Throttle + + // use token bucket + + private const long ThrottleBurstBytes = 128L * 1024; + private const int MaxThrottleDelayMilliseconds = 2_000; + + private static readonly Lock ThrottleScheduleLock = new(); + + private static double _throttleTokens; + + // the last Stopwatch timestamp of refill tokens + // 0 means not be initialized + private static long _throttleLastRefillTikck; + + public static async Task ThrottleAsync(int bytes, CancellationToken ct) { - var limit = ModNet.NetTaskSpeedLimitHigh; - if (limit <= 0 || bytes <= 0) + var limitHigh = ModNet.NetTaskSpeedLimitHigh; + if (limitHigh <= 0 || bytes <= 0) + { return; + } - var reservation = new BandwidthReservation(Math.Max(1L, - (long)Math.Ceiling((double)bytes * Stopwatch.Frequency / limit))); - lock (BandwidthLock) + long waitTicks; + lock (ThrottleScheduleLock) { - reservation.Node = BandwidthReservations.AddLast(reservation); - if (!_bandwidthPumpRunning) + var now = Stopwatch.GetTimestamp(); + + // will be initialize at first calling + if (_throttleLastRefillTikck == 0) { - _bandwidthPumpRunning = true; - _ = PumpBandwidthReservationsAsync(); + _throttleLastRefillTikck = now; + _throttleTokens = ThrottleBurstBytes; + } + else + { + var elaspedTicks = now - _throttleLastRefillTikck; + if (elaspedTicks > 0) + { + var refill = elaspedTicks * (double)limitHigh / Stopwatch.Frequency; + _throttleTokens = Math.Min(ThrottleBurstBytes, _throttleTokens + refill); + _throttleLastRefillTikck = now; + } } - } - try - { - await reservation.Completion.Task.WaitAsync(cancellationToken).ConfigureAwait(false); - } - catch - { - lock (BandwidthLock) + // use tokens + // alow be negative + _throttleTokens -= bytes; + + if (_throttleTokens >= 0) { - if (reservation.Node?.List is not null) - BandwidthReservations.Remove(reservation.Node); + return; } - throw; + waitTicks = (long)Math.Ceiling(-_throttleTokens * Stopwatch.Frequency / limitHigh); } - } - private static async Task PumpBandwidthReservationsAsync() - { - while (true) + if (waitTicks <= 0) { - BandwidthReservation? reservation; - lock (BandwidthLock) + return; + } + + var deadlineTick = Stopwatch.GetTimestamp() + waitTicks; + var maxChunkTicks = MaxThrottleDelayMilliseconds > 0 + ? (long)Math.Ceiling(MaxThrottleDelayMilliseconds * (double)Stopwatch.Frequency / 1000.0) + : long.MaxValue; + + try + { + while (true) { - if (BandwidthReservations.First is null) + var remainingTicks = deadlineTick - Stopwatch.GetTimestamp(); + if (remainingTicks <= 0) + { + break; + } + + var chunkTicks = Math.Min(remainingTicks, maxChunkTicks); + var chunkMs = (long)Math.Ceiling(chunkTicks * 1000d / Stopwatch.Frequency); + + if (chunkMs <= 0) { - _bandwidthPumpRunning = false; - return; + chunkMs = 1; } - reservation = BandwidthReservations.First.Value; - BandwidthReservations.RemoveFirst(); - reservation.Node = null; + await Task.Delay(TimeSpan.FromMilliseconds(chunkMs), ct).ConfigureAwait(false); + } + } + catch (OperationCanceledException) + { + lock (ThrottleScheduleLock) + { + _throttleTokens = Math.Min(ThrottleBurstBytes, _throttleTokens + bytes); } - reservation.Completion.TrySetResult(); - await Task.Delay(TimeSpan.FromSeconds((double)reservation.DurationTicks / Stopwatch.Frequency)) - .ConfigureAwait(false); + throw; } } + #endregion + private static HostQuotaEntry AcquireHostQuotaEntry(string host) { while (true) @@ -172,13 +212,6 @@ internal static void ReleaseHostQuotaEntry(HostQuotaEntry entry) ((ICollection>)HostConnectionQuotas) .Remove(new KeyValuePair(entry.Host, entry)); } - - private sealed class BandwidthReservation(long durationTicks) - { - public long DurationTicks { get; } = durationTicks; - public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); - public LinkedListNode? Node { get; set; } - } } internal sealed class DownloadConnectionLease(DownloadQuotaLease globalLease, DownloadQuotaLease hostLease, From 65311c96ca64b2746c77f51f8f329f5d6d50ea11 Mon Sep 17 00:00:00 2001 From: WhiteCAT <64885812+whitecat346@users.noreply.github.com> Date: Mon, 14 Sep 2026 09:40:43 +0800 Subject: [PATCH 20/20] fix: dismiss null hash to improve performance --- .../Modules/Network/Downloader/FileDownloader.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs index f874ac1db5..16ac74ff5a 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -94,7 +94,7 @@ private static async Task DownloadSingleAsync(string url, string localPath, bool CleanupTempFiles(localPath); var checker = trackedFile?.Check; - var expectedSize = string.IsNullOrEmpty(checker?.hash) ? checker?.actualSize ?? -1 : -1; + var expectedSize = checker?.actualSize ?? -1; var sequentialRequestKind = (expectedSize >= 0 && expectedSize < AdaptiveRangeDownloader.SmallFileThreshold) || (expectedSize < 0 && !enableParallelChunks) ? DownloadRequestKind.SmallOrBatch