diff --git a/PCL.Core/App/Config.cs b/PCL.Core/App/Config.cs index 1137f4eaa2..e7fa1468c2 100644 --- a/PCL.Core/App/Config.cs +++ b/PCL.Core/App/Config.cs @@ -72,8 +72,11 @@ 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("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 c090e7a13e..3bbac59743 100644 --- a/PCL.Core/App/Localization/Languages/en-US.xaml +++ b/PCL.Core/App/Localization/Languages/en-US.xaml @@ -2672,10 +2672,19 @@ 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 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. + 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 @@ -3226,7 +3235,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 +3579,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..a5392a36dc 100644 --- a/PCL.Core/App/Localization/Languages/zh-CN.xaml +++ b/PCL.Core/App/Localization/Languages/zh-CN.xaml @@ -2672,10 +2672,19 @@ 设置下载的速度上限,以避免在下载时导致其他需要联网的程序卡死 目标文件夹 请在 启动 → 实例选择 → 文件夹列表 中更改下载目标文件夹。 在某个文件夹或游戏实例上右键,即可选择打开对应文件夹。 - 最大线程数 + 最大并发连接数 我知道了 - 如果设置过多的下载线程,可能会导致下载时出现非常严重的卡顿。 一般设置 64 线程即可满足大多数下载需求,除非你知道你在干什么,否则不建议设置更多的线程数! - 线程数越多,限速的文件下载越快,但过高的线程数可能会造成下载时非常严重的卡顿。 一般而言,64 线程已可以保证足够的下载速度。 + 过高的并发连接数可能导致下载时出现非常严重的卡顿,甚至导致服务器使用更严格的限速策略。 64 个连接通常已经可以满足需求。除非你知道自己在做什么,否则不建议设置更高的并发连接数! + 限制所有下载任务合计使用的网络连接数。64 个连接通常已经足够。 + 单文件最大连接数 + 大文件最多同时使用的连接数。提高该值可能加快被单连接限速的下载,但也有可能更容易被下载源限速。 + 文件下载协议 + 自动选择(推荐) + 小文件和批量下载优先使用 HTTP/2;大文件分段下载使用 HTTP/1.1,以兼顾并发和兼容性。 + HTTP/1.1 + 所有文件下载强制使用 HTTP/1.1。适合需要多条独立连接提升大文件速度的网络环境。 + 优先使用 HTTP/2 + 所有文件下载优先使用 HTTP/2;如果下载源或代理不支持,会自动回退到 HTTP/1.1。 无限制 @@ -3226,7 +3235,7 @@ 已复制错误详情! 任务管理操作失败。请查看错误详情。 剩余文件 - 剩余线程 + 活动连接 下载速度 总进度 @@ -3570,4 +3579,4 @@ Minecraft 窗口已加载:{0}({1}) 已最大化 Minecraft 窗口:{0} - \ No newline at end of file + 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/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/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/Minecraft/ModSkin.cs b/Plain Craft Launcher 2/Modules/Minecraft/ModSkin.cs index 8cfcce2474..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 new file mode 100644 index 0000000000..ee4dc60694 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/AdaptiveRangeDownloader.cs @@ -0,0 +1,451 @@ +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 +{ + 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; + 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; + + 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, + long expectedSize = -1) + { + // 下载清单已有大小时,小文件直接走顺序请求,避免额外的 Range 探测往返。 + if (expectedSize >= 0 && expectedSize < SmallFileThreshold) + return false; + + var downloader = new AdaptiveRangeDownloader(url, localPath, useBrowserUserAgent, customUserAgent, trackedFile); + 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; + } + + 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) + { + using var connection = await DownloadResourceManager.AcquireConnectionAsync(_url, cancellationToken) + .ConfigureAwait(false); + using var request = CreateRequest(HttpMethod.Get, DownloadRequestKind.RangeProbe); + request.Headers.Range = new RangeHeaderValue(0, 0); + using var response = await FileDownloader.SendDownloadRequestAsync(_url, request, 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, DownloadRequestKind requestKind) + { + var request = FileDownloader.CreateDownloadRequest(_url, _useBrowserUserAgent, _customUserAgent, requestKind); + 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 LastRateSampleTick { 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 or + DownloadRequestTimeoutException)) + 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); + Interlocked.Increment(ref _activeConnections); + var startedAt = Stopwatch.GetTimestamp(); + var attemptBytes = 0L; + try + { + using var request = _owner.CreateRequest(HttpMethod.Get, DownloadRequestKind.RangeSegment); + request.Headers.Range = new RangeHeaderValue(segment.CurrentOffset, segment.End); + 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); + using var readTimeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + 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); + } + 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); + 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); + } + + if (segment.Remaining > 0) + throw new IOException($"分段 {segment.Id} 提前结束,还剩 {segment.Remaining} 字节"); + + AddCompletedRate(attemptBytes, startedAt); + } + finally + { + _rates.TryRemove(segment.Id, out _); + 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) + { + var now = Stopwatch.GetTimestamp(); + var elapsedSeconds = Math.Max(0.001, (double)(now - startedAt) / Stopwatch.Frequency); + 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("分段速度明显低于其他活跃连接"); + } + + ReportProgress(downloaded, Volatile.Read(ref _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 fastest = rates[^1]; + var ownRate = attemptBytes / elapsedSeconds; + + // 确保至少一个分片的速度更高,当所有连接都被同一个源限速时,不会让所有分片一起反复重启 + 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) + { + 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 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 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 new file mode 100644 index 0000000000..4522aacaf8 --- /dev/null +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/DownloadResourceManager.cs @@ -0,0 +1,308 @@ +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 readonly object 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); + + 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) + { + var host = Uri.TryCreate(url, UriKind.Absolute, out var uri) ? uri.Host : url; + 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, hostEntry); + } + catch + { + hostLease?.Dispose(); + ReleaseHostQuotaEntry(hostEntry); + throw; + } + } + + public static ValueTask ReserveBufferAsync(int bytes, CancellationToken cancellationToken) + { + return BufferQuota.AcquireAsync(bytes, () => ModNet.NetTaskBufferBudgetBytes, cancellationToken); + } + + internal static void ReleaseConnection() + { + Interlocked.Decrement(ref _activeConnectionCount); + } + + public static async Task ThrottleAsync(int bytes, CancellationToken cancellationToken) + { + var limit = ModNet.NetTaskSpeedLimitHigh; + if (limit <= 0 || bytes <= 0) + return; + + var reservation = new BandwidthReservation(Math.Max(1L, + (long)Math.Ceiling((double)bytes * Stopwatch.Frequency / limit))); + lock (BandwidthLock) + { + 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; + } + } + + 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, + HostQuotaEntry hostEntry) : IDisposable +{ + private DownloadQuotaLease? _globalLease = globalLease; + private DownloadQuotaLease? _hostLease = hostLease; + private HostQuotaEntry? _hostEntry = hostEntry; + + public void Dispose() + { + var globalLease = Interlocked.Exchange(ref _globalLease, null); + if (globalLease is null) + return; + + 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); + } +} + +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(); + } +} + +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 794a76dd66..0a334db9a9 100644 --- a/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs +++ b/Plain Craft Launcher 2/Modules/Network/Downloader/FileDownloader.cs @@ -1,13 +1,20 @@ +using System.Buffers; +using System.Diagnostics; using System.IO; +using System.Net; using System.Net.Http; -using Downloader; +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) @@ -47,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); @@ -77,118 +94,248 @@ 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 - { - 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; + 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 + : DownloadRequestKind.LargeOrUnknown; - 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); + if (enableParallelChunks && await AdaptiveRangeDownloader.TryDownloadAsync(url, localPath, + useBrowserUserAgent, customUserAgent, cancellationToken, trackedFile, + expectedSize).ConfigureAwait(false)) + { + await ValidateTempFileAsync(localPath, checker, cancellationToken).ConfigureAwait(false); + 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, sequentialRequestKind).ConfigureAwait(false); + } + + private static async Task DownloadSequentiallyAsync(string url, string localPath, bool useBrowserUserAgent, + 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, requestKind); + using var response = await SendDownloadRequestAsync(url, request, 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 responseContentLength = response.Content.Headers.ContentLength ?? -1; + 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; + 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); - }; + long downloaded = 0; + 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: bufferSize, FileOptions.Asynchronous | FileOptions.SequentialScan); + using var bufferLease = await DownloadResourceManager.ReserveBufferAsync(bufferSize, cancellationToken) + .ConfigureAwait(false); + var buffer = ArrayPool.Shared.Rent(bufferSize); try { - 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)) + while (true) { - for (var retry = 0; retry < 5; retry++) + 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) { - try - { - File.Move(tempPath, localPath, true); - break; - } - catch (IOException) - { - 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); + DownloadResourceManager.RecordDownloadedBytes(read); + downloaded += read; + UpdateSequentialProgress(trackedFile, downloaded, totalSize, ref lastProgressBytes, ref lastProgressTick); } - if (!File.Exists(localPath)) - throw new IOException($"下载未产生任何文件:{localPath}"); - ModBase.Log($"[Download] 下载成功:{localPath}"); } - catch (TaskCanceledException ex) when (cancellationToken.IsCancellationRequested) + finally { - throw new OperationCanceledException(cancellationToken); + ArrayPool.Shared.Return(buffer); } - catch (TaskCanceledException ex) + + connection.Dispose(); + await output.DisposeAsync().ConfigureAwait(false); + if (responseContentLength >= 0 && downloaded != responseContentLength) + throw new IOException($"下载不完整:已写入 {downloaded},应为响应声明的 {responseContentLength}"); + + await ValidateTempFileAsync(localPath, checker, cancellationToken).ConfigureAwait(false); + + if (trackedFile is not null && totalSize <= 0) { - throw new TimeoutException($"下载超时({url})", ex); + trackedFile.TotalSize = downloaded; + trackedFile.IsUnknownSize = false; + trackedFile.DownloadedBytes = downloaded; } - catch (OperationCanceledException) + + PromoteTempFile(localPath); + if (!File.Exists(localPath)) + throw new IOException($"下载未产生任何文件:{localPath}"); + MarkDownloadCompleted(trackedFile); + 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) + { + 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, DownloadRequestKind requestKind) + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + RequestSigning.SecretHeadersSign(url, ref request, useBrowserUserAgent, customUserAgent); + ApplyHttpVersion(request, requestKind); + 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 DownloadRequestTimeoutException($"等待下载源响应超时(30 秒):{url}", ex); + } + } + + 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) { - throw; + request.Version = HttpVersion.Version20; + request.VersionPolicy = HttpVersionPolicy.RequestVersionOrLower; } - catch (Exception ex) + else { - throw new IOException($"下载失败:{url}", ex); + request.Version = HttpVersion.Version11; + request.VersionPolicy = HttpVersionPolicy.RequestVersionExact; + } + } + + 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++) + { + 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 +357,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") @@ -221,3 +368,14 @@ private static HttpClient GetHttpClient(string url) return NetworkService.GetClient(); } } + +internal sealed class DownloadRequestTimeoutException(string message, Exception innerException) + : TimeoutException(message, innerException); + +internal enum DownloadRequestKind +{ + SmallOrBatch, + LargeOrUnknown, + RangeProbe, + RangeSegment +} diff --git a/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs b/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs index fe461a8af3..6d5176828b 100644 --- a/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs +++ b/Plain Craft Launcher 2/Modules/Network/Facade/ModNet.cs @@ -5,8 +5,26 @@ 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; + // 主机级限制不再低于全局设置;默认仍由 NetTaskConnectionLimit(64)控制。 + public const int NetTaskConnectionsPerHostLimit = NetTaskConnectionLimitMax; + 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..1b3d16a623 100644 --- a/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs +++ b/Plain Craft Launcher 2/Modules/Network/Http/Requester.cs @@ -133,32 +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.Max(1, ModNet.NetTaskThreadLimit), 4); - 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 = 256L * 1024 * 1024, - RequestConfiguration = DownloadRequestFactory.Create(url, useBrowserUserAgent) - }); - } - public static HttpMethod ParseMethod(string? method) { return (method ?? "GET").ToUpperInvariant() switch diff --git a/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs b/Plain Craft Launcher 2/Modules/Network/Loaders/LoaderDownload.cs index 6ed38c24a5..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; @@ -105,7 +104,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) @@ -116,40 +116,32 @@ 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; - } - - file.State = PCL.Network.NetState.Connecting; - var enableParallelChunks = files.Count <= 1; - for (var retry = 0; retry < 4; retry++) - { - cancellationToken.ThrowIfCancellationRequested(); - try + var checkResult = string.IsNullOrEmpty(checker.hash) + ? checker.Check(file.LocalPath) + : await Task.Run(() => checker.Check(file.LocalPath), cancellationToken).ConfigureAwait(false); + if (checkResult is null) { - 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)); + 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; + // 批量任务中未知大小的文件直接下载,避免小文件逐个产生一次 Range 探测。 + var expectedSize = file.Check?.actualSize ?? -1; + var enableParallelChunks = files.Count <= 1 || expectedSize >= AdaptiveRangeDownloader.SmallFileThreshold; + 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; diff --git a/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs b/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs index f12ea9745b..390957f887 100644 --- a/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs +++ b/Plain Craft Launcher 2/Modules/Network/Management/NetManager.cs @@ -34,22 +34,17 @@ public long DownloadDone public long Speed { - get - { - lock (LockFiles) - return Files.Values.Sum(file => file.Speed); - } + get => DownloadResourceManager.DownloadSpeed; } - public int ThreadCount + public int ConnectionCount { - get - { - lock (LockFiles) - return Files.Values.Sum(file => file.ActiveThreads); - } + get => DownloadResourceManager.ActiveConnectionCount; } + [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..078c945052 100644 --- a/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml +++ b/Plain Craft Launcher 2/Pages/PageSetup/PageSetupGameManage.xaml @@ -20,8 +20,11 @@ + + + + - @@ -39,24 +42,40 @@ - + + + + + + - - + + - - - - + + @@ -178,4 +197,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 9498381bae..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; @@ -125,6 +127,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 +147,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"); } }