Files
av-parser/tests/AvParser.Infrastructure.Tests/ProxyPoolLoaderTests.cs
T
Leonid PershinandClaude Opus 5 44fb0d3a5f Gate network parsers on a working proxy and remember what worked
The pool now warms up from what the previous run learned instead of starting
cold every launch. Startup probes the remembered proxies first, stops as soon
as ProxyMinimumLive of them answer, and writes the survivors to
proxies.state.json after the warm-up and again on shutdown. Only proxies that
ever answered are stored: the feed republishes a few thousand dead addresses
every five minutes, and "was dead an hour ago" says almost nothing.

Remembered state is a hint, not a verdict. A restored proxy sorts first in the
warm-up queue but is not counted live until it answers in this session -
otherwise a launch a week later would report live proxies it had never spoken
to, the warm-up would skip the very entries it exists to re-check, and the
parser gate would open on week-old evidence.

That gate is the other half: a parser declaring RequiresNetwork will not run
while the pool has nothing live. The Parse page disables the run button and
shows a banner that leads to the Proxies page. Parsers that work on pasted text
are never gated - they have nothing to route, and blocking them would make the
app useless whenever the public lists are down. Two new settings cover the
escape hatch and the target: "allow network parsers without a proxy" and how
many live proxies to find at startup.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 19:11:51 +03:00

154 lines
5.0 KiB
C#

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) =>
Build(out source, out pool, new MemoryStateStore());
private static ProxyPoolLoader Build(out CountingSource source, out ProxyPool pool, IProxyStateStore stateStore)
{
source = new CountingSource();
pool = new ProxyPool([source], new NeverProbe(), new ProxyOptions());
return new ProxyPoolLoader(pool, stateStore, NullLogger<ProxyPoolLoader>.Instance);
}
[Fact]
public async Task Loading_fills_the_pool()
{
var loader = Build(out _, out var pool);
(await loader.EnsureLoadedAsync()).Total.ShouldBe(2);
pool.Entries.Count.ShouldBe(2);
loader.IsLoaded.ShouldBeTrue();
}
[Fact]
public async Task What_the_previous_run_learned_is_restored_onto_the_fresh_list()
{
// The feed republishes the same addresses every few minutes; the point of remembering is
// that a proxy known to work is not re-discovered from scratch on every launch.
var stateStore = new MemoryStateStore
{
State =
{
["http://1.2.3.4:8080"] = new ProxyStateRecord("http://1.2.3.4:8080", Alive: true, LatencyMs: 120),
},
};
var loader = Build(out _, out _, stateStore);
var result = await loader.EnsureLoadedAsync();
result.Restored.ShouldBe(1);
}
[Fact]
public async Task The_pool_state_is_written_back_after_a_load()
{
var stateStore = new MemoryStateStore();
var loader = Build(out _, out _, stateStore);
await loader.EnsureLoadedAsync();
stateStore.Saves.ShouldBeGreaterThan(0);
}
[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, new MemoryStateStore(), NullLogger<ProxyPoolLoader>.Instance);
// The app has to start whether or not a public list is reachable.
(await loader.EnsureLoadedAsync()).Total.ShouldBe(0);
}
private sealed class MemoryStateStore : IProxyStateStore
{
public Dictionary<string, ProxyStateRecord> State { get; } = new(StringComparer.Ordinal);
public int Saves { get; private set; }
public Task<IReadOnlyDictionary<string, ProxyStateRecord>> LoadAsync(
CancellationToken cancellationToken = default
) => Task.FromResult<IReadOnlyDictionary<string, ProxyStateRecord>>(State);
public Task SaveAsync(IEnumerable<ProxyEntry> entries, CancellationToken cancellationToken = default)
{
Saves++;
return Task.CompletedTask;
}
}
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"));
}
}