From e9c989c3f7f828534daad622e4c5da212c346861 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 16:07:51 -0600 Subject: [PATCH 1/9] Drain upload response body to enable TCP connection reuse Without reading the response body to EOF before closing, Go's HTTP/1.1 client cannot reuse the connection. Every upload POST was creating a new TCP + TLS handshake (~8ms overhead per 5MB chunk on 4ms RTT). --- src/cfspeedtest/speedtest/throughput.go | 1 + 1 file changed, 1 insertion(+) 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 { From 6115116ac4354c43a47e09a9e0e8b96f587c5398 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 16:39:43 -0600 Subject: [PATCH 2/9] Increase concurrent streams to 8 and drain upload response body Testing showed 8 streams saturates GPON upstream better than 6 (avg 1012 vs 946 Mbps upload). Drain upload response body in .NET to enable TCP connection reuse. Update UI text to say "multiple" instead of hardcoding the stream count. --- .../Components/Pages/WanSpeedTest.razor | 2 +- .../Services/CloudflareSpeedTestService.cs | 8 +++++--- src/cfspeedtest/speedtest/types.go | 2 +- 3 files changed, 7 insertions(+), 5 deletions(-) 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..187a2ea2 100644 --- a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs +++ b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs @@ -22,8 +22,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) @@ -98,7 +98,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. /// @@ -562,6 +562,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/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 From 31c46109f6bf8626e5863a0303bc7360af07fa11 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 17:09:02 -0600 Subject: [PATCH 3/9] Replace aggressive GC with optimized non-blocking collection Aggressive GC with compacting corrupts pinned pipe handles from the iperf3 process stream reader, causing AccessViolationException crashes. Use Optimized + non-blocking instead to hint the runtime without forcing memory compaction over active async handles. --- .../Services/CloudflareSpeedTestService.cs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs index 187a2ea2..6d5f8e24 100644 --- a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs +++ b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs @@ -163,7 +163,7 @@ void Report(string phase, int percent, string? status) 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.Collect(GC.MaxGeneration, GCCollectionMode.Optimized, blocking: false); GC.WaitForPendingFinalizers(); // Phase 4: Upload (55-95%) - concurrent connections + latency probes @@ -237,10 +237,9 @@ 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); + // Reclaim upload phase memory + GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized, blocking: false); 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; From a48ccc269d0cc53eff7aa32bd616801e89ac4307 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 17:14:17 -0600 Subject: [PATCH 4/9] Use Forced GC without compaction to safely reclaim speed test memory Compacting GC moves live objects to new addresses, corrupting in-flight Spans and pipe handles held by background threads (iperf3 stream reader). Forced + blocking + no compaction sweeps dead objects without relocating live ones, safely reclaiming memory while preserving pointer validity. --- .../Services/CloudflareSpeedTestService.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs index 6d5f8e24..ed076ad3 100644 --- a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs +++ b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs @@ -163,7 +163,9 @@ void Report(string phase, int percent, string? status) Report("Download complete", 55, $"Down: {downloadMbps:F1} Mbps"); // Reclaim download phase memory before starting upload - GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized, blocking: false); + // Use Forced (not Aggressive) to avoid decommitting segments, and compacting: false + // to avoid moving live objects - compacting corrupts in-flight Spans and pipe handles + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); GC.WaitForPendingFinalizers(); // Phase 4: Upload (55-95%) - concurrent connections + latency probes @@ -238,7 +240,7 @@ void Report(string phase, int percent, string? status) lock (_lock) _lastCompletedResult = result; // Reclaim upload phase memory - GC.Collect(GC.MaxGeneration, GCCollectionMode.Optimized, blocking: false); + GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); GC.WaitForPendingFinalizers(); // Trigger background path analysis with Cloudflare-reported WAN IP and pre-resolved WAN group From 95908f65ed73c6cdf3550f01a346d412dd29d8db Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 17:18:03 -0600 Subject: [PATCH 5/9] Add LOH compaction to reclaim large upload buffers after speed test CompactOnce targets only the Large Object Heap (upload payloads, HTTP buffers >85KB) without touching the Small Object Heap where pipe handles and async state machines live. This reclaims the ~1.2 GB upload memory while avoiding the AccessViolationException from full-heap compaction. --- .../Services/CloudflareSpeedTestService.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs index ed076ad3..e7bc8b43 100644 --- a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs +++ b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs @@ -163,8 +163,9 @@ void Report(string phase, int percent, string? status) Report("Download complete", 55, $"Down: {downloadMbps:F1} Mbps"); // Reclaim download phase memory before starting upload - // Use Forced (not Aggressive) to avoid decommitting segments, and compacting: false - // to avoid moving live objects - compacting corrupts in-flight Spans and pipe handles + // Compact LOH only (where large download buffers live) - safe because it doesn't + // relocate small object heap where pipe handles and async state machines reside + GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); GC.WaitForPendingFinalizers(); @@ -239,7 +240,8 @@ 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 + // Reclaim upload phase memory and return LOH segments to OS + GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); GC.WaitForPendingFinalizers(); From da96ee0f7375bed8e82772a79dc7bad1b5dcb5e5 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 17:23:16 -0600 Subject: [PATCH 6/9] Remove manual GC calls from speed test - let runtime manage memory Manual GC.Collect with compacting caused AccessViolationException by corrupting in-flight Spans and pipe handles. Rather than finding the right GC incantation, remove all manual GC calls and let the runtime handle memory pressure naturally. Testing with DOTNET_GCHeapHardLimit on Mac to validate behavior under memory constraints. --- .../Services/CloudflareSpeedTestService.cs | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs index e7bc8b43..9eadef2f 100644 --- a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs +++ b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs @@ -162,13 +162,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 - // Compact LOH only (where large download buffers live) - safe because it doesn't - // relocate small object heap where pipe handles and async state machines reside - GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; - GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); - GC.WaitForPendingFinalizers(); - // Phase 4: Upload (55-95%) - concurrent connections + latency probes Report("Testing upload", 56, null); var (uploadBps, uploadBytes, ulLatencyMs, ulJitterMs) = await MeasureThroughputAsync( @@ -240,10 +233,7 @@ 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 LOH segments to OS - GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; - GC.Collect(GC.MaxGeneration, GCCollectionMode.Forced, blocking: true, compacting: false); - GC.WaitForPendingFinalizers(); + // Trigger background path analysis with Cloudflare-reported WAN IP and pre-resolved WAN group var cfWanIp = metadata.Ip; From 402fc1de7fd249972720df49891647d58c409fb1 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 17:36:36 -0600 Subject: [PATCH 7/9] Pause iperf3 server during WAN speed tests to prevent GC crash GC compaction while iperf3 pipe readers are active causes AccessViolationException. Pause the iperf3 process before the Cloudflare speed test and resume it after. --- .../Services/CloudflareSpeedTestService.cs | 16 +++++- .../Services/Iperf3ServerService.cs | 55 +++++++++++++++++++ 2 files changed, 69 insertions(+), 2 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs index 9eadef2f..b9f26251 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; @@ -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; } /// @@ -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) @@ -281,6 +291,8 @@ void Report(string phase, int percent, string? status) } finally { + // Resume iperf3 server after WAN speed test completes (or fails/cancels) + _iperf3ServerService.Resume(); lock (_lock) _isRunning = false; } } diff --git a/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs b/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs index f7da174b..3d327522 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,41 @@ 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. + /// + public void Resume() + { + if (!_isPaused) return; + _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 +99,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); From 5ea1ecc8c97afd845597a30c23797dc8cb714844 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 17:45:02 -0600 Subject: [PATCH 8/9] Make Resume async with orphan cleanup and port release delay --- .../Services/CloudflareSpeedTestService.cs | 2 +- src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs index b9f26251..dd0f9487 100644 --- a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs +++ b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs @@ -292,7 +292,7 @@ void Report(string phase, int percent, string? status) finally { // Resume iperf3 server after WAN speed test completes (or fails/cancels) - _iperf3ServerService.Resume(); + await _iperf3ServerService.ResumeAsync(); lock (_lock) _isRunning = false; } } diff --git a/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs b/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs index 3d327522..3f83e51f 100644 --- a/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs +++ b/src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs @@ -72,10 +72,16 @@ public async Task PauseAsync() /// /// Resume the iperf3 server after a pause. + /// Kills any orphaned iperf3 processes and waits for the port to be released. /// - public void Resume() + 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; From 8833c1c7fc30c5ffefc2d948936c1daa5f67dc21 Mon Sep 17 00:00:00 2001 From: TJ da Tuna Date: Sat, 14 Feb 2026 18:07:32 -0600 Subject: [PATCH 9/9] Remove extra blank lines from GC removal --- src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs index dd0f9487..a57f9d0b 100644 --- a/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs +++ b/src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs @@ -243,8 +243,6 @@ void Report(string phase, int percent, string? status) Report("Complete", 100, $"Down: {downloadMbps:F1} / Up: {uploadMbps:F1} Mbps"); lock (_lock) _lastCompletedResult = result; - - // Trigger background path analysis with Cloudflare-reported WAN IP and pre-resolved WAN group var cfWanIp = metadata.Ip; var resolvedWanGroup = result.WanNetworkGroup;