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>
106 lines
4.1 KiB
C#
106 lines
4.1 KiB
C#
using AvParser.Core.Proxies;
|
|
|
|
namespace AvParser.Core.Tests.Proxies;
|
|
|
|
public class ProxyPoolWarmUpTests
|
|
{
|
|
private static readonly ProxyOptions Options = new() { ProbeConcurrency = 1 };
|
|
|
|
private static ProxyPool Build(out FakeProxyProbe probe, out FakeTimeProvider clock, params string[] hosts)
|
|
{
|
|
var source = new FakeProxySource(ProxySourceKind.Feed);
|
|
source.Endpoints.AddRange(hosts.Select(host => ProxyFactory.Endpoint(host)));
|
|
|
|
probe = new FakeProxyProbe();
|
|
clock = new FakeTimeProvider();
|
|
|
|
return new ProxyPool([source], probe, Options, clock);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task What_worked_last_time_is_tried_first()
|
|
{
|
|
// The whole point of remembering: a second launch should confirm the known-good ones
|
|
// rather than walking a list of a few thousand addresses from the top.
|
|
var pool = Build(out var probe, out var clock, "cold-a", "known-good", "cold-b");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
pool.Entries.Single(entry => entry.Endpoint.Host == "known-good")
|
|
.RestoreState(wasAlive: true, TimeSpan.FromMilliseconds(30), 12, 1, clock.GetUtcNow());
|
|
|
|
pool.WarmUpOrder()[0].Endpoint.Host.ShouldBe("known-good");
|
|
|
|
// Asking for two: one is already known live, so the warm-up has a reason to probe at all.
|
|
probe.DefaultAlive = true;
|
|
await pool.WarmUpAsync(2, cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
probe.Probed[0].ShouldContain("known-good");
|
|
}
|
|
|
|
[Fact]
|
|
public async Task The_faster_of_two_remembered_proxies_goes_first()
|
|
{
|
|
var pool = Build(out _, out var clock, "slow", "fast");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
pool.Entries.Single(entry => entry.Endpoint.Host == "slow")
|
|
.RestoreState(wasAlive: true, TimeSpan.FromMilliseconds(900), 3, 0, clock.GetUtcNow());
|
|
pool.Entries.Single(entry => entry.Endpoint.Host == "fast")
|
|
.RestoreState(wasAlive: true, TimeSpan.FromMilliseconds(40), 3, 0, clock.GetUtcNow());
|
|
|
|
pool.WarmUpOrder().Select(entry => entry.Endpoint.Host).ShouldBe(["fast", "slow"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Warming_up_stops_once_the_target_is_met()
|
|
{
|
|
// Probing all 200 when 2 were asked for would turn every launch into a full sweep.
|
|
var pool = Build(out var probe, out _, [.. Enumerable.Range(0, 200).Select(i => $"h{i}")]);
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
probe.DefaultAlive = true;
|
|
|
|
var live = await pool.WarmUpAsync(2, cancellationToken: TestContext.Current.CancellationToken);
|
|
|
|
live.ShouldBeGreaterThanOrEqualTo(2);
|
|
probe.ProbeCount.ShouldBeLessThan(200);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Warming_up_probes_nothing_when_enough_are_already_live()
|
|
{
|
|
var pool = Build(out var probe, out var clock, "a", "b");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
foreach (var entry in pool.Entries)
|
|
{
|
|
entry.RecordSuccess(clock.GetUtcNow(), TimeSpan.FromMilliseconds(10));
|
|
}
|
|
|
|
(await pool.WarmUpAsync(2, cancellationToken: TestContext.Current.CancellationToken)).ShouldBe(2);
|
|
probe.ProbeCount.ShouldBe(0);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_quarantined_proxy_is_not_warmed_up()
|
|
{
|
|
var pool = Build(out _, out var clock, "a", "b");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
|
|
pool.Entries.Single(entry => entry.Endpoint.Host == "a")
|
|
.RecordFailure(clock.GetUtcNow(), TimeSpan.FromMinutes(5), TimeSpan.FromHours(1), 1);
|
|
|
|
pool.WarmUpOrder().Select(entry => entry.Endpoint.Host).ShouldBe(["b"]);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Nothing_live_reads_as_nothing_live()
|
|
{
|
|
var pool = Build(out var probe, out _, "a", "b");
|
|
await pool.RefreshAsync(TestContext.Current.CancellationToken);
|
|
probe.DefaultAlive = false;
|
|
|
|
(await pool.WarmUpAsync(1, cancellationToken: TestContext.Current.CancellationToken)).ShouldBe(0);
|
|
pool.LiveCount.ShouldBe(0);
|
|
}
|
|
}
|