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>
167 lines
6.0 KiB
C#
167 lines
6.0 KiB
C#
using AvParser.Core.Proxies;
|
|
using AvParser.Infrastructure.Proxies;
|
|
using AvParser.Infrastructure.Storage;
|
|
using Microsoft.Extensions.Logging.Abstractions;
|
|
|
|
namespace AvParser.Infrastructure.Tests;
|
|
|
|
public sealed class ProxyStateStoreTests : IDisposable
|
|
{
|
|
private readonly string _directory = Path.Combine(
|
|
Path.GetTempPath(),
|
|
"AvParserTests",
|
|
Guid.NewGuid().ToString("N")
|
|
);
|
|
|
|
private ProxyStateStore Create() => new(new AppPaths(_directory), NullLogger<ProxyStateStore>.Instance);
|
|
|
|
public void Dispose()
|
|
{
|
|
if (Directory.Exists(_directory))
|
|
{
|
|
Directory.Delete(_directory, recursive: true);
|
|
}
|
|
}
|
|
|
|
private static ProxyEntry Entry(string address, ProxySourceKind kind = ProxySourceKind.Feed)
|
|
{
|
|
ProxyEndpoint.TryParse(address, out var endpoint).ShouldBeTrue();
|
|
|
|
return new ProxyEntry(endpoint!, kind);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task An_absent_file_reads_as_nothing_remembered()
|
|
{
|
|
var store = Create();
|
|
|
|
(await store.LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_working_proxy_survives_a_round_trip()
|
|
{
|
|
var alive = Entry("http://1.2.3.4:8080");
|
|
alive.RecordSuccess(DateTimeOffset.UtcNow, TimeSpan.FromMilliseconds(250));
|
|
|
|
await Create().SaveAsync([alive], TestContext.Current.CancellationToken);
|
|
var state = await Create().LoadAsync(TestContext.Current.CancellationToken);
|
|
|
|
state.ShouldContainKey(alive.Endpoint.Key);
|
|
state[alive.Endpoint.Key].Alive.ShouldBeTrue();
|
|
state[alive.Endpoint.Key].LatencyMs.ShouldBe(250d);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Proxies_that_never_answered_are_not_remembered()
|
|
{
|
|
// The feed republishes a few thousand dead addresses every few minutes; carrying them over
|
|
// would bloat the file to save re-testing entries whose staleness tells us nothing.
|
|
var untried = Entry("http://1.2.3.4:8080");
|
|
var dead = Entry("http://5.6.7.8:3128");
|
|
dead.RecordProbe(DateTimeOffset.UtcNow, alive: false, latency: null, error: "timeout");
|
|
|
|
await Create().SaveAsync([untried, dead], TestContext.Current.CancellationToken);
|
|
|
|
(await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void Applying_remembered_state_restores_the_matching_entries_only()
|
|
{
|
|
var known = Entry("http://1.2.3.4:8080");
|
|
var unknown = Entry("http://9.9.9.9:8080");
|
|
var state = new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
|
|
{
|
|
[known.Endpoint.Key] = new(
|
|
known.Endpoint.ToString(),
|
|
Alive: true,
|
|
LatencyMs: 120,
|
|
SuccessCount: 7,
|
|
FailureCount: 2
|
|
),
|
|
};
|
|
|
|
ProxyStateStore.Apply([known, unknown], state).ShouldBe(1);
|
|
|
|
known.WasAliveOnLastRun.ShouldBeTrue();
|
|
known.Latency.ShouldBe(TimeSpan.FromMilliseconds(120));
|
|
known.SuccessCount.ShouldBe(7);
|
|
unknown.WasAliveOnLastRun.ShouldBeFalse();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_remembered_proxy_is_not_reported_live_until_it_answers_again()
|
|
{
|
|
// Otherwise a launch a week later would open the parser gate on week-old evidence, and the
|
|
// warm-up would skip the very proxies it was supposed to re-check.
|
|
var entry = Entry("http://1.2.3.4:8080");
|
|
var state = new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
|
|
{
|
|
[entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true),
|
|
};
|
|
|
|
ProxyStateStore.Apply([entry], state);
|
|
|
|
entry.Health.ShouldBe(ProxyHealthState.Unknown);
|
|
entry.IsBelievedAlive.ShouldBeTrue();
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_remembered_proxy_that_was_never_re_checked_is_still_remembered()
|
|
{
|
|
// The warm-up stops early, so most remembered entries end a session unprobed. Dropping
|
|
// them on save would erode the remembered set to nothing over a few launches.
|
|
var entry = Entry("http://1.2.3.4:8080");
|
|
ProxyStateStore.Apply(
|
|
[entry],
|
|
new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
|
|
{
|
|
[entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true),
|
|
}
|
|
);
|
|
|
|
await Create().SaveAsync([entry], TestContext.Current.CancellationToken);
|
|
|
|
(await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldContainKey(entry.Endpoint.Key);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task A_remembered_proxy_that_fails_its_re_check_is_forgotten()
|
|
{
|
|
var entry = Entry("http://1.2.3.4:8080");
|
|
ProxyStateStore.Apply(
|
|
[entry],
|
|
new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
|
|
{
|
|
[entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true),
|
|
}
|
|
);
|
|
|
|
entry.RecordProbe(DateTimeOffset.UtcNow, alive: false, latency: null, error: "timeout");
|
|
|
|
await Create().SaveAsync([entry], TestContext.Current.CancellationToken);
|
|
|
|
(await Create().LoadAsync(TestContext.Current.CancellationToken)).ShouldBeEmpty();
|
|
}
|
|
|
|
[Fact]
|
|
public void A_remembered_proxy_is_never_restored_into_a_quarantine()
|
|
{
|
|
// The window is wall-clock; a restart may be days later, so an expired sideline must not
|
|
// be resurrected — the whole point of remembering is to start from the good ones.
|
|
var entry = Entry("http://1.2.3.4:8080");
|
|
entry.RecordFailure(DateTimeOffset.UtcNow, TimeSpan.FromMinutes(5), TimeSpan.FromHours(1), 1);
|
|
entry.IsQuarantined(DateTimeOffset.UtcNow).ShouldBeTrue();
|
|
|
|
var restored = new Dictionary<string, ProxyStateRecord>(StringComparer.Ordinal)
|
|
{
|
|
[entry.Endpoint.Key] = new(entry.Endpoint.ToString(), Alive: true),
|
|
};
|
|
|
|
ProxyStateStore.Apply([entry], restored);
|
|
|
|
entry.IsQuarantined(DateTimeOffset.UtcNow).ShouldBeFalse();
|
|
}
|
|
}
|