From bdf503c6274d1931e9e6f7b6782c798e30c1acb9 Mon Sep 17 00:00:00 2001 From: MaddoScientisto Date: Sun, 8 Mar 2026 14:30:37 +0100 Subject: [PATCH] feat: Refactor RaceUploadCommunicationClient and RaceUploadTabView to improve HttpClient management and resource disposal --- ...ommunicationServiceCollectionExtensions.cs | 38 ++-- .../RaceUploadCommunicationClient.cs | 15 +- .../AvaloniaViews/RaceUploadTabView.axaml.cs | 181 +++++++++++------- 3 files changed, 149 insertions(+), 85 deletions(-) diff --git a/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs b/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs index 231242d..bb5a5b1 100644 --- a/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs +++ b/Catalog.Communication/DependencyInjection/CatalogCommunicationServiceCollectionExtensions.cs @@ -2,6 +2,8 @@ using System.Net; using Catalog.Communication.Abstractions; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; namespace Catalog.Communication.DependencyInjection; @@ -25,22 +27,30 @@ public static class CatalogCommunicationServiceCollectionExtensions services.TryAddSingleton(); - services - .AddHttpClient((sp, client) => + // Create the HttpClient only when the communication client is requested. + // This avoids constructing the DefaultHttpClientFactory (and its background cleanup timer) + // if the race-upload feature is never used. + services.AddTransient(sp => + { + var options = sp.GetRequiredService>().Value; + var logger = sp.GetService>() ?? NullLogger.Instance; + var cookieContainer = sp.GetRequiredService(); + + var handler = new HttpClientHandler { - var options = sp.GetRequiredService>().Value; - client.BaseAddress = options.BaseUri; - }) - .ConfigurePrimaryHttpMessageHandler(sp => + UseCookies = true, + CookieContainer = cookieContainer, + AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, + }; + + var httpClient = new HttpClient(handler, disposeHandler: true) { - var cookieContainer = sp.GetRequiredService(); - return new HttpClientHandler - { - UseCookies = true, - CookieContainer = cookieContainer, - AutomaticDecompression = DecompressionMethods.GZip | DecompressionMethods.Deflate | DecompressionMethods.Brotli, - }; - }); + BaseAddress = options.BaseUri, + Timeout = options.RequestTimeout, + }; + + return new RaceUploadCommunicationClient(httpClient, sp.GetRequiredService>(), logger); + }); return services; } diff --git a/Catalog.Communication/RaceUploadCommunicationClient.cs b/Catalog.Communication/RaceUploadCommunicationClient.cs index fc3987a..9657dfe 100644 --- a/Catalog.Communication/RaceUploadCommunicationClient.cs +++ b/Catalog.Communication/RaceUploadCommunicationClient.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.Options; namespace Catalog.Communication; -public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient +public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClient, IDisposable { private const string AdminMenuPath = "admin/menu/Menu4.abl"; private const string PublicLogonPath = "Logon.abl"; @@ -27,6 +27,7 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie private readonly HttpClient _httpClient; private readonly ILogger _logger; private readonly IOptions _options; + private bool _disposed; public RaceUploadCommunicationClient( HttpClient httpClient, @@ -38,6 +39,18 @@ public sealed class RaceUploadCommunicationClient : IRaceUploadCommunicationClie _logger = logger; } + public void Dispose() + { + if (_disposed) + { + return; + } + + _httpClient.Dispose(); + _disposed = true; + GC.SuppressFinalize(this); + } + public Task LoginAdminAsync(AdminLoginRequest request, CancellationToken cancellationToken = default) { ArgumentNullException.ThrowIfNull(request); diff --git a/imagecatalog/AvaloniaViews/RaceUploadTabView.axaml.cs b/imagecatalog/AvaloniaViews/RaceUploadTabView.axaml.cs index 28be1ed..8e38799 100644 --- a/imagecatalog/AvaloniaViews/RaceUploadTabView.axaml.cs +++ b/imagecatalog/AvaloniaViews/RaceUploadTabView.axaml.cs @@ -17,18 +17,21 @@ namespace ImageCatalog_2.AvaloniaViews; public partial class RaceUploadTabView : Avalonia.Controls.UserControl { - private readonly IRaceUploadCommunicationClient _apiClient; private readonly ILogger _logger; public RaceUploadTabView() { InitializeComponent(); - _apiClient = Program.ServiceProvider.GetService(typeof(IRaceUploadCommunicationClient)) as IRaceUploadCommunicationClient - ?? throw new InvalidOperationException("IRaceUploadCommunicationClient non disponibile."); _logger = Program.ServiceProvider.GetService(typeof(ILogger)) as ILogger ?? NullLogger.Instance; } + private static IRaceUploadCommunicationClient CreateClient() + { + return Program.ServiceProvider.GetService(typeof(IRaceUploadCommunicationClient)) as IRaceUploadCommunicationClient + ?? throw new InvalidOperationException("IRaceUploadCommunicationClient non disponibile."); + } + private async void CreateRace_Click(object? sender, RoutedEventArgs e) { var outputBox = this.FindControl("ApiOutputTextBox"); @@ -76,32 +79,43 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl var loginResponse = await LoginAsync(login, password).ConfigureAwait(true); - var saveResponse = await _apiClient.SaveRaceAsync( - new RaceSaveRequest - { - IdGara = 0, - Description = sanitizedDescription, - StartDate = startDate, - EndDate = endDate, - TipoGaraId = tipoGaraId, - EventoInLinea = model.ApiEventoInLineaIndex, - TipoIndicizzazione = model.ApiTipoIndexValue, - FreeEvent = model.ApiFreeEventIndex, - PathBase = model.ApiPathBase?.Trim(), - Localita = model.ApiLocalita?.Trim(), - }, - CancellationToken.None); + RawEndpointResponse saveResponse; + RawEndpointResponse createPointsResponse; + long raceId; - var raceId = ExtractRaceId(saveResponse.Body); - if (raceId <= 0) + var client = CreateClient(); + try { - throw new InvalidOperationException("Impossibile ricavare id_gara dalla risposta di salvataggio."); + saveResponse = await client.SaveRaceAsync( + new RaceSaveRequest + { + IdGara = 0, + Description = sanitizedDescription, + StartDate = startDate, + EndDate = endDate, + TipoGaraId = tipoGaraId, + EventoInLinea = model.ApiEventoInLineaIndex, + TipoIndicizzazione = model.ApiTipoIndexValue, + FreeEvent = model.ApiFreeEventIndex, + PathBase = model.ApiPathBase?.Trim(), + Localita = model.ApiLocalita?.Trim(), + }, + CancellationToken.None).ConfigureAwait(false); + + raceId = ExtractRaceId(saveResponse.Body); + if (raceId <= 0) + { + throw new InvalidOperationException("Impossibile ricavare id_gara dalla risposta di salvataggio."); + } + + model.ApiRaceId = raceId.ToString(); + + createPointsResponse = await client.CreateRacePointsAsync(raceId, CancellationToken.None).ConfigureAwait(false); + } + finally + { + (client as IDisposable)?.Dispose(); } - - model.ApiRaceId = raceId.ToString(); - - var createPointsResponse = await _apiClient.CreateRacePointsAsync(raceId, CancellationToken.None); - var sb = new StringBuilder(); sb.AppendLine($"Login HTTP: {(int)loginResponse.StatusCode} {loginResponse.StatusCode}"); sb.AppendLine($"Save Gara HTTP: {(int)saveResponse.StatusCode} {saveResponse.StatusCode}"); @@ -197,41 +211,50 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl var sb = new StringBuilder(); sb.AppendLine($"File da inviare: {files.Count}"); - foreach (var file in files) + List pointIds; + + var client = CreateClient(); + try { - var relativePath = Path.GetRelativePath(model.DestinationPath, file); - var relativeDir = Path.GetDirectoryName(relativePath) ?? string.Empty; - var remotePath = CombineRemotePath(remoteProcessedBase, racePathBase, relativeDir); - - await using var stream = File.OpenRead(file); - await _apiClient.UploadFileToReceiverAsync( - new ReceiveFileUploadRequest - { - FileName = Path.GetFileName(file), - FileStream = stream, - DestinationPath = remotePath, - OverwriteRemoteFile = true, - }, - CancellationToken.None).ConfigureAwait(true); - - uploaded++; - if (uploaded % 20 == 0 || uploaded == files.Count) + foreach (var file in files) { - statusBlock.Text = $"Upload foto: {uploaded}/{files.Count}"; + var relativePath = Path.GetRelativePath(model.DestinationPath, file); + var relativeDir = Path.GetDirectoryName(relativePath) ?? string.Empty; + var remotePath = CombineRemotePath(remoteProcessedBase, racePathBase, relativeDir); + + await using var stream = File.OpenRead(file); + await client.UploadFileToReceiverAsync( + new ReceiveFileUploadRequest + { + FileName = Path.GetFileName(file), + FileStream = stream, + DestinationPath = remotePath, + OverwriteRemoteFile = true, + }, + CancellationToken.None).ConfigureAwait(true); + + uploaded++; + if (uploaded % 20 == 0 || uploaded == files.Count) + { + statusBlock.Text = $"Upload foto: {uploaded}/{files.Count}"; + } + } + + sb.AppendLine($"Upload completato: {uploaded}/{files.Count}"); + statusBlock.Text = "Creazione punti foto e indicizzazione in corso..."; + + await client.CreateRacePointsAsync(raceId, CancellationToken.None).ConfigureAwait(true); + pointIds = await LoadPointIdsWithRetryAsync(raceId, CancellationToken.None).ConfigureAwait(true); + + foreach (var pointId in pointIds) + { + await client.IndexRacePointAsync(pointId, CancellationToken.None).ConfigureAwait(true); } } - - sb.AppendLine($"Upload completato: {uploaded}/{files.Count}"); - statusBlock.Text = "Creazione punti foto e indicizzazione in corso..."; - - await _apiClient.CreateRacePointsAsync(raceId, CancellationToken.None).ConfigureAwait(true); - var pointIds = await LoadPointIdsWithRetryAsync(raceId, CancellationToken.None).ConfigureAwait(true); - - foreach (var pointId in pointIds) + finally { - await _apiClient.IndexRacePointAsync(pointId, CancellationToken.None).ConfigureAwait(true); + (client as IDisposable)?.Dispose(); } - sb.AppendLine($"Punti foto indicizzati: {pointIds.Count}"); outputBox.Text = sb.ToString(); statusBlock.Text = "Upload e indicizzazione completati."; @@ -251,31 +274,49 @@ public partial class RaceUploadTabView : Avalonia.Controls.UserControl private async Task LoginAsync(string login, string password) { - return await _apiClient.LoginAdminAsync( - new AdminLoginRequest - { - Login = login, - Password = password, - Command = "check", - }, - CancellationToken.None).ConfigureAwait(false); + var client = CreateClient(); + try + { + return await client.LoginAdminAsync( + new AdminLoginRequest + { + Login = login, + Password = password, + Command = "check", + }, + CancellationToken.None).ConfigureAwait(false); + } + finally + { + (client as IDisposable)?.Dispose(); + } } private async Task> LoadPointIdsWithRetryAsync(long raceId, CancellationToken cancellationToken) { const int maxAttempts = 10; - for (var attempt = 1; attempt <= maxAttempts; attempt++) + var client = CreateClient(); + try { - var response = await _apiClient.GetRaceDetailAsync(raceId, cancellationToken).ConfigureAwait(false); - var ids = ExtractPointIds(response.Body); - - if (ids.Count > 0) + for (var attempt = 1; attempt <= maxAttempts; attempt++) { - return ids; + var response = await client.GetRaceDetailAsync(raceId, cancellationToken).ConfigureAwait(false); + var ids = ExtractPointIds(response.Body); + + if (ids.Count > 0) + { + return ids; + } + + await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); } - await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken).ConfigureAwait(false); + return new List(); + } + finally + { + (client as IDisposable)?.Dispose(); } return [];