using System.Collections.Concurrent; using LumaTunnel.Client.Core.Abstractions; using LumaTunnel.Shared.Models; namespace LumaTunnel.Client.Core.Nodes; public sealed class NodeManagerService( NodeApiClient apiClient, ITunnelService tunnelService, Func> getDeviceToken, string clientVersion) : INodeManagerService { private readonly ConcurrentDictionary _failures = new(); private readonly SemaphoreSlim _switchGate = new(1, 1); private volatile NodeProfile[] _nodes = []; private CancellationTokenSource? _monitorCancellation; private Task? _monitorTask; public NodeProfile? ActiveNode { get; private set; } public IReadOnlyList Nodes => _nodes; public event EventHandler? ActiveNodeChanged; public void SetNodes(IEnumerable nodes) { ArgumentNullException.ThrowIfNull(nodes); _nodes = nodes.OrderBy(static node => node.Priority).ToArray(); } public async Task ConnectBestAsync(CancellationToken cancellationToken = default) { await _switchGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { var candidates = _nodes.Where(static node => node.Enabled && node.DeviceId is not null).ToArray(); if (candidates.Length == 0) throw new InvalidOperationException("No enrolled LumaTunnel node is enabled."); var checks = await Task.WhenAll(candidates.Select(node => CheckNodeAsync(node, cancellationToken))).ConfigureAwait(false); var best = checks.Where(static result => result.Success).OrderBy(static result => result.Latency).ThenBy(static result => result.Node.Priority).FirstOrDefault() ?? throw new IOException("None of the configured LumaTunnel nodes is healthy."); var token = await getDeviceToken(best.Node, cancellationToken).ConfigureAwait(false) ?? throw new InvalidOperationException($"No device token is available for node '{best.Node.Name}'."); await tunnelService.ConnectAsync(new TunnelCredentials(best.Node.TunnelUri, best.Node.DeviceId!, token, clientVersion), cancellationToken).ConfigureAwait(false); ActiveNode = best.Node with { LastLatencyMilliseconds = best.Latency.TotalMilliseconds, LastHealthCheckUtc = DateTimeOffset.UtcNow }; _failures[best.Node.Id] = 0; ActiveNodeChanged?.Invoke(this, ActiveNode); return ActiveNode; } finally { _switchGate.Release(); } } public Task StartMonitoringAsync(CancellationToken cancellationToken = default) { cancellationToken.ThrowIfCancellationRequested(); if (_monitorCancellation is not null) return Task.CompletedTask; _monitorCancellation = new CancellationTokenSource(); _monitorTask = MonitorLoopAsync(_monitorCancellation.Token); return Task.CompletedTask; } public async Task StopMonitoringAsync(CancellationToken cancellationToken = default) { var cancellation = _monitorCancellation; var task = _monitorTask; _monitorCancellation = null; _monitorTask = null; if (cancellation is null) return; cancellation.Cancel(); if (task is not null) await Task.WhenAny(task, Task.Delay(TimeSpan.FromSeconds(2), cancellationToken)).ConfigureAwait(false); cancellation.Dispose(); } public async ValueTask DisposeAsync() { await StopMonitoringAsync().ConfigureAwait(false); _switchGate.Dispose(); apiClient.Dispose(); GC.SuppressFinalize(this); } private async Task MonitorLoopAsync(CancellationToken cancellationToken) { try { while (!cancellationToken.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(30), cancellationToken).ConfigureAwait(false); var active = ActiveNode; if (active is null) continue; var check = await CheckNodeAsync(active, cancellationToken).ConfigureAwait(false); if (check.Success && tunnelService.IsConnected) { _failures[active.Id] = 0; continue; } var failures = _failures.AddOrUpdate(active.Id, 1, static (_, count) => count + 1); if (failures >= 3) { try { await ConnectBestAsync(cancellationToken).ConfigureAwait(false); } catch (IOException) { } } } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { } } private async Task CheckNodeAsync(NodeProfile node, CancellationToken cancellationToken) { try { var (_, latency) = await apiClient.GetHealthAsync(node.ApiUri, cancellationToken).ConfigureAwait(false); return new HealthResult(node, true, latency); } catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException or InvalidDataException) { return new HealthResult(node, false, TimeSpan.MaxValue); } } private sealed record HealthResult(NodeProfile Node, bool Success, TimeSpan Latency); }