Skip to content
Merged
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
@* Run Test Card *@
<div class="card">
<div class="card-body">
<p class="wan-test-description">Measures your internet speed using Cloudflare's global edge network with 6 concurrent connections.</p>
<p class="wan-test-description">Measures your internet speed using Cloudflare's global edge network with multiple concurrent connections.</p>
<div class="wan-test-options">
@* Gateway (Direct) Option - Primary *@
<div class="wan-test-option @(_gatewayAvailable ? "" : "wan-test-option-unavailable")">
Expand Down
33 changes: 19 additions & 14 deletions src/NetworkOptimizer.Web/Services/CloudflareSpeedTestService.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
using System.Diagnostics;
using System.Net;
using System.Runtime;
using System.Text.Json;
using System.Text.RegularExpressions;
using Microsoft.EntityFrameworkCore;
Expand All @@ -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)
Expand All @@ -35,6 +34,7 @@ public partial class CloudflareSpeedTestService
private readonly IDbContextFactory<NetworkOptimizerDbContext> _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();
Expand Down Expand Up @@ -82,13 +82,15 @@ public CloudflareSpeedTestService(
IHttpClientFactory httpClientFactory,
IDbContextFactory<NetworkOptimizerDbContext> dbFactory,
INetworkPathAnalyzer pathAnalyzer,
IConfiguration configuration)
IConfiguration configuration,
Iperf3ServerService iperf3ServerService)
{
_logger = logger;
_httpClientFactory = httpClientFactory;
_dbFactory = dbFactory;
_pathAnalyzer = pathAnalyzer;
_configuration = configuration;
_iperf3ServerService = iperf3ServerService;
}

/// <summary>
Expand All @@ -98,7 +100,7 @@ public record CloudflareMetadata(string Ip, string City, string Country, string

/// <summary>
/// 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.
/// </summary>
Expand All @@ -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)
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
}
Expand Down Expand Up @@ -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);
Expand Down
61 changes: 61 additions & 0 deletions src/NetworkOptimizer.Web/Services/Iperf3ServerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Iperf3ServerService> logger,
ClientSpeedTestService clientSpeedTestService,
Expand All @@ -43,6 +47,47 @@ public Iperf3ServerService(
/// </summary>
public string? FailureMessage { get; private set; }

/// <summary>
/// Pause the iperf3 server (kills the process and prevents restart).
/// Used during WAN speed tests to free pipe handles that interfere with GC compaction.
/// </summary>
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");
}

/// <summary>
/// Resume the iperf3 server after a pause.
/// Kills any orphaned iperf3 processes and waits for the port to be released.
/// </summary>
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
Expand All @@ -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);
Expand Down
1 change: 1 addition & 0 deletions src/cfspeedtest/speedtest/throughput.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion src/cfspeedtest/speedtest/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down