Load the proxy pool at startup, and stop old settings files zeroing new defaults

The pool was empty until someone pressed Refresh, which also meant the parser
would have had nothing to work with. A ProxyPoolLoader now fills it once at
launch; startup does not await it, because blocking on a public list being
reachable would be the wrong trade, and it never throws. The proxy page joins
the same operation rather than starting a second download, so it reports the
outcome whether it is opened during the load or long after.

That change surfaced a worse bug underneath. The first run still loaded zero
entries with no error logged at all, which turned out to be the feed source
never being asked: options said UseFeed=False and Protocols=None. Neither is
reachable from the UI — both are default(T).

The cause is that AppSettings kept its defaults on property initialisers, and
the source-generated deserialiser does not run them. Reflection-based
deserialisation of "{}" keeps them; the generated context does not. So a
settings.json written before a setting existed came back with default(T) for it:
the proxy feed switched off, the protocol filter empty, the probe timeout zero
and the probe URL blank — and the app looked like the network had failed.

Defaults now live on primary constructor parameters, which STJ applies for
absent JSON members on both paths, so an older file upgrades cleanly. The
regression test writes a settings file from before the proxy settings existed
and asserts each one comes back at its default. ToProxyOptions also treats an
empty protocol filter as "all", since a hand-edited file that matches nothing is
the least useful possible reading of it.

Also quietens IHttpClientFactory to Warning: four Information lines per request
buried everything the app said, and a proxy sweep makes thousands of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Leonid Pershin
2026-08-13 18:33:41 +03:00
co-authored by Claude Opus 5
parent 8b552470b7
commit 85656e70b0
11 changed files with 432 additions and 48 deletions
+44 -47
View File
@@ -19,57 +19,54 @@ public enum AppTheme
/// Everything the app remembers between runs. Persisted verbatim as JSON.
/// </summary>
/// <remarks>
/// A mutable record with defaults on every property: a settings file written by an older
/// version must still deserialise, so no property may be required.
/// <para>
/// Defaults live on the <b>primary constructor parameters</b>, not on property initialisers.
/// That is not a style choice: the source-generated serialiser constructs the object without
/// running property initialisers, so a settings file written by an older build came back with
/// <c>default(T)</c> for every setting added since — which silently switched the proxy feed off
/// and zeroed the probe timeout. Constructor parameter defaults are applied for absent JSON
/// members, so an old file now upgrades cleanly.
/// </para>
/// <para>
/// No property may be required, for the same reason: an old file must still deserialise.
/// </para>
/// </remarks>
public sealed record AppSettings
/// <param name="Theme">Chosen theme variant.</param>
/// <param name="Language">Chosen UI language.</param>
/// <param name="LastParserId">Id of the parser selected last time; resolved leniently on load.</param>
/// <param name="WindowWidth">Last main-window width in device-independent pixels.</param>
/// <param name="WindowHeight">Last main-window height in device-independent pixels.</param>
/// <param name="WindowMaximized">Whether the main window was maximised on exit.</param>
/// <param name="MinimumLogLevel">Minimum Serilog level, as a Serilog level name.</param>
/// <param name="ProxyRotation">How the pool picks the next proxy.</param>
/// <param name="ProxyHealthCheck">When proxy liveness is verified.</param>
/// <param name="ProxyUseFeed">Whether the remote proxy feed is consulted.</param>
/// <param name="ProxyProtocols">Protocols accepted when loading proxy sources.</param>
/// <param name="ProxyProbeUrl">URL fetched to decide whether a proxy works.</param>
/// <param name="ProxyProbeTimeoutSeconds">Per-proxy probe timeout, in seconds.</param>
/// <param name="ProxyProbeConcurrency">How many probes run at once during a pool sweep.</param>
public sealed record AppSettings(
AppTheme Theme = AppTheme.System,
AppLanguage Language = AppLanguage.System,
string? LastParserId = null,
double WindowWidth = 1280,
double WindowHeight = 800,
bool WindowMaximized = false,
string MinimumLogLevel = "Information",
ProxyRotation ProxyRotation = ProxyRotation.Sticky,
ProxyHealthCheck ProxyHealthCheck = ProxyHealthCheck.Pool,
bool ProxyUseFeed = true,
ProxyProtocolFilter ProxyProtocols = ProxyProtocolFilter.All,
string ProxyProbeUrl = "http://www.gstatic.com/generate_204",
int ProxyProbeTimeoutSeconds = 8,
int ProxyProbeConcurrency = 64
)
{
/// <summary>Chosen theme variant.</summary>
public AppTheme Theme { get; init; } = AppTheme.System;
/// <summary>Chosen UI language.</summary>
public AppLanguage Language { get; init; } = AppLanguage.System;
/// <summary>Id of the parser selected last time; resolved leniently on load.</summary>
public string? LastParserId { get; init; }
/// <summary>Last main-window width in device-independent pixels.</summary>
public double WindowWidth { get; init; } = 1280;
/// <summary>Last main-window height in device-independent pixels.</summary>
public double WindowHeight { get; init; } = 800;
/// <summary>Whether the main window was maximised on exit.</summary>
public bool WindowMaximized { get; init; }
/// <summary>Minimum Serilog level, as a Serilog level name.</summary>
public string MinimumLogLevel { get; init; } = "Information";
/// <summary>How the pool picks the next proxy.</summary>
public ProxyRotation ProxyRotation { get; init; } = ProxyRotation.Sticky;
/// <summary>When proxy liveness is verified.</summary>
public ProxyHealthCheck ProxyHealthCheck { get; init; } = ProxyHealthCheck.Pool;
/// <summary>Whether the remote proxy feed is consulted.</summary>
public bool ProxyUseFeed { get; init; } = true;
/// <summary>Protocols accepted when loading proxy sources.</summary>
public ProxyProtocolFilter ProxyProtocols { get; init; } = ProxyProtocolFilter.All;
/// <summary>URL fetched to decide whether a proxy works.</summary>
public string ProxyProbeUrl { get; init; } = "http://www.gstatic.com/generate_204";
/// <summary>Per-proxy probe timeout, in seconds.</summary>
public int ProxyProbeTimeoutSeconds { get; init; } = 8;
/// <summary>How many probes run at once during a pool sweep.</summary>
public int ProxyProbeConcurrency { get; init; } = 64;
/// <summary>Projects the proxy-related settings onto <see cref="ProxyOptions"/>.</summary>
/// <remarks>
/// Settings are persisted as primitives so an old file still deserialises; the pool wants a
/// validated options object. This is the single place that bridges the two.
/// validated options object. This is the single place that bridges the two, and it clamps
/// rather than throws so a hand-edited file cannot stop the app from starting.
/// </remarks>
public ProxyOptions ToProxyOptions()
{
@@ -82,7 +79,7 @@ public sealed record AppSettings
Rotation = ProxyRotation,
HealthCheck = ProxyHealthCheck,
UseFeed = ProxyUseFeed,
Protocols = ProxyProtocols,
Protocols = ProxyProtocols == ProxyProtocolFilter.None ? ProxyProtocolFilter.All : ProxyProtocols,
ProbeUrl = probeUrl,
ProbeTimeout = TimeSpan.FromSeconds(Math.Clamp(ProxyProbeTimeoutSeconds, 1, 120)),
ProbeConcurrency = Math.Clamp(ProxyProbeConcurrency, 1, 512),
+6
View File
@@ -3,6 +3,7 @@ using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Proxies;
using AvParser.UI;
using AvParser.UI.Services;
using AvParser.UI.ViewModels;
@@ -57,6 +58,11 @@ public partial class App : Application
desktop.ShutdownRequested += (_, _) => settings.FlushAsync().GetAwaiter().GetResult();
}
// Start filling the proxy pool as soon as the window is up. Not awaited on purpose: the
// load is a network round trip, and blocking startup on a public list being reachable
// would be the wrong trade. It never throws, so there is nothing to observe.
_ = _services.GetRequiredService<IProxyPoolLoader>().EnsureLoadedAsync();
base.OnFrameworkInitializationCompleted();
}
@@ -75,6 +75,7 @@ public static class InfrastructureServiceCollectionExtensions
));
services.AddSingleton<IProxiedHttpClientFactory, ProxiedHttpClientFactory>();
services.AddSingleton<IProxyPoolLoader, ProxyPoolLoader>();
return services;
}
@@ -30,6 +30,9 @@ public static class AppLogging
var logger = new LoggerConfiguration()
.MinimumLevel.ControlledBy(levelSwitch)
// IHttpClientFactory logs four Information lines per request. At Information that
// buries everything the app itself says, and the proxy sweep makes thousands of them.
.MinimumLevel.Override("System.Net.Http.HttpClient", LogEventLevel.Warning)
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: OutputTemplate)
.WriteTo.File(
@@ -0,0 +1,75 @@
using AvParser.Core.Proxies;
using Microsoft.Extensions.Logging;
namespace AvParser.Infrastructure.Proxies;
/// <summary>Fills the pool once at startup, so nothing has to be loaded by hand.</summary>
public interface IProxyPoolLoader
{
/// <summary>
/// Loads every source, once per process. Later callers get the same operation rather than a
/// second download.
/// </summary>
/// <returns>How many proxies the pool holds afterwards.</returns>
/// <remarks>Never throws: a source that is down leaves the pool as it was.</remarks>
Task<int> EnsureLoadedAsync();
/// <summary>Whether the initial load has finished.</summary>
bool IsLoaded { get; }
}
/// <inheritdoc cref="IProxyPoolLoader" />
public sealed class ProxyPoolLoader(IProxyPool pool, ILogger<ProxyPoolLoader> logger) : IProxyPoolLoader
{
private readonly IProxyPool _pool = pool ?? throw new ArgumentNullException(nameof(pool));
private readonly ILogger<ProxyPoolLoader> _logger = logger ?? throw new ArgumentNullException(nameof(logger));
private readonly Lock _gate = new();
private Task<int>? _load;
/// <inheritdoc />
public bool IsLoaded => _load is { IsCompleted: true };
/// <inheritdoc />
public Task<int> EnsureLoadedAsync()
{
if (_load is { } started)
{
return started;
}
lock (_gate)
{
// Deliberately not taking the caller's CancellationToken: the task is shared between
// the startup path and the proxy page, and one caller giving up must not cancel the
// load for the other.
_load ??= LoadAsync();
return _load;
}
}
private async Task<int> LoadAsync()
{
try
{
// Worth logging: an empty pool is almost always a filter or a switched-off feed
// rather than a network problem, and without this it looks identical to both.
_logger.LogInformation(
"Loading proxy pool (feed: {UseFeed}, protocols: {Protocols})",
_pool.Options.UseFeed,
_pool.Options.Protocols
);
var count = await _pool.RefreshAsync(CancellationToken.None).ConfigureAwait(false);
_logger.LogInformation("Proxy pool loaded with {Count} entries", count);
return count;
}
catch (Exception ex)
{
// Startup must not fail because a public list is unreachable; the user can retry from
// the Proxies page, and the app works without proxies in the meantime.
_logger.LogWarning(ex, "Could not load the proxy pool at startup");
return _pool.Entries.Count;
}
}
}
@@ -1,6 +1,7 @@
using AvParser.Core.Parsing;
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Proxies;
using AvParser.Infrastructure.Storage;
using AvParser.UI.Navigation;
using AvParser.UI.Services;
@@ -46,6 +47,7 @@ public static class UiServiceCollectionExtensions
services.AddSingleton<ProxiesViewModel>(static sp => new ProxiesViewModel(
sp.GetRequiredService<IProxyPool>(),
sp.GetRequiredService<IMutableProxySource>(),
sp.GetRequiredService<IProxyPoolLoader>(),
sp.GetRequiredService<ILogger<ProxiesViewModel>>()
));
services.AddSingleton<AboutViewModel>();
@@ -91,11 +91,13 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
/// <summary>Creates the page.</summary>
/// <param name="pool">The pool being managed.</param>
/// <param name="customSource">The user's editable list.</param>
/// <param name="loader">The shared startup load, joined so the page can report its outcome.</param>
/// <param name="logger">Diagnostics.</param>
/// <param name="mainThread">Scheduler for UI-affine updates; tests pass an immediate one.</param>
public ProxiesViewModel(
IProxyPool pool,
IMutableProxySource customSource,
IProxyPoolLoader loader,
ILogger<ProxiesViewModel> logger,
ISequencer? mainThread = null
)
@@ -104,6 +106,7 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
_customSource = customSource ?? throw new ArgumentNullException(nameof(customSource));
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
_mainThread = mainThread ?? RxSchedulers.MainThreadScheduler;
ArgumentNullException.ThrowIfNull(loader);
SearchText = string.Empty;
NewProxies = string.Empty;
@@ -145,6 +148,11 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
RemoveSelectedCommand.ThrownExceptions.Subscribe(OnCommandFailed);
Rebuild();
// The pool is normally already loading by the time this page is built, but joining the
// same operation means the page reports the outcome whether it is opened during the load
// or long after it. Not awaited — a constructor cannot be, and the task never throws.
_ = ReportInitialLoadAsync(loader);
}
/// <inheritdoc />
@@ -185,6 +193,19 @@ public partial class ProxiesViewModel : PageViewModel, IDisposable
/// <summary>Empties the custom list.</summary>
public ReactiveCommand<RxVoid, RxVoid> ClearCustomCommand { get; }
private async Task ReportInitialLoadAsync(IProxyPoolLoader loader)
{
var count = await loader.EnsureLoadedAsync().ConfigureAwait(false);
var message = Localizer.Instance.Format(
"Proxies.Status.PoolHolds",
Localizer.Instance.Plural("Proxies.Count.Proxies", count)
);
// Anything the user has done since — an add, a check — is more interesting than the
// startup count, so do not overwrite it.
OnUi(() => StatusMessage ??= message);
}
private async Task RefreshAsync(CancellationToken cancellationToken)
{
var count = await _pool.RefreshAsync(cancellationToken).ConfigureAwait(false);