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
@@ -0,0 +1,142 @@
using AvParser.Core.Proxies;
using AvParser.Core.Settings;
using AvParser.Infrastructure.Settings;
using AvParser.Infrastructure.Storage;
using Microsoft.Extensions.Logging.Abstractions;
namespace AvParser.Infrastructure.Tests;
public sealed class JsonSettingsServiceTests : IDisposable
{
private readonly string _directory = Path.Combine(
Path.GetTempPath(),
"AvParserTests",
Guid.NewGuid().ToString("N")
);
private JsonSettingsService Create() => new(new AppPaths(_directory), NullLogger<JsonSettingsService>.Instance);
private void WriteSettings(string json)
{
Directory.CreateDirectory(_directory);
File.WriteAllText(Path.Combine(_directory, "settings.json"), json);
}
public void Dispose()
{
if (Directory.Exists(_directory))
{
Directory.Delete(_directory, recursive: true);
}
}
[Fact]
public void An_absent_file_yields_the_defaults()
{
using var service = Create();
service.Current.ShouldBe(new AppSettings());
}
/// <summary>
/// A settings file written before a setting existed must not zero that setting.
/// </summary>
/// <remarks>
/// This is a regression test with a real bug behind it. Defaults used to live on property
/// initialisers, which the source-generated deserialiser does not run — so an older file came
/// back with <c>default(T)</c> everywhere, switching the proxy feed off, clearing the protocol
/// filter and zeroing the probe timeout. The app then loaded an empty pool and looked as if
/// the network had failed.
/// </remarks>
[Fact]
public void A_file_from_an_older_version_keeps_the_defaults_for_settings_it_predates()
{
WriteSettings(
"""
{
"theme": "System",
"lastParserId": "delimited",
"windowWidth": 1280,
"windowHeight": 800,
"windowMaximized": false,
"minimumLogLevel": "Information"
}
"""
);
using var service = Create();
var settings = service.Current;
settings.ProxyUseFeed.ShouldBeTrue();
settings.ProxyProtocols.ShouldBe(ProxyProtocolFilter.All);
settings.ProxyProbeTimeoutSeconds.ShouldBe(8);
settings.ProxyProbeConcurrency.ShouldBe(64);
settings.ProxyProbeUrl.ShouldNotBeNullOrEmpty();
settings.Language.ShouldBe(AppLanguage.System);
// And the values the file did carry must survive.
settings.LastParserId.ShouldBe("delimited");
settings.MinimumLogLevel.ShouldBe("Information");
}
[Fact]
public void Options_built_from_an_older_file_still_consult_the_feed()
{
WriteSettings("""{ "theme": "Dark" }""");
using var service = Create();
var options = service.Current.ToProxyOptions();
options.UseFeed.ShouldBeTrue();
options.Protocols.ShouldBe(ProxyProtocolFilter.All);
}
[Fact]
public void An_empty_protocol_filter_is_treated_as_all()
{
// Nothing in the UI can produce None, but a hand-edited file can — and a pool that
// silently matches nothing is the least useful possible reading of it.
var options = new AppSettings(ProxyProtocols: ProxyProtocolFilter.None).ToProxyOptions();
options.Protocols.ShouldBe(ProxyProtocolFilter.All);
}
[Fact]
public void Out_of_range_values_are_clamped_rather_than_thrown()
{
var options = new AppSettings(
ProxyProbeTimeoutSeconds: 0,
ProxyProbeConcurrency: 100_000,
ProxyProbeUrl: "not a url"
).ToProxyOptions();
options.ProbeTimeout.ShouldBe(TimeSpan.FromSeconds(1));
options.ProbeConcurrency.ShouldBe(512);
options.ProbeUrl.ShouldBe(new ProxyOptions().ProbeUrl);
}
[Fact]
public void A_corrupt_file_falls_back_to_the_defaults_instead_of_failing_to_start()
{
WriteSettings("{ this is not json");
using var service = Create();
service.Current.ShouldBe(new AppSettings());
}
[Fact]
public async Task Updates_round_trip_through_the_file()
{
using (var service = Create())
{
service.Update(current => current with { Theme = AppTheme.Dark, ProxyUseFeed = false });
await service.FlushAsync(TestContext.Current.CancellationToken);
}
using var reopened = Create();
reopened.Current.Theme.ShouldBe(AppTheme.Dark);
reopened.Current.ProxyUseFeed.ShouldBeFalse();
}
}
@@ -0,0 +1,102 @@
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using Microsoft.Extensions.Logging.Abstractions;
namespace AvParser.Infrastructure.Tests;
public class ProxyPoolLoaderTests
{
private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool)
{
source = new CountingSource();
pool = new ProxyPool([source], new NeverProbe(), new ProxyOptions());
return new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance);
}
[Fact]
public async Task Loading_fills_the_pool()
{
var loader = Build(out _, out var pool);
(await loader.EnsureLoadedAsync()).ShouldBe(2);
pool.Entries.Count.ShouldBe(2);
loader.IsLoaded.ShouldBeTrue();
}
[Fact]
public async Task The_feed_is_fetched_once_however_many_callers_there_are()
{
var loader = Build(out var source, out _);
// Startup and the proxy page both ask; a second download of a 600 KB list would be waste.
await Task.WhenAll(loader.EnsureLoadedAsync(), loader.EnsureLoadedAsync(), loader.EnsureLoadedAsync());
await loader.EnsureLoadedAsync();
source.Calls.ShouldBe(1);
}
[Fact]
public async Task Concurrent_callers_share_one_operation()
{
var loader = Build(out _, out _);
var first = loader.EnsureLoadedAsync();
var second = loader.EnsureLoadedAsync();
first.ShouldBeSameAs(second);
await first;
}
[Fact]
public async Task A_source_that_throws_does_not_take_startup_down()
{
var pool = new ProxyPool([new ThrowingSource()], new NeverProbe(), new ProxyOptions());
var loader = new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance);
// The app has to start whether or not a public list is reachable.
(await loader.EnsureLoadedAsync()).ShouldBe(0);
}
private sealed class CountingSource : IProxySource
{
public int Calls { get; private set; }
public string Id => "counting";
public string DisplayName => "Counting source";
public ProxySourceKind Kind => ProxySourceKind.Feed;
public Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default)
{
Calls++;
return Task.FromResult<IReadOnlyList<ProxyEndpoint>>([
new ProxyEndpoint(ProxyProtocol.Http, "1.2.3.4", 8080),
new ProxyEndpoint(ProxyProtocol.Socks5, "5.6.7.8", 1080),
]);
}
}
private sealed class ThrowingSource : IProxySource
{
public string Id => "throwing";
public string DisplayName => "Throwing source";
public ProxySourceKind Kind => ProxySourceKind.Feed;
public Task<IReadOnlyList<ProxyEndpoint>> GetProxiesAsync(CancellationToken cancellationToken = default) =>
throw new HttpRequestException("upstream is down");
}
private sealed class NeverProbe : IProxyProbe
{
public Task<ProxyProbeResult> ProbeAsync(
ProxyEndpoint endpoint,
ProxyOptions options,
CancellationToken cancellationToken = default
) => Task.FromResult(ProxyProbeResult.Failure("not used"));
}
}
@@ -3,6 +3,7 @@ using Avalonia.Headless.XUnit;
using Avalonia.Threading;
using Avalonia.VisualTree;
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using AvParser.UI.ViewModels;
using AvParser.UI.Views;
using Microsoft.Extensions.Logging.Abstractions;
@@ -23,6 +24,7 @@ public class ProxiesViewTests
var viewModel = new ProxiesViewModel(
pool,
custom,
new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
@@ -1,4 +1,5 @@
using AvParser.Core.Proxies;
using AvParser.Infrastructure.Proxies;
using AvParser.UI.Tests.Fakes;
using AvParser.UI.ViewModels;
using Microsoft.Extensions.Logging.Abstractions;
@@ -23,6 +24,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
@@ -37,11 +39,40 @@ public class ProxiesViewModelTests
private static Task Run<TResult>(ReactiveUI.ReactiveCommand<RxVoid, TResult> command) =>
command.Execute().ToTask(TestContext.Current.CancellationToken);
/// <summary>Polls until the throttled rebuild has caught up, or gives up.</summary>
/// <remarks>
/// The page coalesces pool changes over 250 ms, so anything driven by the pool rather than by
/// a command needs to be waited for rather than asserted on the next line.
/// </remarks>
private static async Task WaitUntil(Func<bool> condition)
{
for (var attempt = 0; attempt < 50 && !condition(); attempt++)
{
await Task.Delay(20, TestContext.Current.CancellationToken);
}
}
[Fact]
public void Starts_empty()
public async Task The_pool_loads_without_the_user_pressing_refresh()
{
var (page, _, _) = Build("a", "b");
// Deliberately no RefreshCommand: opening the page joins the startup load, which is what
// stops the list from being empty until someone presses the button.
await WaitUntil(() => page.TotalCount == 2);
page.TotalCount.ShouldBe(2);
page.Proxies.Count.ShouldBe(2);
page.StatusMessage.ShouldNotBeNull();
}
[Fact]
public async Task An_empty_pool_stays_empty()
{
var (page, _, _) = Build();
await WaitUntil(() => page.StatusMessage is not null);
page.Proxies.ShouldBeEmpty();
page.TotalCount.ShouldBe(0);
}
@@ -79,6 +110,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);
@@ -160,6 +192,7 @@ public class ProxiesViewModelTests
var page = new ProxiesViewModel(
pool,
custom,
new ProxyPoolLoader(pool, NullLogger<ProxyPoolLoader>.Instance),
NullLogger<ProxiesViewModel>.Instance,
ImmediateSequencer.Instance
);