diff --git a/src/NetworkOptimizer.Web/Components/Pages/WanSpeedTest.razor b/src/NetworkOptimizer.Web/Components/Pages/WanSpeedTest.razor index ab63371d..22d56a43 100644 --- a/src/NetworkOptimizer.Web/Components/Pages/WanSpeedTest.razor +++ b/src/NetworkOptimizer.Web/Components/Pages/WanSpeedTest.razor @@ -26,7 +26,7 @@ @* Run Test Card *@
-

Measures your internet speed using Cloudflare's global edge network with 6 concurrent connections.

+

Measures your internet speed using Cloudflare's global edge network with multiple concurrent connections.

@* Gateway (Direct) Option - Primary *@
diff --git a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs index fee7f85a..a57f9d0b 100644 --- a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs +++ b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs @@ -1,6 +1,5 @@ using System.Diagnostics; using System.Net; -using System.Runtime; using System.Text.Json; using System.Text.RegularExpressions; using Microsoft.EntityFrameworkCore; @@ -22,8 +21,8 @@ public partial class CloudflareSpeedTestService private const string DownloadPath = "__down?bytes="; private const string UploadPath = "__up"; - // Concurrency and duration settings (matching cloudflare-speed-cli defaults) - private const int Concurrency = 6; + // Concurrency and duration settings + private const int Concurrency = 8; private static readonly TimeSpan DownloadDuration = TimeSpan.FromSeconds(10); private static readonly TimeSpan UploadDuration = TimeSpan.FromSeconds(10); private const int DownloadBytesPerRequest = 10_000_000; // 10 MB per request (matches cloudflare-speed-cli) @@ -35,6 +34,7 @@ public partial class CloudflareSpeedTestService private readonly IDbContextFactory _dbFactory; private readonly INetworkPathAnalyzer _pathAnalyzer; private readonly IConfiguration _configuration; + private readonly Iperf3ServerService _iperf3ServerService; // Observable test state (polled by UI components) private readonly object _lock = new(); @@ -82,13 +82,15 @@ public CloudflareSpeedTestService( IHttpClientFactory httpClientFactory, IDbContextFactory dbFactory, INetworkPathAnalyzer pathAnalyzer, - IConfiguration configuration) + IConfiguration configuration, + Iperf3ServerService iperf3ServerService) { _logger = logger; _httpClientFactory = httpClientFactory; _dbFactory = dbFactory; _pathAnalyzer = pathAnalyzer; _configuration = configuration; + _iperf3ServerService = iperf3ServerService; } /// @@ -98,7 +100,7 @@ public record CloudflareMetadata(string Ip, string City, string Country, string /// /// Run a full Cloudflare WAN speed test with progress reporting. - /// Uses 6 concurrent connections per direction, similar to cloudflare-speed-cli. + /// Uses multiple concurrent connections per direction, similar to cloudflare-speed-cli. /// Test state is tracked on the service so UI components can navigate away and /// poll CurrentProgress / LastCompletedResult when they return. /// @@ -119,6 +121,14 @@ public record CloudflareMetadata(string Ip, string City, string Country, string try { + // Pause iperf3 server during WAN speed test to free pipe handles. + // GC compaction while iperf3 pipe readers are active causes AccessViolationException. + if (_iperf3ServerService.IsRunning) + { + _logger.LogInformation("Pausing iperf3 server for WAN speed test"); + await _iperf3ServerService.PauseAsync(); + } + _logger.LogInformation("Starting Cloudflare WAN speed test ({Concurrency} concurrent connections)", Concurrency); // Wrap progress to always update service-level state (for navigate-away polling) @@ -162,10 +172,6 @@ void Report(string phase, int percent, string? status) downloadMbps, downloadBytes, Concurrency, dlLatencyMs); Report("Download complete", 55, $"Down: {downloadMbps:F1} Mbps"); - // Reclaim download phase memory before starting upload - GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); - GC.WaitForPendingFinalizers(); - // Phase 4: Upload (55-95%) - concurrent connections + latency probes Report("Testing upload", 56, null); var (uploadBps, uploadBytes, ulLatencyMs, ulJitterMs) = await MeasureThroughputAsync( @@ -237,11 +243,6 @@ void Report(string phase, int percent, string? status) Report("Complete", 100, $"Down: {downloadMbps:F1} / Up: {uploadMbps:F1} Mbps"); lock (_lock) _lastCompletedResult = result; - // Reclaim upload phase memory and return to OS - GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); - GC.WaitForPendingFinalizers(); - GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, blocking: true, compacting: true); - // Trigger background path analysis with Cloudflare-reported WAN IP and pre-resolved WAN group var cfWanIp = metadata.Ip; var resolvedWanGroup = result.WanNetworkGroup; @@ -288,6 +289,8 @@ void Report(string phase, int percent, string? status) } finally { + // Resume iperf3 server after WAN speed test completes (or fails/cancels) + await _iperf3ServerService.ResumeAsync(); lock (_lock) _isRunning = false; } } @@ -562,6 +565,8 @@ private static string ColoToCityName(string colo) Interlocked.Add(ref totalBytes, bytesWritten)); using var uploadResponse = await workerClient.PostAsync(url, content, linked.Token); Interlocked.Increment(ref requestCount); + // Drain response body to enable TCP connection reuse (HTTP/1.1) + await uploadResponse.Content.CopyToAsync(Stream.Null, linked.Token); if (!uploadResponse.IsSuccessStatusCode) { Interlocked.Increment(ref errorCount); diff --git a/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs b/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs index f7da174b..3f83e51f 100644 --- a/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs +++ b/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs @@ -18,6 +18,10 @@ public class Iperf3ServerService : BackgroundService private Process? _iperf3Process; private const int Iperf3Port = 5201; + // Pause/resume support (used during WAN speed tests to free pipe handles) + private volatile bool _isPaused; + private TaskCompletionSource? _resumeTcs; + public Iperf3ServerService( ILogger logger, ClientSpeedTestService clientSpeedTestService, @@ -43,6 +47,47 @@ public Iperf3ServerService( /// public string? FailureMessage { get; private set; } + /// + /// Pause the iperf3 server (kills the process and prevents restart). + /// Used during WAN speed tests to free pipe handles that interfere with GC compaction. + /// + public async Task PauseAsync() + { + if (_isPaused) return; + _isPaused = true; + _resumeTcs = new TaskCompletionSource(); + + // Kill current iperf3 process + if (_iperf3Process is { HasExited: false }) + { + try { _iperf3Process.Kill(entireProcessTree: true); } + catch (Exception ex) { _logger.LogDebug(ex, "Error killing iperf3 process during pause"); } + } + + // Also kill orphans to ensure port is free when we resume + await KillOrphanedIperf3ProcessesAsync(); + + _logger.LogInformation("iperf3 server paused"); + } + + /// + /// Resume the iperf3 server after a pause. + /// Kills any orphaned iperf3 processes and waits for the port to be released. + /// + public async Task ResumeAsync() + { + if (!_isPaused) return; + + // Kill any orphaned iperf3 still holding the port, then wait for OS to release it + await KillOrphanedIperf3ProcessesAsync(); + await Task.Delay(1000); + + _isPaused = false; + _resumeTcs?.TrySetResult(); + _resumeTcs = null; + _logger.LogInformation("iperf3 server resumed"); + } + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { // Check if iperf3 server mode is enabled @@ -60,6 +105,22 @@ protected override async Task ExecuteAsync(CancellationToken stoppingToken) while (!stoppingToken.IsCancellationRequested) { + // Wait if paused (e.g., during WAN speed test to free pipe handles) + if (_isPaused && _resumeTcs != null) + { + _logger.LogDebug("iperf3 server paused, waiting for resume signal"); + try + { + await _resumeTcs.Task.WaitAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + consecutiveImmediateExits = 0; + continue; + } + try { var ranSuccessfully = await RunIperf3ServerAsync(stoppingToken); diff --git a/src/cfspeedtest/speedtest/throughput.go b/src/cfspeedtest/speedtest/throughput.go index c19496fb..8b725453 100644 --- a/src/cfspeedtest/speedtest/throughput.go +++ b/src/cfspeedtest/speedtest/throughput.go @@ -126,6 +126,7 @@ func MeasureThroughput(ctx context.Context, isUpload bool, cfg Config) (*Through continue } } + io.Copy(io.Discard, resp.Body) resp.Body.Close() if resp.StatusCode != http.StatusOK { diff --git a/src/cfspeedtest/speedtest/types.go b/src/cfspeedtest/speedtest/types.go index 7f939eea..08b2b5ff 100644 --- a/src/cfspeedtest/speedtest/types.go +++ b/src/cfspeedtest/speedtest/types.go @@ -51,7 +51,7 @@ type Config struct { // DefaultConfig returns sensible defaults matching the C# service. func DefaultConfig() Config { return Config{ - Streams: 6, + Streams: 8, Duration: 10 * time.Second, DownloadSize: 10_000_000, // 10 MB UploadSize: 5_000_000, // 5 MB